diff --git a/.gitignore b/.gitignore index 8345343..38af9a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ cache/ out/ -.env +.env* test-command.txt broadcast/ .DS_Store diff --git a/README.md b/README.md index b773038..3682d88 100644 --- a/README.md +++ b/README.md @@ -177,5 +177,28 @@ To set up the project locally, follow these steps: 3. **Compile Contracts** ```base - forge build --optimize --optimizer-runs 200 --use solc:0.8.20 + forge build --optimize --optimizer-runs 200 --use solc:0.8.28 --sizes ``` + +## Deployment + +```bash +# Setup env var for deployment +cp env.production.abstract-beta.template .env +# Modify .env and update all relevant values + +# To dry-run the deploy scripts (to Ethereum mainnet) +forge script scripts/deploy.abstract-beta.s.sol --use solc:0.8.28 --optimize --optimizer-runs 200 --rpc-url https://your/rpc/endpt + +# To deploy to Ethereum mainnet (and verify on Etherscan) +forge script scripts/deploy.abstract-beta.s.sol --use solc:0.8.28 --optimize --optimizer-runs 200 --rpc-url https://your/rpc/endpt --broadcast --verify --verifier etherscan --etherscan-api-key $ETHERSCAN_API_KEY + +# The scripts will save the Safe txs to out/safeTxs.json, which you can import to the Transaction Builder in Safe web UI for execution +# It should include txs for: +# 1. Approve MetavestController (without recipient overrides) for escrowing the vesting token +# 2. Approve MetavestController (with recipient overrides) for escrowing the vesting token +# 3~. Create Metavest for each grantee (one transaction each) +# +# Always double check the content before signing: +cat out/safeTxs.json +``` diff --git a/env.production.abstract-beta.template b/env.production.abstract-beta.template new file mode 100644 index 0000000..f36cb84 --- /dev/null +++ b/env.production.abstract-beta.template @@ -0,0 +1,34 @@ +# TODO (1) fill in your deployer private key +DEPLOYER_PRIVATE_KEY= + +# TODO (2) Update number of grantees +NUM_GRANTEES=2 + +# GRANTEE_CONTROLLER_TYPE: +# - 0: without recipient override (grantee can specify their desired recipient addresses) +# - 1: with recipient override (authority overrides all grantees' recipient address) + +# Instead of the commonly-used "hire date", MetaVesT's vesting start time is defined as "when the cliff happens". +# Ex. 10000 token (18-decimals) grants vesting over 4 years, hired on 2025/01/01 with a 6-month vesting cliff, unlock over 1 year (unlock start time TBD) +# Parameters may look like the example below: + +GRANTEE_ADDR_0=0x48d206948C366396a86A449DdD085FDbfC280B4b # grantee EOA (grantee must use this account to send txs to the grant smart contract) +GRANTEE_AMOUNT_0=10000000000000000000000 # 10000 token +GRANTEE_VESTING_START_TIME_0=1751328000 # 2025/07/01 = 6 months after hired date +GRANTEE_VESTING_CLIFF_CREDIT_0=1250000000000000000000 # (10000 * 6 / 48) * 1e18 +GRANTEE_VESTING_RATE_0=79274479959411 # 10000e18 // (4 * 365 * 24 * 3600) +GRANTEE_UNLOCKING_CLIFF_CREDIT_0=0 +GRANTEE_UNLOCK_RATE_0=317097919837645 # 10000e18 // (1 * 365 * 24 * 3600) +GRANTEE_CONTROLLER_TYPE_0=0 + +# Ex2. 20000 token (18-decimals) grants vesting over 4 years, hired on 2024/10/01 with a 6-month vesting cliff, unlock over 1 year (unlock start time TBD) +GRANTEE_ADDR_1=0x5ff4e90Efa2B88cf3cA92D63d244a78a88219Abf # grantee EOA (grantee must use this account to send txs to the grant smart contract) +GRANTEE_AMOUNT_1=20000000000000000000000 # 20000 token (18-decimals) +GRANTEE_VESTING_START_TIME_1=1743465600 # 2025/04/01 = 6 months after hired date +GRANTEE_VESTING_CLIFF_CREDIT_1=2500000000000000000000 # (20000 * 6 / 48) * 1e18 +GRANTEE_VESTING_RATE_1=158548959918822 # 20000e18 // (4 * 365 * 24 * 3600) +GRANTEE_UNLOCKING_CLIFF_CREDIT_1=0 +GRANTEE_UNLOCK_RATE_1=634195839675291 # 20000e18 // (1 * 365 * 24 * 3600) +GRANTEE_CONTROLLER_TYPE_1=1 + +# TODO (3) Update the above grantee parameters as needed diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..9e859bf --- /dev/null +++ b/foundry.toml @@ -0,0 +1,5 @@ +[profile.default] +fs_permissions = [ + { access = "read-write", path = "out" }, + { access = "read-write", path = "res" }, +] diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..a6a22a9 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +openzeppelin-contracts=lib/openzeppelin-contracts/contracts +openzeppelin-contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts diff --git a/res/safeTxs-development.abstract-beta.json b/res/safeTxs-development.abstract-beta.json new file mode 100644 index 0000000..5885859 --- /dev/null +++ b/res/safeTxs-development.abstract-beta.json @@ -0,0 +1,35 @@ +{ + "meta": { + "name": "Transactions Batch", + "description": "", + "createdFromSafeAddress": "", + "txBuilderVersion": "", + "createdFromOwnerAddress": "", + "checksum": "" + }, + "createdAt": 1766526996000, + "transactions": [ + { + "value": "0", + "data": "0x095ea7b3000000000000000000000000cef4761cc320fdc28034f00b754e8b608028f42000000000000000000000000000000000000000000000021e19e0c9bab2400000", + "to": "0xA581b1b0D31B0528C20801E56EeEaF0834a8C907" + }, + { + "to": "0xA581b1b0D31B0528C20801E56EeEaF0834a8C907", + "data": "0x095ea7b3000000000000000000000000387116083c8788426fc91d6689972e2ad6d5451200000000000000000000000000000000000000000000043c33c1937564800000", + "value": "0" + }, + { + "value": "0", + "data": "0x99ec93bb000000000000000000000000000000000000000000000000000000000000000200000000000000000000000048d206948c366396a86a449ddd085fdbfc280b4b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000021e19e0c9bab2400000000000000000000000000000000000000000000000000043c33c1937564800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048198737bd730000000000000000000000000000000000000000000000000000000068632500000000000000000000000000000000000000000000000000000120661cdef5cd000000000000000000000000000000000000000000000000000000006d182000000000000000000000000000a581b1b0d31b0528c20801e56eeeaf0834a8c907000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000b9e5ae881f36083cb914205f19eaa265d76eef53000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "to": "0xCEf4761CC320Fdc28034f00B754E8b608028f420" + }, + { + "data": "0x99ec93bb00000000000000000000000000000000000000000000000000000000000000020000000000000000000000005ff4e90efa2b88cf3ca92d63d244a78a88219abf000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000043c33c19375648000000000000000000000000000000000000000000000000000878678326eac9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000090330e6f7ae60000000000000000000000000000000000000000000000000000000067eb2c80000000000000000000000000000000000000000000000000000240cc39bdeb9b000000000000000000000000000000000000000000000000000000006d182000000000000000000000000000a581b1b0d31b0528c20801e56eeeaf0834a8c907000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000b9e5ae881f36083cb914205f19eaa265d76eef53000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "to": "0x387116083c8788426Fc91d6689972e2ad6d54512", + "value": "0" + } + ], + "version": "1.0", + "chainId": "1" +} \ No newline at end of file diff --git a/scripts/deploy.abstract-beta.s.sol b/scripts/deploy.abstract-beta.s.sol new file mode 100644 index 0000000..dff517a --- /dev/null +++ b/scripts/deploy.abstract-beta.s.sol @@ -0,0 +1,217 @@ +import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; +import {AbstractBetaSepolia} from "./lib/AbstractBetaSepolia.sol"; +import {AbstractBeta} from "./lib/AbstractBeta.sol"; +import {SafeUtils} from "./lib/SafeUtils.sol"; +import {MockERC20} from "../test/mocks/MockERC20.sol"; +import {GnosisTransaction} from "./lib/safe.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {Script, console2} from "forge-std/Script.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {BaseAllocation} from "../src/BaseAllocation.sol"; + +contract DeployAbstractBetaScript is Script { + function run() public { + runWithArgs( + // Ethereum mainnet + "MetaLexMetaVest.Abstract.v1.0.0", + vm.envUint("DEPLOYER_PRIVATE_KEY"), + AbstractBetaSepolia.getDefault() + + // Sepolia +// "MetaLexMetaVest.Abstract.v0.1.0", +// vm.envUint("DEPLOYER_PRIVATE_KEY"), +// AbstractBetaSepolia.getDefault() + ); + } + + function runWithArgs( + string memory saltStr, + uint256 deployerPrivateKey, + AbstractBeta.Config memory config + ) public returns ( + metavestController controllerWithoutOverride, + metavestController controllerWithOverride, + AbstractBeta.GrantInfo[] memory, + GnosisTransaction[] memory provisionSafeTxs, + GnosisTransaction[] memory grantSafeTxs, + GnosisTransaction[] memory allSafeTxs + ) { + bytes32 salt = keccak256(bytes(saltStr)); + address deployer = vm.addr(deployerPrivateKey); + + console2.log(""); + console2.log("=== DeployAbstractBetaControllersScript ==="); + console2.log("saltStr: %s", saltStr); + console2.log("deployer: %s", deployer); + console2.log("vesting token: %s", config.vestingToken); + console2.log("payment token: %s", config.paymentToken); + console2.log(""); + + AbstractBeta.GrantInfo[] memory grants = AbstractBeta.loadGrants(); + + vm.startBroadcast(deployerPrivateKey); + + // (1) Deploy factories and controllers + + { + VestingAllocationFactory vestingAllocationFactory = new VestingAllocationFactory{salt: salt}(); + TokenOptionFactory tokenOptionFactory = new TokenOptionFactory{salt: salt}(); + RestrictedTokenFactory restrictedTokenFactory = new RestrictedTokenFactory{salt: salt}(); + + config.controllerWithoutOverride = new metavestController{salt: bytes32(uint256(salt) + 0)}( + config.authority, // _authority + config.dao, // _dao + address(0), // _recipientOverride + address(vestingAllocationFactory), + address(tokenOptionFactory), + address(restrictedTokenFactory) + ); + + config.controllerWithOverride = new metavestController{salt: bytes32(uint256(salt) + 1)}( + config.authority, // _authority + config.dao, // _dao + config.escrowMultisig, // _recipientOverride + address(vestingAllocationFactory), + address(tokenOptionFactory), + address(restrictedTokenFactory) + ); + + console2.log("Deployed controllers:"); + console2.log(" controllerWithoutOverride: ", address(config.controllerWithoutOverride)); + console2.log(" controllerWithOverride: ", address(config.controllerWithOverride)); + console2.log(""); + } + + vm.stopBroadcast(); + + // (2a) Prepare Safe txs (vesting token approval & grants creation) + + // Calculate total vesting token needed for all grants + uint256 totalVestingTokenAmountWithoutOverride = 0; + uint256 totalVestingTokenAmountWithOverride = 0; + for (uint256 i = 0; i < grants.length; i++) { + if (grants[i].controllerType == AbstractBeta.ControllerType.WithoutOverride) { + totalVestingTokenAmountWithoutOverride += grants[i].amount; + } else { + totalVestingTokenAmountWithOverride += grants[i].amount; + } + } + + console2.log("Preparing Safe tx for approving vesting tokens..."); + provisionSafeTxs = new GnosisTransaction[](2); + provisionSafeTxs[0] = GnosisTransaction({ + to: config.vestingToken, + value: 0, + data: abi.encodeWithSelector( + ERC20.approve.selector, + address(config.controllerWithoutOverride), + totalVestingTokenAmountWithoutOverride + ) + }); + provisionSafeTxs[1] = GnosisTransaction({ + to: config.vestingToken, + value: 0, + data: abi.encodeWithSelector( + ERC20.approve.selector, + address(config.controllerWithOverride), + totalVestingTokenAmountWithOverride + ) + }); + + console2.log("Preparing Safe txs for grants creation:"); + grantSafeTxs = _generateGrantSafeTxs(config, grants); + + allSafeTxs = new GnosisTransaction[](provisionSafeTxs.length + grantSafeTxs.length); + + // (2b) Create Safe txs JSON file + + { + uint256 safeTxIdx = 0; + for (uint256 i = 0; i < provisionSafeTxs.length; i++) { + allSafeTxs[safeTxIdx++] = provisionSafeTxs[i]; + } + for (uint256 i = 0; i < grantSafeTxs.length; i++) { + allSafeTxs[safeTxIdx++] = grantSafeTxs[i]; + } + + string memory safeTxJson = SafeUtils.formatSafeTxJson(allSafeTxs); + + console2.log("Safe tx JSON (can be imported to Safe Transaction Builder):"); + console2.log("==== JSON data start ===="); + console2.log(safeTxJson); + console2.log("==== JSON data end ===="); + + string memory safeTxJsonPath = "./out/safeTxs.json"; + vm.writeJson(safeTxJson, safeTxJsonPath); + console2.log("JSON file written to: %s", safeTxJsonPath); + } + + return ( + config.controllerWithoutOverride, + config.controllerWithOverride, + grants, + provisionSafeTxs, + grantSafeTxs, + allSafeTxs + ); + } + + function _generateGrantSafeTxs( + AbstractBeta.Config memory config, + AbstractBeta.GrantInfo[] memory grants + ) internal returns (GnosisTransaction[] memory safeTxs) { + safeTxs = new GnosisTransaction[](grants.length); + + for (uint256 i = 0; i < grants.length; i++) { + AbstractBeta.GrantInfo memory grant = grants[i]; + + metavestController controller = _getController(grant, config); + + safeTxs[i] = GnosisTransaction({ + to: address(controller), + value: 0, + data: abi.encodeWithSignature( + "createMetavest(uint8,address,address,(uint256,uint128,uint128,uint160,uint48,uint160,uint48,address),(uint256,bool,bool,address[])[],uint256,address,uint256,uint256)", + metavestController.metavestType.RestrictedTokenAward, + grant.grantee, + address(0), // no preference + BaseAllocation.Allocation({ + tokenContract: config.vestingToken, + tokenStreamTotal: grant.amount, + vestingCliffCredit: grant.vestingCliffCredit, + unlockingCliffCredit: grant.unlockingCliffCredit, + vestingRate: grant.vestingRate, + vestingStartTime: grant.vestingStartTime, + unlockRate: grant.unlockRate, + unlockStartTime: config.unlockStartTime + }), + new BaseAllocation.Milestone[](0), + config.exercisePrice, + address(config.paymentToken), + config.shortStopDuration, + 0 // no-op: _longStopDate + ) + }); + + console2.log(" #%d:", i + 1); + console2.log(" grantee: %s", grant.grantee); + console2.log(" amount: %d", grant.amount); + console2.log(" vestingStartTime: %d", grant.vestingStartTime); + console2.log(" vestingCliffCredit: %d", grant.vestingCliffCredit); + console2.log(" vestingRate: %d", grant.vestingRate); + console2.log(" unlockingCliffCredit: %d", grant.unlockingCliffCredit); + console2.log(" unlockRate: %d", grant.unlockRate); + console2.log(" controllerType: %d", uint8(grant.controllerType)); + console2.log(""); + } + } + + function _getController(AbstractBeta.GrantInfo memory grant, AbstractBeta.Config memory config) internal returns (metavestController) { + return (grant.controllerType == AbstractBeta.ControllerType.WithoutOverride) + ? config.controllerWithoutOverride + : config.controllerWithOverride; + } +} diff --git a/scripts/deploy.mock-erc20.s.sol b/scripts/deploy.mock-erc20.s.sol new file mode 100644 index 0000000..f17f9e6 --- /dev/null +++ b/scripts/deploy.mock-erc20.s.sol @@ -0,0 +1,35 @@ +import {Script, console2} from "forge-std/Script.sol"; +import {MockERC20} from "../test/mocks/MockERC20.sol"; + +contract DeployMockErc20Script is Script { + function run() public { + + string memory saltStr = "MetaLexMetaVest.Abstract.mockPaymentToken.dev.0"; + bytes32 salt = keccak256(bytes(saltStr)); + + string memory tokenName = "Payment Token"; + string memory tokenSymbol = "PAY"; + uint8 tokenDecimals = 6; + + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + console2.log(""); + console2.log("=== DeployMockErc20Script ==="); + console2.log("saltStr: %s", saltStr); + console2.log("deployer: %s", deployer); + console2.log(""); + + vm.startBroadcast(deployerPrivateKey); + + MockERC20 mockToken = new MockERC20{salt: salt}(tokenName, tokenSymbol, tokenDecimals); + console2.log("deployed mock token: %s", address(mockToken)); + + vm.stopBroadcast(); + + console2.log("Deployed addresses:"); + console2.log(" mock token: %s", address(mockToken)); + console2.log(" decimals: %d", mockToken.decimals()); + console2.log(""); + } +} \ No newline at end of file diff --git a/scripts/lib/AbstractBeta.sol b/scripts/lib/AbstractBeta.sol new file mode 100644 index 0000000..f9eebf4 --- /dev/null +++ b/scripts/lib/AbstractBeta.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.28; + +import {Vm} from "forge-std/Vm.sol"; +import {CommonBase} from "forge-std/Base.sol"; +import {console2} from "forge-std/Console2.sol"; +import {BaseAllocation} from "../../src/BaseAllocation.sol"; +import {metavestController} from "../../src/MetaVesTController.sol"; + +library AbstractBeta { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + error UnexpectedControllerType(uint256 controllerType); + + enum ControllerType { + WithoutOverride, + WithOverride + } + + struct Config { + + // External dependencies + + address vestingToken; + address paymentToken; + + // Authority + + address dao; + address authority; + address escrowMultisig; + + uint48 unlockStartTime; + uint256 exercisePrice; + uint256 shortStopDuration; + + // Grants (without override) (grantee can specify their desired recipient addresses) + metavestController controllerWithoutOverride; + + // Grants (with override) (authority overrides all grantees' recipient address) + metavestController controllerWithOverride; + } + + struct GrantInfo { + address grantee; + uint256 amount; + uint128 vestingCliffCredit; + uint128 unlockingCliffCredit; + uint160 vestingRate; + uint48 vestingStartTime; + uint160 unlockRate; + // Note unlockStartTime, exercisePrice and shortStopDuration are universal and not grant-specific + ControllerType controllerType; + address metavest; + } + + function getDefault() internal view returns(Config memory) { + return Config({ + + // External dependencies + + vestingToken: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD: Abstract token + paymentToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // TODO TBD: USDC? + + // Authority + + dao: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD + authority: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD + escrowMultisig: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD + + // Sat Jan 1 00:00:00 UTC 2028 + unlockStartTime: 1830297600, // TODO TBD + exercisePrice: 1e6, // TODO TBD + shortStopDuration: 0, // TODO TBD + + // Grants (without override) (grantee can specify their desired recipient addresses) + controllerWithoutOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF), // TODO TBD + + // Grants (with override) (authority overrides all grantees' recipient address) + controllerWithOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF) // TODO TBD + }); + } + + function loadGrants() internal view returns(GrantInfo[] memory grants) { + uint256 numGrantees = vm.envOr("NUM_GRANTEES", uint256(0)); + console2.log("Loading number of grantees: %d", numGrantees); + + grants = new GrantInfo[](numGrantees); + for (uint i = 0; i < numGrantees ; i++) { + grants[i] = GrantInfo({ + grantee: address(uint160(vm.envUint(string(abi.encodePacked("GRANTEE_ADDR_", vm.toString(i)))))), + amount: vm.envUint(string(abi.encodePacked("GRANTEE_AMOUNT_", vm.toString(i)))), + vestingStartTime: uint48(vm.envUint(string(abi.encodePacked("GRANTEE_VESTING_START_TIME_", vm.toString(i))))), + vestingCliffCredit: uint128(vm.envUint(string(abi.encodePacked("GRANTEE_VESTING_CLIFF_CREDIT_", vm.toString(i))))), + vestingRate: uint160(vm.envUint(string(abi.encodePacked("GRANTEE_VESTING_RATE_", vm.toString(i))))), + unlockingCliffCredit: uint128(vm.envUint(string(abi.encodePacked("GRANTEE_UNLOCKING_CLIFF_CREDIT_", vm.toString(i))))), + unlockRate: uint160(vm.envUint(string(abi.encodePacked("GRANTEE_UNLOCK_RATE_", vm.toString(i))))), + controllerType: ControllerType(vm.envUint(string(abi.encodePacked("GRANTEE_CONTROLLER_TYPE_", vm.toString(i))))), + metavest: address(0) + }); + + if (grants[i].controllerType > ControllerType.WithOverride) { + revert UnexpectedControllerType(uint256(grants[i].controllerType)); + } + } + } + + function parseAllocation(Config memory config, GrantInfo memory grant) internal view returns(BaseAllocation.Allocation memory) { + return BaseAllocation.Allocation({ + tokenContract: address(config.vestingToken), + tokenStreamTotal: grant.amount, + vestingCliffCredit: grant.vestingCliffCredit, + unlockingCliffCredit: grant.unlockingCliffCredit, + vestingRate: grant.vestingRate, + vestingStartTime: grant.vestingStartTime, + unlockRate: grant.unlockRate, + unlockStartTime: config.unlockStartTime + }); + } + + function getController(Config memory config, ControllerType controllerType) external view returns(metavestController) { + return (controllerType == AbstractBeta.ControllerType.WithoutOverride) + ? config.controllerWithoutOverride + : config.controllerWithOverride; + } +} diff --git a/scripts/lib/AbstractBetaSepolia.sol b/scripts/lib/AbstractBetaSepolia.sol new file mode 100644 index 0000000..563e1b0 --- /dev/null +++ b/scripts/lib/AbstractBetaSepolia.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.28; + +import {Vm} from "forge-std/Vm.sol"; +import {CommonBase} from "forge-std/Base.sol"; +import {console2} from "forge-std/Console2.sol"; +import {BaseAllocation} from "../../src/BaseAllocation.sol"; +import {metavestController} from "../../src/MetaVesTController.sol"; +import {AbstractBeta} from "./AbstractBeta.sol"; + +library AbstractBetaSepolia { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function getDefault() internal view returns(AbstractBeta.Config memory) { + return AbstractBeta.Config({ + + // External dependencies + + vestingToken: 0xA581b1b0D31B0528C20801E56EeEaF0834a8C907, // mock vesting token + paymentToken: 0xB9E5Ae881f36083cB914205F19EAa265D76eeF53, // mock payment token + + // Authority + + dao: 0x4F22ba82a6B71F7305d1be7Ae7323811f9D555Ab, // dev Safe + authority: 0x4F22ba82a6B71F7305d1be7Ae7323811f9D555Ab, // dev Safe + escrowMultisig: 0x4F22ba82a6B71F7305d1be7Ae7323811f9D555Ab, // dev Safe + + // Sat Jan 1 00:00:00 UTC 2028 + // Will update the start times once finalized + unlockStartTime: 1830297600, + exercisePrice: 1e6, + shortStopDuration: 0, + + // Grants (without override) (grantee can specify their desired recipient addresses) + controllerWithoutOverride: metavestController(0xCEf4761CC320Fdc28034f00B754E8b608028f420), + + // Grants (with override) (authority overrides all grantees' recipient address) + controllerWithOverride: metavestController(0x387116083c8788426Fc91d6689972e2ad6d54512) + }); + } +} diff --git a/scripts/lib/SafeUtils.sol b/scripts/lib/SafeUtils.sol new file mode 100644 index 0000000..def476b --- /dev/null +++ b/scripts/lib/SafeUtils.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Vm, console2} from "forge-std/Test.sol"; +import {GnosisTransaction} from "./safe.sol"; + +// Access hidden cheatcodes +interface EnhancedVm is Vm { + function serializeJsonType(string calldata typeDescription, bytes memory value) external pure returns (string memory json); +} + +library SafeUtils { + EnhancedVm constant vm = EnhancedVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + struct SafeTxImport { + string version; + string chainId; + uint256 createdAt; + SafeTxMeta meta; + SafeTx[] transactions; + } + + struct SafeTxMeta { + string name; + string description; + string txBuilderVersion; + string createdFromSafeAddress; + string createdFromOwnerAddress; + string checksum; + } + + struct SafeTx { + address to; + string value; + bytes data; + } + + function formatSafeTxJson(GnosisTransaction[] memory safeTxs) internal returns (string memory) { + SafeTx[] memory convertedSafeTxs = new SafeTx[](safeTxs.length); + for (uint256 i = 0; i < safeTxs.length; i++) { + convertedSafeTxs[i] = SafeTx({ + to: safeTxs[i].to, + value: vm.toString(safeTxs[i].value), + data: safeTxs[i].data + }); + } + + return vm.serializeJsonType( + // it is important to include the input argument names as the utility will use them + "SafeTxImport(string version,string chainId,uint256 createdAt,SafeTxMeta meta,SafeTx[] transactions)SafeTxMeta(string name,string description,string txBuilderVersion,string createdFromSafeAddress,string createdFromOwnerAddress,string checksum)SafeTx(address to,string value,bytes data)", + abi.encode(SafeTxImport({ + version: "1.0", + chainId: "1", + createdAt: block.timestamp * 1000, + meta: SafeTxMeta({ + name: "Transactions Batch", + description: "", + txBuilderVersion: "", + createdFromSafeAddress: "", + createdFromOwnerAddress: "", + checksum: "" + }), + transactions: convertedSafeTxs + })) + ); + } + + function parseSafeTxJson(string memory json) internal returns (SafeTxImport memory) { + return abi.decode(vm.parseJson(json), (SafeTxImport)); + } +} diff --git a/scripts/lib/safe.sol b/scripts/lib/safe.sol new file mode 100644 index 0000000..be09f19 --- /dev/null +++ b/scripts/lib/safe.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +interface IGnosisSafe { + function getThreshold() external view returns (uint256); + + function isOwner(address owner) external view returns (bool); + + function getOwners() external view returns (address[] memory); + + function isModuleEnabled(address module) external view returns (bool); + + function setGuard(address guard) external; + + function addOwnerWithThreshold(address owner, uint256 threshold) external; + + function execTransaction( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes memory signatures + ) external payable returns (bool success); + + function encodeTransactionData( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes memory); + + function nonce() external view returns (uint256); + + function setup( + address[] calldata _owners, + uint256 _threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; +} + +struct GnosisTransaction { + address to; + uint256 value; + bytes data; +} + +interface IMultiSendCallOnly { + function multiSend(bytes memory transactions) external payable; +} + +interface ISafeProxyFactory { + function createProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce) external returns (address proxy); +} diff --git a/src/BaseAllocation.sol b/src/BaseAllocation.sol index 161ca4a..fff9f27 100644 --- a/src/BaseAllocation.sol +++ b/src/BaseAllocation.sol @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; + +import {IMetaVesTController} from "./interfaces/IMetaVesTController.sol"; /// @notice interface to a MetaLeX condition contract /// @dev see https://github.com/MetaLex-Tech/BORG-CORE/tree/main/src/libs/conditions @@ -116,6 +118,7 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ error MetaVesT_NotTerminated(); error MetaVesT_MilestoneIndexCompletedOrDoesNotExist(); error MetaVesT_ConditionNotSatisfied(); + error MetaVesT_AlreadyStarted(); error MetaVesT_AlreadyTerminated(); error MetaVesT_MoreThanAvailable(); error MetaVesT_VestNotTransferable(); @@ -134,9 +137,12 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ event MetaVesT_TransferabilityUpdated(address indexed grantee, bool isTransferable); event MetaVest_TransferRightsPending(address indexed grantee, address indexed pendingGrantee); event MetaVesT_TransferredRights(address indexed grantee, address transferee); - event MetaVesT_UnlockRateUpdated(address indexed grantee, uint208 unlockRate); - event MetaVesT_VestingRateUpdated(address indexed grantee, uint208 vestingRate); - event MetaVesT_Withdrawn(address indexed grantee, address indexed tokenAddress, uint256 amount); + event MetaVesT_DesiredRecipientUpdated(address indexed grantee, address newDesiredRecipient); + event MetaVesT_UnlockRateUpdated(address indexed grantee, uint160 unlockRate); + event MetaVesT_VestingRateUpdated(address indexed grantee, uint160 vestingRate); + event MetaVesT_UnlockStartTimeUpdated(address indexed grantee, uint48 unlockStartTime); + event MetaVesT_VestingStartTimeUpdated(address indexed grantee, uint48 vestingStartTime); + event MetaVesT_Withdrawn(address indexed grantee, address indexed recipient, address indexed tokenAddress, uint256 amount); event MetaVesT_PriceUpdated(address indexed grantee, uint256 exercisePrice); event MetaVesT_RepurchaseAndWithdrawal(address indexed grantee, address indexed tokenAddress, uint256 withdrawalAmount, uint256 repurchaseAmount); event MetaVesT_Terminated(address indexed grantee, uint256 tokensRecovered); @@ -168,15 +174,23 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ bool public terminated; uint256 public terminationTime; + // TODO: unaudited beta feature + // Specifies desired recipient of the tokens. Set by grantee only (address(0) = unset) + // Note this is a preference and it could be overridden by the controller. + address public desiredRecipient; + /// @notice BaseAllocation constructor /// @param _grantee: address of the grantee, cannot be a zero address /// @param _controller: address of the MetaVesTController contract - constructor(address _grantee, address _controller) { + constructor(address _grantee, address _desiredRecipient, address _controller) { // Controller can be 0 for an immuatable version, but grantee cannot if (_grantee == address(0)) revert MetaVesT_ZeroAddress(); + grantee = _grantee; controller = _controller; govType = GovType.vested; + + _updateDesiredRecipient(_desiredRecipient); } function getVestingType() external view virtual returns (uint256); @@ -207,7 +221,7 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ emit MetaVesT_TransferabilityUpdated(grantee, _transferable); } - /// @notice updates the vesting rate of the VestingAllocation + /// @notice updates the vesting rate of the Allocation /// @dev onlyController -- must be called from the metavest controller /// @param _newVestingRate - the updated vesting rate in tokens per second in the vesting token decimal function updateVestingRate(uint160 _newVestingRate) external onlyController { @@ -216,7 +230,22 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ emit MetaVesT_VestingRateUpdated(grantee, _newVestingRate); } - /// @notice updates the unlock rate of the VestingAllocation + /// @notice updates the vesting start time of the Allocation + /// @dev onlyController -- must be called from the metavest controller + /// @param _newVestingStartTime - the updated vesting start time + function updateVestingStartTime(uint48 _newVestingStartTime) external onlyController { + if (terminated) revert MetaVesT_AlreadyTerminated(); + + // It is not allowed to update start time if it has already passed, because + // the contract treats vested tokens as permanent and therefore would not allow any change to the start time, + // which would change the number of vested token at that very moment. + if (allocation.vestingStartTime <= block.timestamp || _newVestingStartTime <= block.timestamp) revert MetaVesT_AlreadyStarted(); + + allocation.vestingStartTime = _newVestingStartTime; + emit MetaVesT_VestingStartTimeUpdated(grantee, _newVestingStartTime); + } + + /// @notice updates the unlock rate of the Allocation /// @dev onlyController -- must be called from the metavest controller /// @param _newUnlockRate - the updated unlock rate in tokens per second in the vesting token decimal function updateUnlockRate(uint160 _newUnlockRate) external onlyController { @@ -224,6 +253,21 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ emit MetaVesT_UnlockRateUpdated(grantee, _newUnlockRate); } + /// @notice updates the unlock start time of the Allocation + /// @dev onlyController -- must be called from the metavest controller + /// @param _newUnlockStartTime - the updated unlock start time + function updateUnlockStartTime(uint48 _newUnlockStartTime) external onlyController { + if (terminated) revert MetaVesT_AlreadyTerminated(); + + // It is not allowed to update start time if it has already passed, because + // the contract treats unlock tokens as permanent and therefore would not allow any change to the start time, + // which would change the number of unlock token at that very moment. + if (allocation.unlockStartTime <= block.timestamp || _newUnlockStartTime <= block.timestamp) revert MetaVesT_AlreadyStarted(); + + allocation.unlockStartTime = _newUnlockStartTime; + emit MetaVesT_UnlockStartTimeUpdated(grantee, _newUnlockStartTime); + } + /// @notice Sets the governing power type for the MetaVesT /// @param _govType: the type of governing power to be used function setGovVariables(GovType _govType) external onlyController { @@ -300,6 +344,18 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ pendingGrantee = address(0); } + /// @notice update the recipient to a new address + /// @dev onlyGrantee -- must be called by the grantee + /// @param _newDesiredRecipient - the address of the new recipient preference + function updateDesiredRecipient(address _newDesiredRecipient) external onlyGrantee { + _updateDesiredRecipient(_newDesiredRecipient); + } + + function _updateDesiredRecipient(address _newDesiredRecipient) internal { + emit MetaVesT_DesiredRecipientUpdated(grantee, _newDesiredRecipient); + desiredRecipient = _newDesiredRecipient; + } + /// @notice withdraws tokens from the VestingAllocation /// @dev onlyGrantee -- must be called by the grantee /// @param _amount - the amount of tokens to withdraw @@ -307,8 +363,25 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ if (_amount == 0) revert MetaVesT_ZeroAmount(); if (_amount > getAmountWithdrawable() || _amount > IERC20M(allocation.tokenContract).balanceOf(address(this))) revert MetaVesT_MoreThanAvailable(); tokensWithdrawn += _amount; - safeTransfer(allocation.tokenContract, msg.sender, _amount); - emit MetaVesT_Withdrawn(msg.sender, allocation.tokenContract, _amount); + address recipient = getRecipient(); + safeTransfer(allocation.tokenContract, recipient, _amount); + emit MetaVesT_Withdrawn(grantee, recipient, allocation.tokenContract, _amount); + } + + /// @notice Determine the recipient address based on grantee settings and controller overrides. + /// Priority: + /// - `controller.recipientOverride` + /// - `desiredRecipient` + /// - `grantee` + /// @dev `address(0)` means null/no preference + function getRecipient() public view returns (address) { + if (controller != address(0) && IMetaVesTController(controller).recipientOverride() != address(0)) { + return IMetaVesTController(controller).recipientOverride(); + } else if (desiredRecipient != address(0)) { + return desiredRecipient; + } else { + return grantee; + } } /// @notice gets the details of the vest diff --git a/src/MetaVesTController.sol b/src/MetaVesTController.sol index 77d5f6f..b2e0e6c 100644 --- a/src/MetaVesTController.sol +++ b/src/MetaVesTController.sol @@ -6,7 +6,7 @@ ************************************* */ -pragma solidity 0.8.20; +pragma solidity ^0.8.20; //import "./MetaVesT.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -39,6 +39,12 @@ contract metavestController is SafeTransferLib { address public vestingFactory; address public tokenOptionFactory; address public restrictedTokenFactory; + + // TODO: unaudited beta feature + // Overrides all downstream metavest vaults's recipient addresses. Set by authority only (address(0) = unset). + // Individual metavest grantees may have their own desired recipients set, but this value would override them if set. + address public recipientOverride; + address internal _pendingAuthority; address internal _pendingDao; @@ -90,6 +96,7 @@ contract metavestController is SafeTransferLib { event MetaVesTController_SetRemoved(string indexed set); event MetaVesTController_AddressAddedToSet(string set, address indexed grantee); event MetaVesTController_AddressRemovedFromSet(string set, address indexed grantee); + event MetaVesTController_RecipientOverrideUpdated(address indexed newRecipientOverride); /// /// ERRORS @@ -173,13 +180,23 @@ contract metavestController is SafeTransferLib { /// @param _authority address of the authority who can call the functions in this contract and update each MetaVesT in '_metavest', such as a BORG /// @param _dao DAO governance contract address which exercises control over ability of 'authority' to call certain functions via imposing /// conditions through 'updateFunctionCondition'. - constructor(address _authority, address _dao, address _vestingFactory, address _tokenOptionFactory, address _restrictedTokenFactory) { + /// @param _recipientOverride override individual metavest vault's recipient addresses so grantee cannot specify their own recipient addresses + constructor( + address _authority, + address _dao, + address _recipientOverride, + address _vestingFactory, + address _tokenOptionFactory, + address _restrictedTokenFactory + ) { if (_authority == address(0)) revert MetaVesTController_ZeroAddress(); authority = _authority; vestingFactory = _vestingFactory; tokenOptionFactory = _tokenOptionFactory; restrictedTokenFactory = _restrictedTokenFactory; dao = _dao; + + _updateRecipientOverride(_recipientOverride); } /// @notice for a grantee to consent to an update to one of their metavestDetails by 'authority' corresponding to the applicable function in this controller @@ -222,20 +239,54 @@ contract metavestController is SafeTransferLib { emit MetaVesTController_ConditionUpdated(_condition, _functionSig); } - function createMetavest(metavestType _type, address _grantee, BaseAllocation.Allocation calldata _allocation, BaseAllocation.Milestone[] calldata _milestones, uint256 _exercisePrice, address _paymentToken, uint256 _shortStopDuration, uint256 _longStopDate) external onlyAuthority conditionCheck returns (address) - { + /// @notice For backward compatibility (default recipient) + /// @dev Should always call the internal createMetavest() because the latter performs the necessary ACL + function createMetavest( + metavestType _type, + address _grantee, + BaseAllocation.Allocation calldata _allocation, + BaseAllocation.Milestone[] calldata _milestones, + uint256 _exercisePrice, + address _paymentToken, + uint256 _shortStopDuration, + uint256 _longStopDate + ) external returns (address) { + return createMetavest( + _type, + _grantee, + address(0), // no preference + _allocation, + _milestones, + _exercisePrice, + _paymentToken, + _shortStopDuration, + _longStopDate + ); + } + + function createMetavest( + metavestType _type, + address _grantee, + address _desiredRecipient, + BaseAllocation.Allocation calldata _allocation, + BaseAllocation.Milestone[] calldata _milestones, + uint256 _exercisePrice, + address _paymentToken, + uint256 _shortStopDuration, + uint256 _longStopDate + ) public onlyAuthority conditionCheck returns (address) { address newMetavest; if(_type == metavestType.Vesting) { - newMetavest = createVestingAllocation(_grantee, _allocation, _milestones); + newMetavest = createVestingAllocation(_grantee, _desiredRecipient, _allocation, _milestones); } else if(_type == metavestType.TokenOption) { - newMetavest = createTokenOptionAllocation(_grantee, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); + newMetavest = createTokenOptionAllocation(_grantee, _desiredRecipient, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); } else if(_type == metavestType.RestrictedTokenAward) { - newMetavest = createRestrictedTokenAward(_grantee, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); + newMetavest = createRestrictedTokenAward(_grantee, _desiredRecipient, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); } else { @@ -243,10 +294,10 @@ contract metavestController is SafeTransferLib { } return newMetavest; } - function validateInputParameters( address _grantee, + address _desiredRecipient, address _paymentToken, uint256 _exercisePrice, VestingAllocation.Allocation calldata _allocation @@ -283,50 +334,58 @@ contract metavestController is SafeTransferLib { ) revert MetaVesTController_AmountNotApprovedForTransferFrom(); } - function createAndInitializeTokenOptionAllocation( - address _grantee, - address _paymentToken, - uint256 _exercisePrice, - uint256 _shortStopDuration, - VestingAllocation.Allocation calldata _allocation, - VestingAllocation.Milestone[] calldata _milestones - ) internal returns (address) { - return IAllocationFactory(tokenOptionFactory).createAllocation( - IAllocationFactory.AllocationType.TokenOption, - _grantee, - address(this), - _allocation, - _milestones, - _paymentToken, - _exercisePrice, - _shortStopDuration - ); - } - - function createAndInitializeRestrictedTokenAward( - address _grantee, - address _paymentToken, - uint256 _repurchasePrice, - uint256 _shortStopDuration, - VestingAllocation.Allocation calldata _allocation, - VestingAllocation.Milestone[] calldata _milestones - ) internal returns (address) { - return IAllocationFactory(restrictedTokenFactory).createAllocation( - IAllocationFactory.AllocationType.RestrictedToken, - _grantee, - address(this), - _allocation, - _milestones, - _paymentToken, - _repurchasePrice, - _shortStopDuration - ); - } + function createAndInitializeTokenOptionAllocation( + address _grantee, + address _desiredRecipient, + address _paymentToken, + uint256 _exercisePrice, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal returns (address) { + return IAllocationFactory(tokenOptionFactory).createAllocation( + IAllocationFactory.AllocationType.TokenOption, + _grantee, + _desiredRecipient, + address(this), + _allocation, + _milestones, + _paymentToken, + _exercisePrice, + _shortStopDuration + ); + } + function createAndInitializeRestrictedTokenAward( + address _grantee, + address _desiredRecipient, + address _paymentToken, + uint256 _repurchasePrice, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal returns (address) { + return IAllocationFactory(restrictedTokenFactory).createAllocation( + IAllocationFactory.AllocationType.RestrictedToken, + _grantee, + _desiredRecipient, + address(this), + _allocation, + _milestones, + _paymentToken, + _repurchasePrice, + _shortStopDuration + ); + } - function createVestingAllocation(address _grantee, VestingAllocation.Allocation calldata _allocation, VestingAllocation.Milestone[] calldata _milestones) internal returns (address){ + function createVestingAllocation( + address _grantee, + address _desiredRecipient, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal returns (address){ //hard code values not to trigger the failure for the 2 parameters that don't matter for this type of allocation - validateInputParameters(_grantee, address(this), 1, _allocation); + validateInputParameters(_grantee, _desiredRecipient, address(this), 1, _allocation); validateAllocation(_allocation); uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); @@ -337,6 +396,7 @@ contract metavestController is SafeTransferLib { address vestingAllocation = IAllocationFactory(vestingFactory).createAllocation( IAllocationFactory.AllocationType.Vesting, _grantee, + _desiredRecipient, address(this), _allocation, _milestones, @@ -349,50 +409,70 @@ contract metavestController is SafeTransferLib { return vestingAllocation; } - function createTokenOptionAllocation(address _grantee, uint256 _exercisePrice, address _paymentToken, uint256 _shortStopDuration, VestingAllocation.Allocation calldata _allocation, VestingAllocation.Milestone[] calldata _milestones) internal conditionCheck returns (address) { - - validateInputParameters(_grantee, _paymentToken, _exercisePrice, _allocation); + function createTokenOptionAllocation( + address _grantee, + address _desiredRecipient, + uint256 _exercisePrice, + address _paymentToken, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal conditionCheck returns (address) { + + validateInputParameters(_grantee, _desiredRecipient, _paymentToken, _exercisePrice, _allocation); validateAllocation(_allocation); uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); uint256 _total = _allocation.tokenStreamTotal + _milestoneTotal; if (_total == 0) revert MetaVesTController_ZeroAmount(); validateTokenApprovalAndBalance(_allocation.tokenContract, _total); - + + // This is for fixing stack-too-deep errors address tokenOptionAllocation = createAndInitializeTokenOptionAllocation( - _grantee, - _paymentToken, - _exercisePrice, - _shortStopDuration, - _allocation, - _milestones - ); + _grantee, + _desiredRecipient, + _paymentToken, + _exercisePrice, + _shortStopDuration, + _allocation, + _milestones + ); - safeTransferFrom(_allocation.tokenContract, authority, tokenOptionAllocation, _total); - return tokenOptionAllocation; - } + safeTransferFrom(_allocation.tokenContract, authority, tokenOptionAllocation, _total); + return tokenOptionAllocation; + } - function createRestrictedTokenAward(address _grantee, uint256 _repurchasePrice, address _paymentToken, uint256 _shortStopDuration, VestingAllocation.Allocation calldata _allocation, VestingAllocation.Milestone[] calldata _milestones) internal conditionCheck returns (address){ - validateInputParameters(_grantee, _paymentToken, _repurchasePrice, _allocation); - validateAllocation(_allocation); - uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); - - uint256 _total = _allocation.tokenStreamTotal + _milestoneTotal; - if (_total == 0) revert MetaVesTController_ZeroAmount(); - validateTokenApprovalAndBalance(_allocation.tokenContract, _total); - - address restrictedTokenAward = createAndInitializeRestrictedTokenAward( - _grantee, - _paymentToken, - _repurchasePrice, - _shortStopDuration, - _allocation, - _milestones - ); + function createRestrictedTokenAward( + address _grantee, + address _desiredRecipient, + uint256 _repurchasePrice, + address _paymentToken, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal conditionCheck returns (address){ + validateInputParameters(_grantee, _desiredRecipient, _paymentToken, _repurchasePrice, _allocation); + validateAllocation(_allocation); + uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); - safeTransferFrom(_allocation.tokenContract, authority, restrictedTokenAward, _total); - return restrictedTokenAward; - } + uint256 _total = _allocation.tokenStreamTotal + _milestoneTotal; + if (_total == 0) revert MetaVesTController_ZeroAmount(); + validateTokenApprovalAndBalance(_allocation.tokenContract, _total); + + // This is for fixing stack-too-deep errors + address restrictedTokenAward = createAndInitializeRestrictedTokenAward( + _grantee, + _desiredRecipient, + _paymentToken, + _repurchasePrice, + _shortStopDuration, + _allocation, + _milestones + ); + + safeTransferFrom(_allocation.tokenContract, authority, restrictedTokenAward, _total); + return restrictedTokenAward; + } function getMetaVestType(address _grant) public view returns (uint256) { return BaseAllocation(_grant).getVestingType(); @@ -489,6 +569,18 @@ contract metavestController is SafeTransferLib { BaseAllocation(_grant).updateUnlockRate(_unlockRate); } + /// @notice for 'authority' to update a MetaVesT's unlockStartTime (including any transferees) + /// @dev '_unlockStartTime' is subject to the rules as defined in `BaseAllocation.updateUnlockStartTime()` + /// @param _grant address of grantee whose MetaVesT is being updated + /// @param _unlockStartTime token unlock rate in tokens per second + function updateMetavestUnlockStartTime( + address _grant, + uint48 _unlockStartTime + ) external onlyAuthority conditionCheck consentCheck(_grant, msg.data) { + _resetAmendmentParams(_grant, msg.sig); + BaseAllocation(_grant).updateUnlockStartTime(_unlockStartTime); + } + /// @notice for 'authority' to update a MetaVesT's vestingRate (including any transferees) /// @dev a '_vestingRate' of 0 is permissible to enable temporary freezes of allocation vestings by authority, but to permanently terminate vesting, call 'terminateMetavestVesting' /// @param _grant address of grantee whose MetaVesT is being updated @@ -501,6 +593,18 @@ contract metavestController is SafeTransferLib { BaseAllocation(_grant).updateVestingRate(_vestingRate); } + /// @notice for 'authority' to update a MetaVesT's vestingStartTime (including any transferees) + /// @dev '_vestingStartTime' is subject to the rules as defined in `BaseAllocation.updateVestingStartTime()` + /// @param _grant address of grantee whose MetaVesT is being updated + /// @param _vestingStartTime token vesting rate in tokens per second + function updateMetavestVestingStartTime( + address _grant, + uint48 _vestingStartTime + ) external onlyAuthority conditionCheck consentCheck(_grant, msg.data) { + _resetAmendmentParams(_grant, msg.sig); + BaseAllocation(_grant).updateVestingStartTime(_vestingStartTime); + } + /// @notice for authority to update a MetaVesT's stopTime and/or shortStopTime, as applicable (including any transferees) /// @dev if '_shortStopTime' has already occurred, it will be ignored in MetaVest.sol. Allows stop times before block.timestamp to enable accelerated schedules. /// @param _grant address of grantee whose MetaVesT is being updated @@ -747,4 +851,17 @@ contract metavestController is SafeTransferLib { emit MetaVesTController_AddressRemovedFromSet(_name, _metaVest); } + /// @notice Override individual metavest vault recipient to a new address. Individual metavest grantees may + /// set their own desired recipients, but this overrides them if set. + /// @dev `address(0)` means null/no overrides + /// @dev onlyAuthority -- must be called by the authority + /// @param _newRecipientOverride - the address of the new overriding recipient + function updateRecipientOverride(address _newRecipientOverride) external onlyAuthority { + _updateRecipientOverride(_newRecipientOverride); + } + + function _updateRecipientOverride(address _newRecipientOverride) internal { + emit MetaVesTController_RecipientOverrideUpdated(_newRecipientOverride); + recipientOverride = _newRecipientOverride; + } } diff --git a/src/MetaVesTFactory.sol b/src/MetaVesTFactory.sol deleted file mode 100644 index 0c200b6..0000000 --- a/src/MetaVesTFactory.sol +++ /dev/null @@ -1,50 +0,0 @@ -//SPDX-License-Identifier: AGPL-3.0-only - -pragma solidity 0.8.20; - -/* -************************************ - MetaVesTFactory - ************************************ - */ - -import "./MetaVesTController.sol"; -interface IMetaVesTController { - function metavest() external view returns (address); -} - -/** - * @title MetaVesT Factory - * - * @notice Deploy a new instance of MetaVesTController, which in turn deploys a new MetaVesT it controls - * - **/ -contract MetaVesTFactory { - event MetaVesT_Deployment( - address newMetaVesT, - address authority, - address controller, - address dao, - address vestingAllocationFactory, - address tokenOptionFactory, - address restrictedTokenFactory - ); - - error MetaVesTFactory_ZeroAddress(); - - constructor() { } - - /// @notice constructs a MetaVesT framework specifying authority address, DAO staking/voting contract address - /// each individual grantee's MetaVesT will be initiated in the newly deployed MetaVesT contract, and deployed MetaVesTs are amendable by 'authority' via the controller contract - /// @dev conditionals are contained in the deployed MetaVesT, which is deployed in the MetaVesTController's constructor(); the MetaVesT within the MetaVesTController is immutable, but the 'authority' which has access control within the controller may replace itself - /// @param _authority: address which initiates and may update each MetaVesT, such as a BORG or DAO - /// @param _dao: contract address which token may be staked and used for voting, typically a DAO pool, governor, staking address. Submit address(0) for no such functionality. - function deployMetavestAndController(address _authority, address _dao, address _vestingAllocationFactory, address _tokenOptionFactory, address _restrictedTokenFactory ) external returns(address) { - if(_vestingAllocationFactory == address(0) || _tokenOptionFactory == address(0) || _restrictedTokenFactory == address(0)) - revert MetaVesTFactory_ZeroAddress(); - metavestController _controller = new metavestController(_authority, _dao, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); - emit MetaVesT_Deployment(address(0), _authority, address(_controller), _dao, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); - return address(_controller); - } - -} diff --git a/src/RestrictedTokenAllocation.sol b/src/RestrictedTokenAllocation.sol index b0ccece..e8b3b26 100644 --- a/src/RestrictedTokenAllocation.sol +++ b/src/RestrictedTokenAllocation.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import "./BaseAllocation.sol"; -pragma solidity 0.8.20; +pragma solidity ^0.8.20; contract RestrictedTokenAward is BaseAllocation { @@ -15,6 +15,7 @@ contract RestrictedTokenAward is BaseAllocation { /// @notice Constructor to deploy a new RestrictedTokenAward /// @param _grantee - address of the grantee + /// @param _desiredRecipient address of the fund recipient /// @param _controller - address of the controller /// @param _paymentToken - address of the payment token /// @param _repurchasePrice - price at which the restricted tokens can be repurchased in vesting token decimals but only up to payment decimal precision @@ -23,6 +24,7 @@ contract RestrictedTokenAward is BaseAllocation { /// @param _milestones - milestones with their conditions and awards constructor ( address _grantee, + address _desiredRecipient, address _controller, address _paymentToken, uint256 _repurchasePrice, @@ -31,6 +33,7 @@ contract RestrictedTokenAward is BaseAllocation { Milestone[] memory _milestones ) BaseAllocation( _grantee, + _desiredRecipient, _controller ) { //perform input validation @@ -152,9 +155,11 @@ contract RestrictedTokenAward is BaseAllocation { function claimRepurchasedTokens() external onlyGrantee nonReentrant { if(IERC20M(paymentToken).balanceOf(address(this)) == 0) revert MetaVesT_MoreThanAvailable(); uint256 _amount = IERC20M(paymentToken).balanceOf(address(this)); - safeTransfer(paymentToken, msg.sender, _amount); + + address recipient = getRecipient(); + safeTransfer(paymentToken, recipient, _amount); tokensRepurchasedWithdrawn += _amount; - emit MetaVesT_Withdrawn(msg.sender, paymentToken, _amount); + emit MetaVesT_Withdrawn(grantee, recipient, paymentToken, _amount); } /// @notice Allows the controller to terminate the RestrictedTokenAward diff --git a/src/RestrictedTokenFactory.sol b/src/RestrictedTokenFactory.sol index c9e43a3..a993e26 100644 --- a/src/RestrictedTokenFactory.sol +++ b/src/RestrictedTokenFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./RestrictedTokenAllocation.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -9,6 +9,7 @@ contract RestrictedTokenFactory is IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, RestrictedTokenAward.Allocation memory _allocation, RestrictedTokenAward.Milestone[] memory _milestones, @@ -17,7 +18,7 @@ contract RestrictedTokenFactory is IAllocationFactory { uint256 _shortStopDuration ) external returns (address) { if (_allocationType == AllocationType.RestrictedToken) { - return address(new RestrictedTokenAward(_grantee, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); + return address(new RestrictedTokenAward(_grantee, _desiredRecipient, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); } else { revert("AllocationFactory: invalid allocation type"); } diff --git a/src/TokenOptionAllocation.sol b/src/TokenOptionAllocation.sol index c99fb65..9a886e6 100644 --- a/src/TokenOptionAllocation.sol +++ b/src/TokenOptionAllocation.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import "./BaseAllocation.sol"; -pragma solidity 0.8.20; +pragma solidity ^0.8.20; contract TokenOptionAllocation is BaseAllocation { @@ -16,6 +16,7 @@ contract TokenOptionAllocation is BaseAllocation { /// @notice Constructor to create a TokenOptionAllocation /// @param _grantee - address of the grantee + /// @param _desiredRecipient address of the fund recipient /// @param _controller - address of the controller /// @param _paymentToken - address of the payment token /// @param _exercisePrice - price of the token option exercise in vesting token decimals but only up to payment decimal precision @@ -24,6 +25,7 @@ contract TokenOptionAllocation is BaseAllocation { /// @param _milestones - milestones with conditions and awards constructor ( address _grantee, + address _desiredRecipient, address _controller, address _paymentToken, uint256 _exercisePrice, @@ -32,6 +34,7 @@ contract TokenOptionAllocation is BaseAllocation { Milestone[] memory _milestones ) BaseAllocation( _grantee, + _desiredRecipient, _controller ) { //perform input validation @@ -139,10 +142,10 @@ contract TokenOptionAllocation is BaseAllocation { // Calculate paymentAmount uint256 paymentAmount = getPaymentAmount(_tokensToExercise); if(paymentAmount == 0) revert MetaVesT_TooSmallAmount(); - - safeTransferFrom(paymentToken, msg.sender, getAuthority(), paymentAmount); + + safeTransferFrom(paymentToken, grantee, getAuthority(), paymentAmount); tokensExercised += _tokensToExercise; - emit MetaVesT_TokenOptionExercised(msg.sender, _tokensToExercise, paymentAmount); + emit MetaVesT_TokenOptionExercised(grantee, _tokensToExercise, paymentAmount); } /// @notice Allows the controller to terminate the TokenOptionAllocation diff --git a/src/TokenOptionFactory.sol b/src/TokenOptionFactory.sol index ed90780..7c38303 100644 --- a/src/TokenOptionFactory.sol +++ b/src/TokenOptionFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./TokenOptionAllocation.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -9,6 +9,7 @@ contract TokenOptionFactory is IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, TokenOptionAllocation.Allocation memory _allocation, TokenOptionAllocation.Milestone[] memory _milestones, @@ -17,7 +18,7 @@ contract TokenOptionFactory is IAllocationFactory { uint256 _shortStopDuration ) external returns (address) { if (_allocationType == AllocationType.TokenOption) { - return address(new TokenOptionAllocation(_grantee, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); + return address(new TokenOptionAllocation(_grantee, _desiredRecipient, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); } else { revert("AllocationFactory: invalid allocation type"); } diff --git a/src/VestingAllocation.sol b/src/VestingAllocation.sol index 57801fe..614673e 100644 --- a/src/VestingAllocation.sol +++ b/src/VestingAllocation.sol @@ -1,23 +1,26 @@ // SPDX-License-Identifier: AGPL-3.0-only import "./BaseAllocation.sol"; -pragma solidity 0.8.20; +pragma solidity ^0.8.20; contract VestingAllocation is BaseAllocation { /// @notice constructor for VestingAllocation /// @param _grantee address of the grantee + /// @param _desiredRecipient address of the fund recipient /// @param _controller address of the controller /// @param _allocation Allocation struct containing token contract /// @param _milestones array of Milestone structs with conditions and awards constructor ( address _grantee, + address _desiredRecipient, address _controller, Allocation memory _allocation, Milestone[] memory _milestones ) BaseAllocation( - _grantee, - _controller + _grantee, + _desiredRecipient, + _controller ) { //perform input validation if (_allocation.tokenContract == address(0)) revert MetaVesT_ZeroAddress(); @@ -49,19 +52,24 @@ contract VestingAllocation is BaseAllocation { /// @notice returns the governing power of the VestingAllocation /// @return governingPower - the governing power of the VestingAllocation based on the governance setting function getGoverningPower() external view override returns (uint256 governingPower) { - if(govType==GovType.all) - { + // TODO WIP: revise needed. There are some changes since the last audit. Do we need to apply those changes to the other two allocation types? + if(govType==GovType.all) { uint256 totalMilestoneAward = 0; - for(uint256 i; i < milestones.length; ++i) - { + for(uint256 i; i < milestones.length; ++i) { totalMilestoneAward += milestones[i].milestoneAward; } governingPower = (allocation.tokenStreamTotal + totalMilestoneAward) - tokensWithdrawn; + } else if(govType==GovType.vested) { + uint256 amount = getVestedTokenAmount(); + governingPower = (amount > tokensWithdrawn) + ? amount - tokensWithdrawn + : 0; + } else { + uint256 amount = _min(getVestedTokenAmount(), getUnlockedTokenAmount()); + governingPower = (amount > tokensWithdrawn) + ? amount - tokensWithdrawn + : 0; } - else if(govType==GovType.vested) - governingPower = getVestedTokenAmount() - tokensWithdrawn; - else - governingPower = _min(getVestedTokenAmount(), getUnlockedTokenAmount()) - tokensWithdrawn; return governingPower; } diff --git a/src/VestingAllocationFactory.sol b/src/VestingAllocationFactory.sol index a27e220..34f6824 100644 --- a/src/VestingAllocationFactory.sol +++ b/src/VestingAllocationFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./VestingAllocation.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -9,6 +9,7 @@ contract VestingAllocationFactory is IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, VestingAllocation.Allocation memory _allocation, VestingAllocation.Milestone[] memory _milestones, @@ -17,7 +18,7 @@ contract VestingAllocationFactory is IAllocationFactory { uint256 _shortStopDuration ) external returns (address) { if (_allocationType == AllocationType.Vesting) { - return address(new VestingAllocation(_grantee, _controller, _allocation, _milestones)); + return address(new VestingAllocation(_grantee, _desiredRecipient, _controller, _allocation, _milestones)); } else { revert("AllocationFactory: invalid allocation type"); } diff --git a/src/interfaces/IAllocationFactory.sol b/src/interfaces/IAllocationFactory.sol index 5d85402..e9392e2 100644 --- a/src/interfaces/IAllocationFactory.sol +++ b/src/interfaces/IAllocationFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "../VestingAllocation.sol"; @@ -14,6 +14,7 @@ interface IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, VestingAllocation.Allocation memory _allocation, VestingAllocation.Milestone[] memory _milestones, diff --git a/src/interfaces/IBaseAllocation.sol b/src/interfaces/IBaseAllocation.sol index 9618f1a..72e6231 100644 --- a/src/interfaces/IBaseAllocation.sol +++ b/src/interfaces/IBaseAllocation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; interface IBaseAllocation { function getVestingType() external view returns (uint256); diff --git a/src/interfaces/IMetaVesTController.sol b/src/interfaces/IMetaVesTController.sol new file mode 100644 index 0000000..7bf03fe --- /dev/null +++ b/src/interfaces/IMetaVesTController.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +interface IMetaVesTController { + function recipientOverride() external view returns (address); +} diff --git a/src/interfaces/IPriceAllocation.sol b/src/interfaces/IPriceAllocation.sol index 0bad4b8..c39e3ed 100644 --- a/src/interfaces/IPriceAllocation.sol +++ b/src/interfaces/IPriceAllocation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; interface IPriceAllocation { function getVestingType() external view returns (uint256); diff --git a/src/interfaces/IRestrictedTokenAward.sol b/src/interfaces/IRestrictedTokenAward.sol index aac6dd1..27dcb72 100644 --- a/src/interfaces/IRestrictedTokenAward.sol +++ b/src/interfaces/IRestrictedTokenAward.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./IBaseAllocation.sol"; diff --git a/test/AbstractBeta.t.sol b/test/AbstractBeta.t.sol new file mode 100644 index 0000000..04b886f --- /dev/null +++ b/test/AbstractBeta.t.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {DeployAbstractBetaScript} from "../scripts/deploy.abstract-beta.s.sol"; +import {AbstractBetaSepolia} from "../scripts/lib/AbstractBetaSepolia.sol"; +import {AbstractBeta} from "../scripts/lib/AbstractBeta.sol"; +import {GnosisTransaction} from "../scripts/lib/safe.sol"; +import {MockERC20} from "../test/mocks/MockERC20.sol"; + +contract AbstractBetaTest is Test { + string saltStr = "AbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer; + + // Test against mainnet configs + AbstractBeta.Config config = AbstractBeta.getDefault(); + AbstractBeta.GrantInfo[] grants; + + /// @notice Assumes Sepolia testnet + function setUp() public { + (deployer, deployerPrivateKey) = makeAddrAndKey("deployer"); + + // Simulate the authority deploy their vesting token + vm.startPrank(config.authority); + config.vestingToken = address(new MockERC20("Vesting Token", "VEST", 18)); + MockERC20(config.vestingToken).mint(config.authority, 100_000_000_000 ether); + vm.stopPrank(); + console2.log("Vesting token deployed: %s", config.vestingToken); + + // Deploy controllers and prepare txs for grants + AbstractBeta.GrantInfo[] memory loadedGrants; + GnosisTransaction[] memory provisionSafeTxs; + GnosisTransaction[] memory grantSafeTxs; + GnosisTransaction[] memory allSafeTxs; + ( + config.controllerWithoutOverride, + config.controllerWithOverride, + loadedGrants, + provisionSafeTxs, + grantSafeTxs, + allSafeTxs + ) = (new DeployAbstractBetaScript()).runWithArgs( + saltStr, + deployerPrivateKey, + config + ); + + // Simulate authority creating the grants + vm.startPrank(config.authority); + ERC20(config.vestingToken).approve(address(config.controllerWithoutOverride), 100_000_000_000 ether); + ERC20(config.vestingToken).approve(address(config.controllerWithOverride), 100_000_000_000 ether); + + console2.log("Provisioning Safe funds..."); + for (uint256 i = 0; i < provisionSafeTxs.length; i++) { + (bool success, bytes memory ret) = provisionSafeTxs[i].to.call{value: provisionSafeTxs[i].value}(provisionSafeTxs[i].data); + assertTrue(success, string(abi.encodePacked("call #", vm.toString(i + 1), " failed: ", vm.toString(ret)))); + } + + console2.log("Deploying grants:"); + for (uint256 i = 0; i < grantSafeTxs.length; i++) { + (bool success, bytes memory ret) = grantSafeTxs[i].to.call{value: grantSafeTxs[i].value}(grantSafeTxs[i].data); + assertTrue(success, string(abi.encodePacked("call #", vm.toString(i + 1), " failed: ", vm.toString(ret)))); + loadedGrants[i].metavest = abi.decode(ret, (address)); + grants.push(loadedGrants[i]); // Save it to storage + console2.log(" #%s: %s", vm.toString(i + 1), loadedGrants[i].metavest); + } + + console2.log(""); + vm.stopPrank(); + } + + function test_sanityCheck() public { + // Verify grant parameters + for (uint256 i = 0; i < grants.length; i++) { + RestrictedTokenAward vault = RestrictedTokenAward(grants[i].metavest); + + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + + assertEq(vault.controller(), address(controller), string(abi.encodePacked("unexpected controller for grant #", vm.toString(i)))); + + ( + uint256 tokenStreamTotal, + uint128 vestingCliffCredit, + uint128 unlockingCliffCredit, + uint160 vestingRate, + uint48 vestingStartTime, + uint160 unlockRate, + uint48 unlockStartTime, + address tokenContract + ) = vault.allocation(); + assertEq(tokenStreamTotal, grants[i].amount, string(abi.encodePacked("unexpected tokenStreamTotal for grant #", vm.toString(i)))); + assertEq(vestingCliffCredit, grants[i].vestingCliffCredit, string(abi.encodePacked("unexpected vestingCliffCredit for grant #", vm.toString(i)))); + assertEq(unlockingCliffCredit, grants[i].unlockingCliffCredit, string(abi.encodePacked("unexpected unlockingCliffCredit for grant #", vm.toString(i)))); + assertEq(vestingRate, grants[i].vestingRate, string(abi.encodePacked("unexpected vestingRate for grant #", vm.toString(i)))); + assertEq(unlockRate, grants[i].unlockRate, string(abi.encodePacked("unexpected unlockRate for grant #", vm.toString(i)))); + assertEq(vestingStartTime, grants[i].vestingStartTime, string(abi.encodePacked("unexpected vestingStartTime for grant #", vm.toString(i)))); + assertEq(unlockStartTime, config.unlockStartTime, string(abi.encodePacked("unexpected unlockStartTime for grant #", vm.toString(i)))); + assertEq(tokenContract, config.vestingToken, string(abi.encodePacked("unexpected vestingToken for grant #", vm.toString(i)))); + assertEq(vault.paymentToken(), config.paymentToken, string(abi.encodePacked("unexpected paymentToken for grant #", vm.toString(i)))); + } + } + + /// @notice Authority should be able to update the unlock start times later + function test_updateUnlockStartTimes() public { + uint48 now = 1772323200; // 2026/03/01 + vm.warp(now); + + // (1) Add all vaults to sets + + string memory setName = "grants"; + + vm.startPrank(config.authority); + + config.controllerWithoutOverride.createSet(setName); + config.controllerWithOverride.createSet(setName); + + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + controller.addMetaVestToSet(setName, grants[i].metavest); + } + + vm.stopPrank(); + + // (2) Propose amendment for updating unlockStartTime + + vm.startPrank(config.authority); + config.controllerWithoutOverride.proposeMajorityMetavestAmendment( + setName, + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector( + metavestController.updateMetavestUnlockStartTime.selector, + address(0), // no-op + now + 90 days + ) + ); + config.controllerWithOverride.proposeMajorityMetavestAmendment( + setName, + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector( + metavestController.updateMetavestUnlockStartTime.selector, + address(0), // no-op + now + 90 days + ) + ); + vm.stopPrank(); + + // Approve amendment + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + vm.prank(grants[i].grantee); + controller.voteOnMetavestAmendment(grants[i].metavest, setName, metavestController.updateMetavestUnlockStartTime.selector, true); + } + + // Execute amendment + vm.startPrank(config.authority); + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + controller.updateMetavestUnlockStartTime(grants[i].metavest, now + 90 days); + + { + (,,,, uint48 vestingStartTime,, uint48 unlockStartTime,) = RestrictedTokenAward(grants[0].metavest).allocation(); + assertEq(unlockStartTime, now + 90 days, string(abi.encodePacked("unexpected unlockStartTime after amendment for grant #", vm.toString(i)))); + } + } + vm.stopPrank(); + + // Simulate and verify withdrawal on new schedules + + vm.warp(now + 365 days * 4); // enough time to withdraw all + + for (uint256 i = 0; i < grants.length; i++) { + ERC20 vestingToken = ERC20(config.vestingToken); + RestrictedTokenAward vault = RestrictedTokenAward(grants[i].metavest); + address recipient = vault.getRecipient(); + uint256 vestingTokenBalanceBefore = vestingToken.balanceOf(recipient); + + vm.startPrank(grants[i].grantee); + vault.withdraw(grants[i].amount); + assertEq(vestingToken.balanceOf(recipient) - vestingTokenBalanceBefore, grants[i].amount, "unexpected vesting token amount after withdrawal"); + vm.stopPrank(); + } + } +} diff --git a/test/BaseAllocation.abstract-beta.t.sol b/test/BaseAllocation.abstract-beta.t.sol new file mode 100644 index 0000000..d1bf2ff --- /dev/null +++ b/test/BaseAllocation.abstract-beta.t.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract BaseAllocationAbstractBetaTest is Test { + string saltStr = "BaseAllocationAbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + metavestController controller; + + function setUp() public { + vm.startPrank(deployer); + + controller = new metavestController{salt: salt}( + authority, + authority, + address(0), + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + + // Prepare funds + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_DesiredRecipientUpdatedOnConstruct() public { + bytes32 salt = keccak256(bytes("test_DesiredRecipientUpdatedOnConstruct")); + + vm.startPrank(authority); + vm.expectEmit(true, true, true, true); + emit BaseAllocation.MetaVesT_DesiredRecipientUpdated(alice, bob); + RestrictedTokenAward vault = _createTestVault(bob); // grantee specify bob as the desired recipient + vm.stopPrank(); + } + + /// @notice Should be able to create a metavest vault without recipient overrides nor grantee preference + function test_createMetavestDefaultRecipient() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + vm.stopPrank(); + + assertEq(vault.desiredRecipient(), address(0), "should have no grantee preference"); + assertEq(vault.getRecipient(), alice, "should use grantee as the recipient"); + + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_Withdrawn( + alice, // grantee + alice, // recipient + address(vestingToken), // tokenAddress + 1000e6 // amount + ); + vm.prank(alice); + vault.withdraw(1000e6); + assertEq(vestingToken.balanceOf(alice), 1000e6, "alice should be able to withdraw cliff to her desired wallet"); + } + + /// @notice Should be able to create a metavest vault without recipient overrides but with grantee preference + function test_createMetavestGranteePreference() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(bob); // grantee specify bob as the desired recipient + vm.stopPrank(); + + assertEq(vault.desiredRecipient(), bob, "grantee preference should be set"); + assertEq(vault.getRecipient(), bob, "should use the grantee preference as the recipient"); + + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_Withdrawn( + alice, // grantee + bob, // recipient + address(vestingToken), // tokenAddress + 1000e6 // amount + ); + vm.prank(alice); + vault.withdraw(1000e6); + assertEq(vestingToken.balanceOf(bob), 1000e6, "alice should be able to withdraw cliff to her desired wallet"); + } + + /// @notice Should be able to create a metavest vault with recipient overrides and no grantee preference + function test_createMetavestRecipientOverridden() public { + vm.startPrank(authority); + // Override recipient address + controller.updateRecipientOverride(chad); // controller overrides recipient address to chad's + RestrictedTokenAward vault = _createTestVault(bob); // grantee specify bob as the desired recipient, but it would be overridden and have no effects + vm.stopPrank(); + + assertEq(vault.desiredRecipient(), bob, "grantee preference should be set"); + assertEq(vault.getRecipient(), chad, "should use controller override as the recipient"); + + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_Withdrawn( + alice, // grantee + chad, // recipient + address(vestingToken), // tokenAddress + 1000e6 // amount + ); + vm.prank(alice); + vault.withdraw(1000e6); + assertEq(vestingToken.balanceOf(chad), 1000e6, "alice should be able to withdraw cliff to controller-overridden wallet"); + } + + function test_updateDesiredRecipient() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + vm.stopPrank(); + + vm.prank(alice); + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_DesiredRecipientUpdated(alice, bob); + vault.updateDesiredRecipient(bob); + assertEq(vault.desiredRecipient(), bob, "unexpected desiredRecipient"); + } + + function test_RevertIf_updateDesiredRecipientNotGrantee() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + vm.stopPrank(); + + vm.expectRevert(BaseAllocation.MetaVesT_OnlyGrantee.selector); + vault.updateDesiredRecipient(chad); + } + + function _createTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + return RestrictedTokenAward(controller.createMetavest( + metavestController.metavestType.RestrictedTokenAward, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: uint48(block.timestamp), + unlockRate: 100e6, + unlockStartTime: uint48(block.timestamp) + }), + new BaseAllocation.Milestone[](0), + 1e6, // no-op: exercisePrice + address(paymentToken), + 0, // no-op: _shortStopDuration + 0 // no-op: _longStopDate + )); + } +} diff --git a/test/MetaVesTController.abstract-beta.t.sol b/test/MetaVesTController.abstract-beta.t.sol new file mode 100644 index 0000000..e09d361 --- /dev/null +++ b/test/MetaVesTController.abstract-beta.t.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {FalseCondition} from "./mocks/MockCondition.sol"; + +contract MetaVestControllerAbstractBetaTest is Test { + string saltStr = "MetaVestControllerAbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + metavestController controller; + + function setUp() public { + vm.startPrank(deployer); + + controller = new metavestController{salt: salt}( + authority, + authority, + address(0), + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + + // Prepare funds + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_RecipientOverrideUpdatedOnConstruct() public { + bytes32 salt = keccak256(bytes("test_RecipientOverrideUpdatedOnConstruct")); + + vm.expectEmit(true, true, true, true); + emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); + metavestController testController = new metavestController{salt: salt}( + authority, + authority, + chad, + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + assertEq(testController.recipientOverride(), chad, "unexpected recipientOverride"); + } + + function test_updateRecipientOverride() public { + vm.prank(authority); + vm.expectEmit(true, true, true, true, address(controller)); + emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); + controller.updateRecipientOverride(chad); + assertEq(controller.recipientOverride(), chad, "unexpected recipientOverride"); + } + + function test_RevertIf_updateRecipientOverrideNotAuthority() public { + vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); + controller.updateRecipientOverride(chad); + } + + function test_updateMetavestVestingStartTime() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, block.timestamp + 10, "unexpected vestingStartTime before update"); + } + + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), uint48(block.timestamp + 30)) + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(address(vault), metavestController.updateMetavestVestingStartTime.selector, true); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, block.timestamp + 30, "unexpected vestingStartTime after update"); + } + } + + function test_RevertIf_updateMetavestVestingStartTimeNotAuthority() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + } + + function test_RevertIf_updateMetavestVestingStartTimeConditionNotMet() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + // Add mock condition + address falseCondition = address(new FalseCondition()); + vm.prank(authority); + controller.updateFunctionCondition( + falseCondition, + metavestController.updateMetavestVestingStartTime.selector + ); + + vm.expectRevert(abi.encodeWithSelector(metavestController.MetaVesTController_ConditionNotSatisfied.selector, falseCondition)); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + } + + function test_RevertIf_updateMetavestVestingStartTimeNoConsent() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_AmendmentNeitherMutualNorMajorityConsented.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + } + + function test_updateMetavestUnlockStartTime() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, block.timestamp + 20, "unexpected unlockStartTime before update"); + } + + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), uint48(block.timestamp + 40)) + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(address(vault), metavestController.updateMetavestUnlockStartTime.selector, true); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 40)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, block.timestamp + 40, "unexpected unlockStartTime after update"); + } + } + + function test_RevertIf_updateMetavestUnlockStartTimeNotAuthority() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 40)); + } + + function test_RevertIf_updateMetavestUnlockStartTimeConditionNotMet() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + // Add mock condition + address falseCondition = address(new FalseCondition()); + vm.prank(authority); + controller.updateFunctionCondition( + falseCondition, + metavestController.updateMetavestUnlockStartTime.selector + ); + + vm.expectRevert(abi.encodeWithSelector(metavestController.MetaVesTController_ConditionNotSatisfied.selector, falseCondition)); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 30)); + } + + + + function test_RevertIf_updateMetavestUnlockStartTimeNoConsent() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_AmendmentNeitherMutualNorMajorityConsented.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 40)); + } + + function _createTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + return RestrictedTokenAward(controller.createMetavest( + metavestController.metavestType.RestrictedTokenAward, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: uint48(block.timestamp + 10), + unlockRate: 100e6, + unlockStartTime: uint48(block.timestamp + 20) + }), + new BaseAllocation.Milestone[](0), + 1e6, // no-op: exercisePrice + address(paymentToken), + block.timestamp, // no-op: _shortStopDuration + 0 // no-op: _longStopDate + )); + } +} diff --git a/test/MetaVesTFactory.t.sol b/test/MetaVesTFactory.t.sol deleted file mode 100644 index 63d1108..0000000 --- a/test/MetaVesTFactory.t.sol +++ /dev/null @@ -1,71 +0,0 @@ -//SPDX-License-Identifier: AGPL-3.0-only - -pragma solidity ^0.8.18; - -import "forge-std/Test.sol"; -import "../src/MetaVesTFactory.sol"; -import "../src/MetaVesTController.sol"; -import "../src/VestingAllocationFactory.sol"; -import "../src/TokenOptionFactory.sol"; -import "../src/RestrictedTokenFactory.sol"; - -interface IERC20 { - function transfer(address recipient, uint256 amount) external returns (bool); - function balanceOf(address account) external view returns (uint256); - function approve(address spender, uint256 amount) external returns (bool); - -} - -/// @dev foundry framework testing of MetaVesTFactory.sol -/// forge t --via-ir - -/// @notice test contract for MetaVesTFactory using Foundry -contract MetaVesTFactoryTest is Test { - MetaVesTFactory internal factory; - address factoryAddr; - address dai_addr = 0x3e622317f8C93f7328350cF0B56d9eD4C620C5d6; - VestingAllocationFactory _factory;// = new VestingAllocationFactory(); - RestrictedTokenFactory _factory2;// = new RestrictedTokenFactory(); - TokenOptionFactory _factory3;// = new TokenOptionFactory(); - - event MetaVesT_Deployment( - address newMetaVesT, - address authority, - address controller, - address dao, - address paymentToken - ); - - function setUp() public { - _factory = new VestingAllocationFactory(); - _factory2 = new RestrictedTokenFactory(); - _factory3 = new TokenOptionFactory(); - factory = new MetaVesTFactory(); - factoryAddr = address(factory); - address _authority = address(0xa); - - address _dao = address(0xB); - address _paymentToken = address(0xC); - - address _controller = factory.deployMetavestAndController(_authority, _dao, address(_factory), address(_factory2), address(_factory3)); - } - - function testDeployMetavestAndController() public { - address _authority = address(0xa); - address _dao = address(0xB); - address _paymentToken = address(0xC); - - - address _controller = factory.deployMetavestAndController(_authority, _dao, address(_factory), address(_factory2), address(_factory3)); - metavestController controller = metavestController(_controller); - } - - function testFailControllerZeroAddress() public { - address _authority = address(0); - address _dao = address(0); - address _paymentToken = address(0); - factory.deployMetavestAndController(_authority, _dao, address(0), address(0), address(0)); - } - - -} diff --git a/test/RestrictedTokenAllocation.abstract-beta.t.sol b/test/RestrictedTokenAllocation.abstract-beta.t.sol new file mode 100644 index 0000000..ec8bb01 --- /dev/null +++ b/test/RestrictedTokenAllocation.abstract-beta.t.sol @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract RestrictedTokenAwardAbstractBetaTest is Test { + string saltStr = "RestrictedTokenAwardAbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + metavestController controller; + RestrictedTokenAward vault; + + function setUp() public { + vm.startPrank(deployer); + + controller = new metavestController{salt: salt}( + authority, + authority, + address(0), // _recipientOverride + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + + // Prepare funds + + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + paymentToken.mint(authority, 100000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_SanityCheck() public { + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + assertEq(vault.desiredRecipient(), address(0), "test vault should have no recipient preference set"); + } + + /// @notice Payment should go to the default recipient (grantee) + function test_claimRepurchasedTokensDefaultRecipient() public { + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + + RestrictedTokenAward vault = _createAndTerminateTestVault(address(0)); // no grantee preference + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(alice) - alicePaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_claimRepurchasedTokensGranteePreference() public { + uint256 bobPaymentTokenBalanceBefore = paymentToken.balanceOf(bob); + + RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(bob) - bobPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_claimRepurchasedTokensControllerOverride() public { + RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient, but it would be overridden and have no effects + + // Override recipient address + vm.prank(authority); + controller.updateRecipientOverride(chad); + + uint256 chadPaymentTokenBalanceBefore = paymentToken.balanceOf(chad); + + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(chad) - chadPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_RevertIf_repurchaseTokensShortStopTimeNotReached() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Not enough wait for shortStopDuration + vm.warp(block.timestamp + 9); + + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVesT_ShortStopTimeNotReached.selector); + vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation + + vm.stopPrank(); + } + + function test_RevertIf_repurchaseTokensMoreThanAvailable() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Wait for shortStopDuration + vm.warp(block.timestamp + 10); + + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVesT_MoreThanAvailable.selector); + vault.repurchaseTokens(8801e6); // 10000 - (1000 + 100 * 2) at the time of creation, plus one + + vm.stopPrank(); + } + + function test_updateVestingStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 10, "unexpected vestingStartTime before update"); + assertEq(vault.getVestedTokenAmount(), 0, "unexpected getVestedTokenAmount() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(now + 30)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 30, "unexpected vestingStartTime after update"); + + vm.warp(now + 29); + assertEq(vault.getVestedTokenAmount(), 0, "unexpected getVestedTokenAmount() after update & before new start time"); + } + + vm.warp(now + 30 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getVestedTokenAmount(), 1200e6, "unexpected getVestedTokenAmount() after update & after new start time"); + } + + function test_RevertIf_updateVestingStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Old vestingStartTime has passed + vm.warp(now + 10); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 30); + } + + function test_RevertIf_updateVestingStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 5) + ); + + // Old vestingStartTime has not passed, but new vestingStartTime has + vm.warp(now + 5); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 5); + } + + function test_updateUnlockStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 20, "unexpected unlockStartTime before update"); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(now + 40)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 40, "unexpected unlockStartTime after update"); + + vm.warp(now + 39); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() after update & before new start time"); + } + + vm.warp(now + 40 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getUnlockedTokenAmount(), 1200e6, "unexpected getUnlockedTokenAmount() after update & after new start time"); + } + + function test_RevertIf_updateUnlockStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Old unlockStartTime has passed + vm.warp(now + 20); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 40); + } + + function test_RevertIf_updateUnlockStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 15) + ); + + // Old unlockStartTime has not passed, but new unlockStartTime has + vm.warp(now + 15); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 15); + } + + function _createTestVault( + address desiredRecipient, + uint48 vestingStartTime, + uint48 unlockStartTime + ) internal returns (RestrictedTokenAward) { + return RestrictedTokenAward(controller.createMetavest( + metavestController.metavestType.RestrictedTokenAward, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: vestingStartTime, + unlockRate: 100e6, + unlockStartTime: unlockStartTime + }), + new BaseAllocation.Milestone[](0), + 10e6, + address(paymentToken), + 10, // shortStopDuration + 0 // no-op: _longStopDate + )); + } + + function _createAndTerminateTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + vm.startPrank(authority); + + RestrictedTokenAward vault = _createTestVault( + desiredRecipient, + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Wait for shortStopDuration + vm.warp(block.timestamp + 10); + + uint256 authorityPaymentTokenBalanceBefore = paymentToken.balanceOf(authority); + uint256 authorityVestingTokenBalanceBefore = vestingToken.balanceOf(authority); + + paymentToken.approve(address(vault), 100000e6); + vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation + + assertEq(authorityPaymentTokenBalanceBefore - paymentToken.balanceOf(authority), 88000e6, "unexpected paid payment"); + assertEq(vestingToken.balanceOf(authority) - authorityVestingTokenBalanceBefore, 8800e6, "unexpected repurchased token amount"); + + vm.stopPrank(); + + return vault; + } + + function _consentStartTime(address vaultAddr, bytes4 msgSig, bytes memory data) internal { + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + vaultAddr, + msgSig, + data + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(vaultAddr, msgSig, true); + } +} diff --git a/test/TokenOptionAllocation.abstract-beta.t.sol b/test/TokenOptionAllocation.abstract-beta.t.sol new file mode 100644 index 0000000..0ad214e --- /dev/null +++ b/test/TokenOptionAllocation.abstract-beta.t.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {TokenOptionAllocation} from "../src/TokenOptionAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract TokenOptionAllocationAbstractBetaTest is Test { + string saltStr = "TokenOptionAllocationAbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + metavestController controller; + TokenOptionAllocation vault; + + function setUp() public { + vm.startPrank(deployer); + + controller = new metavestController{salt: salt}( + authority, + authority, + address(0), // _recipientOverride + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + + // Prepare funds + + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + paymentToken.mint(alice, 100000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_SanityCheck() public { + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + assertEq(vault.desiredRecipient(), address(0), "test vault should have no recipient preference set"); + } + + /// @notice Vesting token should go to the default recipient (grantee) + function test_exerciseTokenOptionDefaultRecipient() public { + uint48 now = uint48(block.timestamp); + + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + uint256 aliceVestingTokenBalanceBefore = vestingToken.balanceOf(alice); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 12000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectEmit(true, true, true, true, address(vault)); + emit TokenOptionAllocation.MetaVesT_TokenOptionExercised(alice, 1200e6, 12000e6); + vault.exerciseTokenOption(1200e6); + vault.withdraw(1200e6); + vm.stopPrank(); + + // Payment is always coming from grantee + assertEq(alicePaymentTokenBalanceBefore - paymentToken.balanceOf(alice), 12000e6, "unexpected payment"); + assertEq(vestingToken.balanceOf(alice) - aliceVestingTokenBalanceBefore, 1200e6, "unexpected received token amount"); + } + + /// @notice Vesting token should go to the recipient address set by grantee + function test_exerciseTokenOptionGranteePreference() public { + uint48 now = uint48(block.timestamp); + + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + uint256 bobVestingTokenBalanceBefore = vestingToken.balanceOf(bob); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + bob, // set bob as the desired recipient + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 12000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectEmit(true, true, true, true, address(vault)); + emit TokenOptionAllocation.MetaVesT_TokenOptionExercised(alice, 1200e6, 12000e6); + vault.exerciseTokenOption(1200e6); + vault.withdraw(1200e6); + vm.stopPrank(); + + // Payment is always coming from grantee + assertEq(alicePaymentTokenBalanceBefore - paymentToken.balanceOf(alice), 12000e6, "unexpected payment"); + assertEq(vestingToken.balanceOf(bob) - bobVestingTokenBalanceBefore, 1200e6, "unexpected received token amount"); + } + + /// @notice Vesting token should go to the recipient address set by the controller + function test_exerciseTokenOptionControllerOverride() public { + uint48 now = uint48(block.timestamp); + + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + uint256 chadVestingTokenBalanceBefore = vestingToken.balanceOf(chad); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + bob, // set bob as the desired recipient, but it would be overridden and have no effects + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // Override recipient address + vm.prank(authority); + controller.updateRecipientOverride(chad); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 12000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectEmit(true, true, true, true, address(vault)); + emit TokenOptionAllocation.MetaVesT_TokenOptionExercised(alice, 1200e6, 12000e6); + vault.exerciseTokenOption(1200e6); + vault.withdraw(1200e6); + vm.stopPrank(); + + // Payment is always coming from grantee + assertEq(alicePaymentTokenBalanceBefore - paymentToken.balanceOf(alice), 12000e6, "unexpected payment"); + assertEq(vestingToken.balanceOf(chad) - chadVestingTokenBalanceBefore, 1200e6, "unexpected received token amount"); + } + + function test_RevertIf_exerciseTokenOptionShortStopDatePassed() public { + uint48 now = uint48(block.timestamp); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // contract terminated and shortStop date has passed + vm.prank(authority); + controller.terminateMetavestVesting(address(vault)); + vm.warp(now + 11); + + vm.startPrank(alice); + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVest_ShortStopDatePassed.selector); + vault.exerciseTokenOption(1e6); + vm.stopPrank(); + } + + function test_RevertIf_exerciseTokenOptionMoreThanAvailable() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 100000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectRevert(BaseAllocation.MetaVesT_MoreThanAvailable.selector); + vault.exerciseTokenOption(1201e6); + vm.stopPrank(); + } + + function test_updateVestingStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 10, "unexpected vestingStartTime before update"); + assertEq(vault.getAmountExercisable(), 0, "unexpected getAmountExercisable() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(now + 30)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 30, "unexpected vestingStartTime after update"); + + vm.warp(now + 29); + assertEq(vault.getAmountExercisable(), 0, "unexpected getAmountExercisable() after update & before new start time"); + } + + vm.warp(now + 30 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getAmountExercisable(), 1200e6, "unexpected getAmountExercisable() after update & after new start time"); + } + + function test_RevertIf_updateVestingStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Old vestingStartTime has passed + vm.warp(now + 10); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 30); + } + + function test_RevertIf_updateVestingStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 5) + ); + + // Old vestingStartTime has not passed, but new vestingStartTime has + vm.warp(now + 5); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 5); + } + + function test_updateUnlockStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 20, "unexpected unlockStartTime before update"); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(now + 40)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 40, "unexpected unlockStartTime after update"); + + vm.warp(now + 39); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() after update & before new start time"); + } + + vm.warp(now + 40 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getUnlockedTokenAmount(), 1200e6, "unexpected getUnlockedTokenAmount() after update & after new start time"); + } + + function test_RevertIf_updateUnlockStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Old unlockStartTime has passed + vm.warp(now + 20); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 40); + } + + function test_RevertIf_updateUnlockStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 15) + ); + + // Old unlockStartTime has not passed, but new unlockStartTime has + vm.warp(now + 15); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 15); + } + + function _createTestVault( + address desiredRecipient, + uint48 vestingStartTime, + uint48 unlockStartTime + ) internal returns (TokenOptionAllocation) { + return TokenOptionAllocation(controller.createMetavest( + metavestController.metavestType.TokenOption, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: vestingStartTime, + unlockRate: 100e6, + unlockStartTime: unlockStartTime + }), + new BaseAllocation.Milestone[](0), + 10e6, + address(paymentToken), + 10, // shortStopDuration + 0 // no-op: _longStopDate + )); + } + + function _consentStartTime(address vaultAddr, bytes4 msgSig, bytes memory data) internal { + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + vaultAddr, + msgSig, + data + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(vaultAddr, msgSig, true); + } +} diff --git a/test/amendement.t.sol b/test/amendement.t.sol index 205c60f..954a7bd 100644 --- a/test/amendement.t.sol +++ b/test/amendement.t.sol @@ -572,6 +572,7 @@ contract MetaVestControllerTest is Test { controller = new metavestController( authority, dao, + address(0), address(factory), address(tokenFactory), address(restrictedTokenFactory) diff --git a/test/controller.t.sol b/test/controller.t.sol index 809c079..9f8c0fe 100644 --- a/test/controller.t.sol +++ b/test/controller.t.sol @@ -1117,6 +1117,7 @@ contract MetaVestControllerTest is Test { controller = new metavestController( authority, dao, + address(0), address(factory), address(tokenFactory), address(restrictedTokenFactory) diff --git a/test/mocks/MockCondition.sol b/test/mocks/MockCondition.sol index aed853e..f9659b7 100644 --- a/test/mocks/MockCondition.sol +++ b/test/mocks/MockCondition.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "../../src/BaseAllocation.sol"; @@ -95,3 +95,10 @@ contract SignatureCondition is BaseCondition { } else return false; // Default case, should not reach here } } + +/// @title FalseCondition - A condition that always fails +contract FalseCondition is BaseCondition { + function checkCondition(address _contract, bytes4 _functionSignature, bytes memory data) public view override returns (bool) { + return false; + } +} diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol new file mode 100644 index 0000000..2060eeb --- /dev/null +++ b/test/mocks/MockERC20.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + uint8 internal _decimals; + + constructor(string memory _name, string memory _symbol, uint8 __decimals) ERC20(_name, _symbol) { + _decimals = __decimals; + } + + function decimals() public view override returns (uint8) { + return _decimals; + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } +}