diff --git a/src/JBVestedERC20.sol b/src/JBVestedERC20.sol new file mode 100644 index 000000000..6f5fdb35d --- /dev/null +++ b/src/JBVestedERC20.sol @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit, Nonces} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol"; + +import {IJBToken} from "./interfaces/IJBToken.sol"; +import {JBVestingSchedule} from "./structs/JBVestingSchedule.sol"; + +/// @notice An ERC-20 token that can be used by a project in `JBTokens` and `JBController`. +/// @dev By default, a project uses "credits" to track balances. Once a project sets their `IJBToken` using +/// `JBController.deployERC20For(...)` or `JBController.setTokenFor(...)`, credits can be redeemed to claim tokens. +/// @dev `JBController.deployERC20For(...)` deploys a `JBERC20` contract and sets it as the project's token. +contract JBVestedERC20 is ERC20Votes, ERC20Permit, Ownable, IJBToken { + //*********************************************************************// + // --------------------------- custom errors ------------------------- // + //*********************************************************************// + + error JBVestedERC20_TransferExceedsVestedAmount(uint256 amount, uint256 vestedAmount); + + //*********************************************************************// + // ---------------- public immutable stored properties --------------- // + //*********************************************************************// + + /// @notice The number of seconds to wait before the tokens start to unlock. + uint256 public immutable override CLIFF; + + /// @notice The number of seconds it takes to unlock the full amount of tokens. + uint256 public immutable override UNLOCK_DURATION; + + /// @notice The project ID. + uint256 public immutable override PROJECT_ID; + + /// @notice The JBTokens contract. + IJBTokens public immutable override TOKENS; + + //*********************************************************************// + // --------------------- internal stored properties ------------------ // + //*********************************************************************// + + /// @notice The token's name. + // slither-disable-next-line shadowing-state + string private _name; + + /// @notice The token's symbol. + // slither-disable-next-line shadowing-state + string private _symbol; + + mapping(address => VestingSchedule[]) private _vestingSchedules; + + /// @notice Mapping of addresses exempt from vesting restrictions. + mapping(address => bool) public isExemptFromVesting; + + /// @notice The admin address for managing vesting exemptions. + address public vestingAdmin; + + //*********************************************************************// + // -------------------------- events -------------------------------- // + //*********************************************************************// + event ExemptAddressAdded(address indexed account); + event ExemptAddressRemoved(address indexed account); + + //*********************************************************************// + // -------------------------- modifiers ----------------------------- // + //*********************************************************************// + modifier onlyVestingAdmin() { + require(msg.sender == vestingAdmin, "NOT_ADMIN"); + _; + } + + //*********************************************************************// + // -------------------------- constructor ---------------------------- // + //*********************************************************************// + + /// @param tokens A contract that manages token minting and burning. + constructor(IJBTokens tokens) Ownable(address(this)) ERC20("invalid", "invalid") ERC20Permit("JBToken") { + TOKENS = tokens; + } + + //*********************************************************************// + // -------------------------- public views --------------------------- // + //*********************************************************************// + + /// @notice The balance of the given address. + /// @dev Returns only the vested (available) amount, not the total tokens owned. + /// @param account The account to get the balance of. + /// @return The number of vested (available) tokens owned by the `account`, as a fixed point number with 18 + /// decimals. + function balanceOf(address account) public view override(ERC20, IJBToken) returns (uint256) { + if (isExemptFromVesting[account]) { + return super.balanceOf(account); + } + return super.balanceOf(account) - _vestingAmount(account); + } + + /// @notice This token can only be added to a project when its created by the `JBTokens` contract. + function canBeAddedTo(uint256 projectId) external pure override returns (bool) { + return projectId == PROJECT_ID; + } + + /// @notice The number of decimals used for this token's fixed point accounting. + /// @return The number of decimals. + function decimals() public view override(ERC20, IJBToken) returns (uint8) { + return super.decimals(); + } + + /// @notice The token's name. + function name() public view virtual override returns (string memory) { + return _name; + } + + /// @notice The token's symbol. + function symbol() public view virtual override returns (string memory) { + return _symbol; + } + + /// @notice The total supply of this ERC20 i.e. the total number of tokens in existence. + /// @return The total supply of this ERC20, as a fixed point number. + function totalSupply() public view override(ERC20, IJBToken) returns (uint256) { + return super.totalSupply(); + } + + /// @notice The total amount still vesting for an account. + /// @param account The address to get the vesting amount for. + /// @return The total amount still vesting for the `account`. + function vestingAmount(address account) public view returns (uint256) { + return _vestingAmount(account); + } + + /// @notice The total amount vested for an account. + /// @param account The address to get the vested amount for. + /// @return The total amount vested for the `account`. + function vestedAmount(address account) public view returns (uint256) { + return balanceOf(account) - _vestingAmount(account); + } + + //*********************************************************************// + // ---------------------- external transactions ---------------------- // + //*********************************************************************// + + /// @notice Burn some outstanding tokens. + /// @dev Can only be called by this contract's owner. + /// @param account The address to burn tokens from. + /// @param amount The amount of tokens to burn, as a fixed point number with 18 decimals. + function burn(address account, uint256 amount) external override onlyOwner { + return _burn(account, amount); + } + + /// @notice Mints more of this token with a new vesting schedule. + /// @dev Can only be called by this contract's owner. + /// @param account The address to mint the new tokens to. + /// @param amount The amount of tokens to mint, as a fixed point number with 18 decimals. + function mint(address account, uint256 amount) external override onlyOwner { + _mint(account, amount); + + // Add a new vesting schedule for the minted tokens + _vestingSchedules[account].push(VestingSchedule({totalAmount: amount, startTime: block.timestamp})); + } + + //*********************************************************************// + // ----------------------- public transactions ----------------------- // + //*********************************************************************// + + /// @notice Initializes the token. + /// @param name_ The token's name. + /// @param symbol_ The token's symbol. + /// @param owner The token contract's owner. + /// @param projectId The project ID. + /// @param cliff The number of seconds to wait before the tokens start to unlock. + /// @param unlockDuration The number of seconds it takes to unlock the full amount of tokens. + /// @param vestingAdmin_ The admin address for managing vesting exemptions. + function initialize( + string memory name_, + string memory symbol_, + address owner, + uint256 projectId, + uint256 cliff, + uint256 unlockDuration, + address vestingAdmin_ + ) + public + override + { + // Prevent re-initialization by reverting if a name is already set or if the provided name is empty. + if (bytes(_name).length != 0 || bytes(name_).length == 0) revert(); + + _name = name_; + _symbol = symbol_; + PROJECT_ID = projectId; + CLIFF = cliff; + UNLOCK_DURATION = unlockDuration; + vestingAdmin = vestingAdmin_; + + // Transfer ownership to the owner. + _transferOwnership(owner); + } + + /// @notice Required override. + function nonces(address owner) public view virtual override(ERC20Permit, Nonces) returns (uint256) { + return super.nonces(owner); + } + + //*********************************************************************// + // ------------------------ internal functions ----------------------- // + //*********************************************************************// + + /// @notice Required override. + function _update(address from, address to, uint256 value) internal virtual override(ERC20, ERC20Votes) { + super._update(from, to, value); + } + + /// @notice Override to enforce vesting schedule and clean up fully vested schedules. + /// @param from The address to transfer from. + /// @param to The address to transfer to. + /// @param amount The amount to transfer. + function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override { + super._beforeTokenTransfer(from, to, amount); + + if (from != address(0)) { + // Not a minting operation + // Skip vesting checks for exempt addresses + if (isExemptFromVesting[from]) { + return; + } + + // Keep track of the amount still vesting for the sender. + uint256 vestingAmount = _vestingAmountFor(from); + + // Make sure there is sufficient vested balance to transfer. + if (balanceOf(from) - vestingAmount < amount) { + revert JBVestedERC20_TransferExceedsVestedAmount(amount, vestingAmount); + } + + // Clean up fully vested schedules + VestingSchedule[] storage schedules = _vestingSchedules[from]; + uint256 len = schedules.length; + uint256 cutoff = 0; + for (uint256 i = 0; i < len; i++) { + VestingSchedule storage schedule = schedules[i]; + if (block.timestamp >= schedule.startTime + CLIFF + UNLOCK_DURATION) { + cutoff = i + 1; + } else { + break; + } + } + if (cutoff > 0) { + // Remove all fully vested schedules at the start of the array + for (uint256 i = cutoff; i < len; i++) { + schedules[i - cutoff] = schedules[i]; + } + for (uint256 i = 0; i < cutoff; i++) { + schedules.pop(); + } + } + } + } + + /// @notice Calculate the total amount still vesting for an account. + /// @param account The address to get the vesting amount for. + /// @return stillVesting The total amount still vesting for the `account`. + function _vestingAmountFor(address account) internal view returns (uint256 stillVesting) { + // Iterate over the vesting schedules for the account. + VestingSchedule[] storage schedules = _vestingSchedules[account]; + for (uint256 i = schedules.length; i > 0; i--) { + // Get the vesting schedule for the account. + VestingSchedule storage schedule = schedules[i - 1]; + + // Calculate the elapsed time since the vesting schedule started. + uint256 elapsedTime = block.timestamp - schedule.startTime; + + // If the cliff period hasn't passed, the entire amount is still vesting. + if (elapsedTime < CLIFF) { + // If the cliff period hasn't passed, the entire amount is still vesting + stillVesting += schedule.totalAmount; + // If the cliff period has passed, calculate the amount still vesting. + } else if (elapsedTime < CLIFF + UNLOCK_DURATION) { + uint256 vested = (schedule.totalAmount * elapsedTime) / UNLOCK_DURATION; + stillVesting += schedule.totalAmount - vested; + // If the schedule is fully vested, no need to add anything else since all other schedules must also be + // fully vested. + } else { + return stillVesting; + } + } + } + + //*********************************************************************// + // ---------------------- external admin functions ------------------ // + //*********************************************************************// + + /// @notice Add an address to the vesting exemption list. + /// @dev Only callable by the vesting admin. + function addExemptAddress(address account) external onlyVestingAdmin { + isExemptFromVesting[account] = true; + emit ExemptAddressAdded(account); + } + + /// @notice Remove an address from the vesting exemption list. + /// @dev Only callable by the vesting admin. + function removeExemptAddress(address account) external onlyVestingAdmin { + isExemptFromVesting[account] = false; + emit ExemptAddressRemoved(account); + } + + /// @notice Allows the owner to change the admin address. + function setVestingAdmin(address newVestingAdmin) external onlyVestingAdmin { + vestingAdmin = newVestingAdmin; + } +} diff --git a/src/JBVestedERC20Deployer.sol b/src/JBVestedERC20Deployer.sol new file mode 100644 index 000000000..c2dee3b1f --- /dev/null +++ b/src/JBVestedERC20Deployer.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {JBVestedERC20} from "./JBVestedERC20.sol"; +import {IJBTokens} from "./interfaces/IJBTokens.sol"; +import {IJBController} from "./interfaces/IJBController.sol"; +import {IJBToken} from "./interfaces/IJBToken.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {IJBDirectory} from "./interfaces/IJBDirectory.sol"; + +contract JBVestedERC20Deployer { + IJBTokens public immutable TOKENS; + IJBDirectory public immutable DIRECTORY; + address public immutable TOKEN; + + event VestedERC20Deployed(address indexed token, uint256 indexed projectId, bytes32 salt); + + /// @param directory A contract storing directories of terminals and controllers for each project. + /// @param tokens A contract that manages token minting and burning. + /// @param token The JBVestedERC20 implementation. + constructor(IJBDirectory directory, IJBTokens tokens, address token) { + DIRECTORY = directory; + TOKENS = tokens; + TOKEN = token; + } + + /// @notice Deploys, initializes, and sets a JBVestedERC20 as the project's token. + /// @param projectId The project ID. + /// @param name The token's name. + /// @param symbol The token's symbol. + /// @param cliff The number of seconds to wait before the tokens start to unlock. + /// @param unlockDuration The number of seconds it takes to unlock the full amount of tokens. + /// @param vestingAdmin The admin address for managing vesting exemptions. + /// @param salt The salt for deterministic deployment (optional, set to 0 for non-deterministic). + /// @return token The address of the deployed and initialized JBVestedERC20. + function deployVestedERC20ForProject( + uint256 projectId, + string memory name, + string memory symbol, + uint256 cliff, + uint256 unlockDuration, + address vestingAdmin, + bytes32 salt + ) + external + returns (JBVestedERC20 token) + { + token = salt == bytes32(0) + ? IJBToken(Clones.clone(address(TOKEN))) + : IJBToken(Clones.cloneDeterministic(address(TOKEN), keccak256(abi.encode(msg.sender, salt)))); + token.initialize({ + name: name, + symbol: symbol, + owner: address(TOKENS), + projectId: projectId, + cliff: cliff, + unlockDuration: unlockDuration, + vestingAdmin: admin + }); + // Get the controller for the project from the directory + IJBController controller = IJBController(address(DIRECTORY.controllerOf(projectId))); + controller.setTokenFor({projectId: projectId, token: IJBToken(address(token))}); + emit VestedERC20Deployed(address(token), projectId, salt); + } +} diff --git a/src/interfaces/IJBVestedERC20Deployer.sol b/src/interfaces/IJBVestedERC20Deployer.sol new file mode 100644 index 000000000..7249e502c --- /dev/null +++ b/src/interfaces/IJBVestedERC20Deployer.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IJBDirectory} from "./IJBDirectory.sol"; +import {IJBTokens} from "./IJBTokens.sol"; +import {IJBToken} from "./IJBToken.sol"; + +interface IJBVestedERC20Deployer { + event VestedERC20Deployed(address indexed token, uint256 indexed projectId, bytes32 salt); + + function DIRECTORY() external view returns (IJBDirectory); + function TOKENS() external view returns (IJBTokens); + function TOKEN() external view returns (address); + + /// @notice Deploys, initializes, and sets a JBVestedERC20 as the project's token. + /// @param projectId The project ID. + /// @param name The token's name. + /// @param symbol The token's symbol. + /// @param owner The token contract's owner. + /// @param cliff The number of seconds to wait before the tokens start to unlock. + /// @param unlockDuration The number of seconds it takes to unlock the full amount of tokens. + /// @param salt The salt for deterministic deployment (optional, set to 0 for non-deterministic). + /// @return token The address of the deployed and initialized JBVestedERC20. + function deployVestedERC20ForProject( + uint256 projectId, + string calldata name, + string calldata symbol, + address owner, + uint256 cliff, + uint256 unlockDuration, + bytes32 salt + ) + external + returns (IJBToken token); +} diff --git a/src/libraries/JBCurrencyIds.sol b/src/libraries/JBCurrencyIds.sol index 0c265a559..d5c6ccb61 100644 --- a/src/libraries/JBCurrencyIds.sol +++ b/src/libraries/JBCurrencyIds.sol @@ -3,5 +3,5 @@ pragma solidity ^0.8.0; library JBCurrencyIds { uint32 public constant ETH = 1; - uint32 public constant USD = 2; + uint32 public constant USD = 3; // Skip 2, a botched price feed was deployed on 2. } diff --git a/src/structs/JBVestingSchedule.sol b/src/structs/JBVestingSchedule.sol new file mode 100644 index 000000000..0f947eb0c --- /dev/null +++ b/src/structs/JBVestingSchedule.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @custom:member totalAmount The total amount of tokens to be vested. +/// @custom:member startTime The timestamp when the vesting starts. +struct JBVestingSchedule { + uint256 totalAmount; + uint256 startTime; +} diff --git a/test/units/static/JBERC20/TestVestedERC20Deployer.sol b/test/units/static/JBERC20/TestVestedERC20Deployer.sol new file mode 100644 index 000000000..c07c02dd3 --- /dev/null +++ b/test/units/static/JBERC20/TestVestedERC20Deployer.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import /* {*} from */ "../../../helpers/TestBaseWorkflow.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {JBVestedERC20} from "../../../../src/JBVestedERC20.sol"; +import {JBVestedERC20Deployer} from "../../../../src/JBVestedERC20Deployer.sol"; +import {IJBDirectory} from "../../../../src/interfaces/IJBDirectory.sol"; +import {IJBTokens} from "../../../../src/interfaces/IJBTokens.sol"; +import {IJBController} from "../../../../src/interfaces/IJBController.sol"; +import {IJBToken} from "../../../../src/interfaces/IJBToken.sol"; + +contract MockDirectory is IJBDirectory { + address public controller; + function controllerOf(uint256) external view override returns (address) { return controller; } + // stub all other functions + function PROJECTS() external pure override returns (address) { return address(0); } + function isAllowedToSetFirstController(address) external pure override returns (bool) { return false; } + function isTerminalOf(uint256, address) external pure override returns (bool) { return false; } + function primaryTerminalOf(uint256, address) external pure override returns (address) { return address(0); } + function terminalsOf(uint256) external pure override returns (address[] memory) { address[] memory a; return a; } + function setControllerOf(uint256, address) external pure override {} + function setIsAllowedToSetFirstController(address, bool) external pure override {} + function setPrimaryTerminalOf(uint256, address, address) external pure override {} + function setTerminalsOf(uint256, address[] calldata) external pure override {} +} + +contract MockTokens is IJBTokens { + // stub all functions +} + +contract MockController is IJBController { + address public lastSetToken; + uint256 public lastSetProjectId; + function setTokenFor(uint256 projectId, IJBToken token) external override { + lastSetProjectId = projectId; + lastSetToken = address(token); + } + // stub all other functions +} + +contract TestVestedERC20Deployer is JBTest { + function test_DeployerDeploysAndInitializesVestedERC20() public { + // Deploy implementation + JBVestedERC20 implementation = new JBVestedERC20(); + // Deploy mocks + MockDirectory directory = new MockDirectory(); + MockTokens tokens = new MockTokens(); + MockController controller = new MockController(); + directory.controller = address(controller); + // Deploy deployer + JBVestedERC20Deployer deployer = new JBVestedERC20Deployer( + IJBDirectory(address(directory)), + IJBTokens(address(tokens)), + address(implementation) + ); + // Deploy a new vested token via the deployer + string memory name = "VestedToken"; + string memory symbol = "VST"; + address owner = address(0x123); + address admin = address(0x456); + uint256 projectId = 42; + uint256 cliff = 1 days; + uint256 duration = 3 days; + bytes32 salt = bytes32(0); + IJBToken token = deployer.deployVestedERC20ForProject( + projectId, name, symbol, owner, cliff, duration, salt + ); + // Check initialization + JBVestedERC20 vested = JBVestedERC20(address(token)); + assertEq(vested.name(), name); + assertEq(vested.symbol(), symbol); + assertEq(vested.owner(), owner); + assertEq(vested.admin(), admin); + assertEq(vested.CLIFF(), cliff); + assertEq(vested.UNLOCK_DURATION(), duration); + assertEq(vested.PROJECT_ID(), projectId); + // Check controller was set + assertEq(controller.lastSetProjectId, projectId); + assertEq(controller.lastSetToken, address(token)); + } +} \ No newline at end of file diff --git a/test/units/static/JBERC20/TestVesting.sol b/test/units/static/JBERC20/TestVesting.sol new file mode 100644 index 000000000..cf56306cc --- /dev/null +++ b/test/units/static/JBERC20/TestVesting.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import /* {*} from */ "../../../helpers/TestBaseWorkflow.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {JBVestedERC20} from "../../../../src/JBVestedERC20.sol"; + +contract TestVesting is JBTest { + address internal _owner = makeAddr("owner"); + address internal _user = makeAddr("user"); +<<<<<<< Updated upstream +======= + address internal _admin = makeAddr("admin"); +>>>>>>> Stashed changes + JBVestedERC20 internal _vestedToken; + + string internal _name = "VestedToken"; + string internal _symbol = "VST"; + uint256 internal _projectId = 1; + uint256 internal _cliff = 1 days; + uint256 internal _duration = 3 days; + uint256 internal _amount = 1000 ether; + + function setUp() public { + _vestedToken = new JBVestedERC20(); +<<<<<<< Updated upstream + _vestedToken.initialize(_name, _symbol, _owner, _projectId, _cliff, _duration); +======= + _vestedToken.initialize(_name, _symbol, _owner, _projectId, _cliff, _duration, _admin); +>>>>>>> Stashed changes + vm.startPrank(_owner); + _vestedToken.mint(_user, _amount); + vm.stopPrank(); + } + + function test_BalanceAndVesting_BeforeCliff() public { + // Before cliff, nothing is vested + assertEq(_vestedToken.balanceOf(_user), 0); + assertEq(_vestedToken.vestingAmount(_user), _amount); + assertEq(_vestedToken.vestedAmount(_user), 0); + } + + function test_BalanceAndVesting_DuringVesting() public { + // Move time to halfway through vesting (after cliff) + vm.warp(block.timestamp + _cliff + _duration / 2); + uint256 expectedVested = (_amount * (_duration / 2 + _cliff)) / _duration; + uint256 actualVested = _vestedToken.balanceOf(_user); + assertGt(actualVested, 0); + assertLt(actualVested, _amount); + assertEq(_vestedToken.vestingAmount(_user) + actualVested, _amount); + } + + function test_BalanceAndVesting_AfterFullVesting() public { + // Move time past full vesting + vm.warp(block.timestamp + _cliff + _duration + 1); + assertEq(_vestedToken.balanceOf(_user), _amount); + assertEq(_vestedToken.vestingAmount(_user), 0); + assertEq(_vestedToken.vestedAmount(_user), _amount); + } + + function test_Transfer_RevertIfNotVested() public { + // Try to transfer before cliff + vm.startPrank(_user); + vm.expectRevert(); + _vestedToken.transfer(makeAddr("recipient"), 1 ether); + vm.stopPrank(); + } + + function test_Transfer_SucceedIfVested() public { + // Move time past full vesting + vm.warp(block.timestamp + _cliff + _duration + 1); + vm.startPrank(_user); + _vestedToken.transfer(makeAddr("recipient"), _amount); + vm.stopPrank(); + } + + function test_CleanupFullyVestedSchedules() public { + // Move time past full vesting + vm.warp(block.timestamp + _cliff + _duration + 1); + // Transfer triggers cleanup + vm.startPrank(_user); + _vestedToken.transfer(makeAddr("recipient"), 1 ether); + vm.stopPrank(); + // The vesting schedule array should be empty + // (Direct storage access for test purposes) + bytes32 slot = keccak256(abi.encode(_user, uint256(6))); // _vestingSchedules is at storage slot 6 + uint256 len; + assembly { + len := sload(slot) + } + assertEq(len, 0); + } +<<<<<<< Updated upstream +} +======= + + function test_OnlyAdminCanAddRemoveExempt() public { + address exempt = makeAddr("exempt"); + vm.startPrank(_owner); + vm.expectRevert(); + _vestedToken.addExemptAddress(exempt); + vm.expectRevert(); + _vestedToken.removeExemptAddress(exempt); + vm.stopPrank(); + vm.startPrank(_admin); + _vestedToken.addExemptAddress(exempt); + assertTrue(_vestedToken.isExemptFromVesting(exempt)); + _vestedToken.removeExemptAddress(exempt); + assertFalse(_vestedToken.isExemptFromVesting(exempt)); + vm.stopPrank(); + } + + function test_OwnerCanChangeAdmin() public { + address newAdmin = makeAddr("newAdmin"); + vm.startPrank(_owner); + _vestedToken.setAdmin(newAdmin); + vm.stopPrank(); + vm.startPrank(newAdmin); + _vestedToken.addExemptAddress(makeAddr("exempt2")); + assertTrue(_vestedToken.isExemptFromVesting(makeAddr("exempt2"))); + vm.stopPrank(); + } +} +/ SPDX-License-Identifier: MIT +>>>>>>> Stashed changes