diff --git a/.tool-versions b/.tool-versions index 958fb369..c6743f9b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ nodejs 20.12.2 +solidity 0.8.24 diff --git a/contracts/talent/TalentRewardClaim.sol b/contracts/talent/TalentRewardClaim.sol index 028c46ed..f81bd033 100644 --- a/contracts/talent/TalentRewardClaim.sol +++ b/contracts/talent/TalentRewardClaim.sol @@ -7,6 +7,7 @@ import "@openzeppelin/contracts/utils/math/Math.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "./TalentProtocolToken.sol"; import "../passport/PassportBuilderScore.sol"; +import "../passport/PassportWalletRegistry.sol"; import "../merkle/MerkleProof.sol"; contract TalentRewardClaim is Ownable, ReentrancyGuard { @@ -14,6 +15,7 @@ contract TalentRewardClaim is Ownable, ReentrancyGuard { TalentProtocolToken public talentToken; PassportBuilderScore public passportBuilderScore; + PassportWalletRegistry public passportWalletRegistry; address public holdingWallet; uint256 public constant WEEKLY_CLAIM_AMOUNT = 2000 ether; uint256 public constant WEEK_DURATION = 7 days; @@ -36,6 +38,7 @@ contract TalentRewardClaim is Ownable, ReentrancyGuard { constructor( TalentProtocolToken _talentToken, PassportBuilderScore _passportBuilderScore, + PassportWalletRegistry _passportWalletRegistry, address _holdingWallet, address initialOwner, bytes32 _merkleRoot @@ -43,6 +46,7 @@ contract TalentRewardClaim is Ownable, ReentrancyGuard { merkleRoot = _merkleRoot; talentToken = _talentToken; passportBuilderScore = _passportBuilderScore; + passportWalletRegistry = _passportWalletRegistry; holdingWallet = _holdingWallet; } @@ -80,6 +84,7 @@ contract TalentRewardClaim is Ownable, ReentrancyGuard { uint256 amountAllocated ) external nonReentrant { require(startTime > 0, "Start time not set"); + require(block.timestamp >= startTime, "Claiming has not started yet"); verify(merkleProof, amountAllocated); @@ -89,7 +94,7 @@ contract TalentRewardClaim is Ownable, ReentrancyGuard { UserInfo storage user = userInfo[msg.sender]; require(amountToClaim > 0, "No tokens owed"); - uint256 passportId = passportBuilderScore.passportRegistry().passportId(beneficiary); + uint256 passportId = passportWalletRegistry.passportId(beneficiary); uint256 builderScore = passportBuilderScore.getScore(passportId); uint256 claimMultiplier = (builderScore > 40) ? 5 : 1; diff --git a/contracts/talent/TalentTGEUnlock.sol b/contracts/talent/TalentTGEUnlock.sol index bbcc63bf..179bb789 100644 --- a/contracts/talent/TalentTGEUnlock.sol +++ b/contracts/talent/TalentTGEUnlock.sol @@ -61,12 +61,11 @@ contract TalentTGEUnlock is Ownable { verifyAmount(merkleProofClaim, amountAllocated); address beneficiary = msg.sender; - uint256 amountToClaim = calculate(beneficiary, amountAllocated); - claimed[beneficiary] += amountToClaim; - IERC20(token).safeTransfer(beneficiary, amountToClaim); + claimed[beneficiary] += amountAllocated; + IERC20(token).safeTransfer(beneficiary, amountAllocated); - emit Claimed(beneficiary, amountToClaim, 0); + emit Claimed(beneficiary, amountAllocated, 0); } function verifyAmount( @@ -84,15 +83,6 @@ contract TalentTGEUnlock is Ownable { ); } - function calculate( - address beneficiary, - uint256 amountAllocated - ) internal view returns (uint256 amountToClaim) { - uint256 amountClaimed = claimed[beneficiary]; - assert(amountClaimed <= amountAllocated); - amountToClaim = amountAllocated - amountClaimed; - } - function setMerkleRoot(bytes32 nextMerkleRoot) external onlyOwner { merkleRoot = nextMerkleRoot; } diff --git a/contracts/talent/TalentTGEUnlockTimestamp.sol b/contracts/talent/TalentTGEUnlockTimestamp.sol new file mode 100644 index 00000000..ed5eab93 --- /dev/null +++ b/contracts/talent/TalentTGEUnlockTimestamp.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +// Based on: https://github.com/gnosis/safe-token-distribution/blob/master/tooling/contracts/MerkleDistribution.sol +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "../merkle/MerkleProof.sol"; + +contract TalentTGEUnlockTimestamp is Ownable { + using SafeERC20 for IERC20; + + event Claimed(address indexed claimer, uint256 amount, uint256 burned); + + address public immutable token; + bytes32 public merkleRoot; + bool public isContractEnabled; + uint256 public unlockTimestamp; + mapping(address => uint256) public claimed; + + constructor( + address _token, + bytes32 _merkleRoot, + address owner, + uint256 _unlockTimestamp + ) Ownable(owner) { + token = _token; + merkleRoot = _merkleRoot; + isContractEnabled = false; + unlockTimestamp = _unlockTimestamp; + } + + function setUnlockTimestamp(uint256 _unlockTimestamp) external onlyOwner { + unlockTimestamp = _unlockTimestamp; + } + + function disableContract() external onlyOwner { + isContractEnabled = false; + } + + function enableContract() external onlyOwner { + isContractEnabled = true; + } + + function claim( + bytes32[] calldata merkleProofClaim, + uint256 amountAllocated + ) external { + require(isContractEnabled, "Contracts are disabled"); + require(block.timestamp >= unlockTimestamp, "Unlock period not started"); + require(claimed[msg.sender] == 0, "Already claimed"); + verifyAmount(merkleProofClaim, amountAllocated); + + address beneficiary = msg.sender; + uint256 amountToClaim = calculate(beneficiary, amountAllocated); + + claimed[beneficiary] += amountToClaim; + IERC20(token).safeTransfer(beneficiary, amountToClaim); + + emit Claimed(beneficiary, amountToClaim, 0); + } + + function verifyAmount( + bytes32[] calldata proof, + uint256 amountAllocated + ) internal view { + bytes32 root = merkleRoot; + bytes32 leaf = keccak256( + bytes.concat(keccak256(abi.encode(msg.sender, amountAllocated))) + ); + + require( + MerkleProof.verify(proof, root, leaf), + "Invalid Allocation Proof" + ); + } + + function calculate( + address beneficiary, + uint256 amountAllocated + ) internal view returns (uint256 amountToClaim) { + uint256 amountClaimed = claimed[beneficiary]; + assert(amountClaimed <= amountAllocated); + amountToClaim = amountAllocated - amountClaimed; + } + + function setMerkleRoot(bytes32 nextMerkleRoot) external onlyOwner { + merkleRoot = nextMerkleRoot; + } + + function withdraw() external onlyOwner { + IERC20(token).transfer(owner(), IERC20(token).balanceOf(address(this))); + } +} diff --git a/contracts/talent/TalentVault.sol b/contracts/talent/TalentVault.sol new file mode 100644 index 00000000..63718dc2 --- /dev/null +++ b/contracts/talent/TalentVault.sol @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "../passport/PassportBuilderScore.sol"; +import "../passport/PassportWalletRegistry.sol"; + +/// @title Talent Protocol Vault Token Contract +/// @author Talent Protocol - Francisco Leal, Panagiotis Matsinopoulos +/// @notice Allows any $TALENT holders to deposit their tokens and earn rewards. +/// @dev This is an ERC4626 compliant contract. +contract TalentVault is ERC4626, Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + /// @notice Emitted when the yield rate is updated + /// @param yieldRate The new yield rate + event YieldRateUpdated(uint256 yieldRate); + + /// @notice Emitted when the yield accrual deadline is updated + /// @param yieldAccrualDeadline The new yield accrual deadline + event YieldAccrualDeadlineUpdated(uint256 yieldAccrualDeadline); + + error CantWithdrawWithinTheLockPeriod(); + error ContractInsolvent(); + error InsufficientAllowance(); + error InsufficientBalance(); + error InvalidAddress(); + error InvalidDepositAmount(); + error NoDepositFound(); + error TalentVaultNonTransferable(); + error TransferFailed(); + error MaxOverallDepositReached(); + /// @notice Represents user's balance meta data + /// @param depositedAmount The amount of tokens that were deposited, excluding rewards + /// @param lastRewardCalculation The timestamp (seconds since Epoch) of the last rewards calculation + struct UserBalanceMeta { + uint256 depositedAmount; + uint256 lastRewardCalculation; + uint256 lastDepositAt; + } + + /// @notice The amount of days that your deposits are locked and can't be withdrawn. + /// Lock period end-day is calculated base on the last datetime user did a deposit. + uint256 public lockPeriod; + + /// @notice The maximum amount of tokens that can be deposited into the vault + uint256 public maxOverallDeposit; + + /// @notice The number of seconds in a day + uint256 internal constant SECONDS_WITHIN_DAY = 86400; + + /// @notice The number of seconds in a year + uint256 internal constant SECONDS_PER_YEAR = 31536000; + + /// @notice The maximum yield rate that can be set, represented as a percentage. + uint256 internal constant ONE_HUNDRED_PERCENT = 100_00; + + /// @notice The number of seconds in a year multiplied by 100% (to make it easier to calculate rewards) + uint256 internal constant SECONDS_PER_YEAR_x_ONE_HUNDRED_PERCENT = SECONDS_PER_YEAR * ONE_HUNDRED_PERCENT; + + /// @notice The token that will be deposited into the contract + IERC20 public immutable token; + + /// @notice The wallet paying for the yield + address public yieldSource; + + /// @notice The yield base rate for the contract, represented as a percentage. + /// @dev Represented with 2 decimal places, e.g. 10_00 for 10% + uint256 public yieldRateBase; + + /// @notice The time at which the users of the contract will stop accruing rewards + uint256 public yieldAccrualDeadline; + + /// @notice Whether the contract is accruing rewards or not + bool public yieldRewardsFlag; + + /// @notice The Passport Builder Score contract + PassportBuilderScore public passportBuilderScore; + + /// @notice The Passport Wallet Registry contract + PassportWalletRegistry public passportWalletRegistry; + + /// @notice A mapping of user addresses to their deposits + mapping(address => UserBalanceMeta) public userBalanceMeta; + + /// @notice Whether the max deposit limit is enabled for an address or not + mapping(address => bool) private maxDepositLimitFlags; + + /// @notice The maximum deposit amount for an address (if there is one) + mapping(address => uint256) private maxDeposits; + + /// @notice Create a new Talent Vault contract + /// @param _token The token that will be deposited into the contract + /// @param _yieldSource The wallet paying for the yield + /// @param _passportBuilderScore The Passport Builder Score contract + constructor( + IERC20 _token, + address _yieldSource, + PassportBuilderScore _passportBuilderScore, + PassportWalletRegistry _passportWalletRegistry + ) ERC4626(_token) ERC20("TalentVault", "sTALENT") Ownable(msg.sender) { + if ( + address(_token) == address(0) || + address(_yieldSource) == address(0) || + address(_passportBuilderScore) == address(0) + ) { + revert InvalidAddress(); + } + + token = _token; + yieldRateBase = 5_00; + yieldSource = _yieldSource; + yieldRewardsFlag = true; + yieldAccrualDeadline = block.timestamp + 90 days; + passportBuilderScore = _passportBuilderScore; + lockPeriod = 30 days; + maxOverallDeposit = 1_000_000 ether; + passportWalletRegistry = _passportWalletRegistry; + } + + // ------------------- EXTERNAL -------------------------------------------- + + /// @notice Set the maximum amount of tokens that can be deposited into the vault + /// @dev Can only be called by the owner + /// @param _maxOverallDeposit The new maximum amount of tokens that can be deposited into the vault + function setMaxOverallDeposit(uint256 _maxOverallDeposit) external onlyOwner { + maxOverallDeposit = _maxOverallDeposit; + } + + /// @notice Set the lock period for the contract + /// @dev Can only be called by the owner + /// @param _lockPeriod The lock period in days + function setLockPeriod(uint256 _lockPeriod) external onlyOwner { + lockPeriod = _lockPeriod * SECONDS_WITHIN_DAY; + } + + /// @notice Set the maximum deposit amount for an address + /// @dev Can only be called by the owner + /// @param receiver The address to set the maximum deposit amount for + /// @param shares The maximum deposit amount + function setMaxMint(address receiver, uint256 shares) external onlyOwner { + setMaxDeposit(receiver, shares); + } + + /// @notice Remove the maximum deposit limit for an address + /// @dev Can only be called by the owner + /// @param receiver The address to remove the maximum deposit limit for + function removeMaxMintLimit(address receiver) external onlyOwner { + removeMaxDepositLimit(receiver); + } + + /// @notice Calculate any accrued rewards for the caller + function refresh() external { + refreshForAddress(msg.sender); + } + + /// @notice Withdraws all of the user's balance, including any accrued rewards. + function withdrawAll() external nonReentrant { + refreshForAddress(msg.sender); + redeem(balanceOf(msg.sender), msg.sender, msg.sender); + } + + /// @notice Update the base yield rate for the contract + /// @dev Can only be called by the owner + /// @param _yieldRate The new yield rate + function setYieldRate(uint256 _yieldRate) external onlyOwner { + require(_yieldRate > yieldRateBase, "Yield rate cannot be decreased"); + + yieldRateBase = _yieldRate; + emit YieldRateUpdated(_yieldRate); + } + + /// @notice Update the time at which the users of the contract will stop accruing rewards + /// @dev Can only be called by the owner + /// @param _yieldAccrualDeadline The new yield accrual deadline + function setYieldAccrualDeadline(uint256 _yieldAccrualDeadline) external onlyOwner { + require(_yieldAccrualDeadline > block.timestamp, "Invalid yield accrual deadline"); + + yieldAccrualDeadline = _yieldAccrualDeadline; + + emit YieldAccrualDeadlineUpdated(_yieldAccrualDeadline); + } + + /// @notice Stop the contract from accruing rewards + /// @dev Can only be called by the owner + function stopYieldingRewards() external onlyOwner { + yieldRewardsFlag = false; + } + + /// @notice Start the contract accruing rewards + /// @dev Can only be called by the owner + function startYieldingRewards() external onlyOwner { + yieldRewardsFlag = true; + } + + /// @notice Set the yield source for the contract + /// @dev Can only be called by the owner + /// @param _yieldSource The new yield source + function setYieldSource(address _yieldSource) external onlyOwner { + yieldSource = _yieldSource; + } + + // ------------------------- PUBLIC ---------------------------------------------------- + + /// @notice Get the maximum deposit amount for an address + /// @param receiver The address to get the maximum deposit amount for + function maxDeposit(address receiver) public view virtual override returns (uint256) { + if (maxDepositLimitFlags[receiver]) { + return maxDeposits[receiver]; + } else { + return type(uint256).max; + } + } + + /// @notice Get the maximum deposit amount for an address + /// @param receiver The address to get the maximum deposit amount for + function maxMint(address receiver) public view virtual override returns (uint256) { + return maxDeposit(receiver); + } + + /// @notice Deposit tokens into the contract + /// @param assets The amount of tokens to deposit + /// @param receiver The address to deposit the tokens for + function deposit(uint256 assets, address receiver) public virtual override returns (uint256) { + if (assets <= 0) { + revert InvalidDepositAmount(); + } + + if (totalAssets() + assets > maxOverallDeposit) { + revert MaxOverallDepositReached(); + } + + refreshForAddress(receiver); + + uint256 shares = super.deposit(assets, receiver); + + UserBalanceMeta storage balanceMeta = userBalanceMeta[receiver]; + + balanceMeta.depositedAmount += assets; + + balanceMeta.lastDepositAt = block.timestamp; + + return shares; + } + + /// @notice Deposit tokens into the contract + /// @param shares The amount of shares to deposit + /// @param receiver The address to deposit the shares for + function mint(uint256 shares, address receiver) public virtual override returns (uint256) { + return deposit(shares, receiver); + } + + /// @notice Calculate any accrued rewards for an address and update + /// the deposit meta data including minting any rewards + /// @param account The address of the user to refresh + function refreshForAddress(address account) public { + if (balanceOf(account) <= 0) { + UserBalanceMeta storage balanceMeta = userBalanceMeta[account]; + balanceMeta.lastRewardCalculation = block.timestamp; + return; + } + + yieldRewards(account); + } + + /// @notice Get the yield rate for the contract for a given user + /// @param user The address of the user to get the yield rate for + function getYieldRateForScore(address user) public view returns (uint256) { + uint256 passportId = passportWalletRegistry.passportId(user); + uint256 builderScore = passportBuilderScore.getScore(passportId); + + if (builderScore < 60) return yieldRateBase; + return yieldRateBase + 5_00; + } + + /// @notice Prevents the owner from renouncing ownership + /// @dev Can only be called by the owner + function renounceOwnership() public view override onlyOwner { + revert("Cannot renounce ownership"); + } + + /// @notice Set the Passport Builder Score contract + /// @dev Can only be called by the owner + function setPassportBuilderScore(PassportBuilderScore _passportBuilderScore) external onlyOwner { + passportBuilderScore = _passportBuilderScore; + } + + /// @notice This reverts because TalentVault is non-transferable + /// @dev reverts with TalentVaultNonTransferable + function transfer(address, uint256) public virtual override(ERC20, IERC20) returns (bool) { + revert TalentVaultNonTransferable(); + } + + /// @notice This reverts because TalentVault is non-transferable + /// @dev reverts with TalentVaultNonTansferable + function transferFrom(address, address, uint256) public virtual override(ERC20, IERC20) returns (bool) { + revert TalentVaultNonTransferable(); + } + + /// @notice Calculate the accrued rewards for an address + /// @param user The address to calculate the accrued rewards for + function calculateRewards(address user) public view returns (uint256) { + UserBalanceMeta storage balanceMeta = userBalanceMeta[user]; + + if (!yieldRewardsFlag) { + return 0; + } + + uint256 userBalance = balanceOf(user); + + uint256 endTime; + + if (yieldAccrualDeadline != 0 && block.timestamp > yieldAccrualDeadline) { + endTime = yieldAccrualDeadline; + } else { + endTime = block.timestamp; + } + + uint256 timeElapsed; + + if (block.timestamp > endTime) { + timeElapsed = endTime > balanceMeta.lastRewardCalculation + ? endTime - balanceMeta.lastRewardCalculation + : 0; + } else { + timeElapsed = block.timestamp - balanceMeta.lastRewardCalculation; + } + + uint256 yieldRate = getYieldRateForScore(user); + + return (userBalance * yieldRate * timeElapsed) / (SECONDS_PER_YEAR_x_ONE_HUNDRED_PERCENT); + } + + // ---------- INTERNAL -------------------------------------- + + /// @notice Set the maximum deposit amount for an address + /// @dev Can only be called by the owner + /// @param receiver The address to set the maximum deposit amount for + /// @param assets The maximum deposit amount + function setMaxDeposit(address receiver, uint256 assets) internal onlyOwner { + maxDeposits[receiver] = assets; + maxDepositLimitFlags[receiver] = true; + } + + /// @notice Remove the maximum deposit limit for an address + /// @dev Can only be called by the owner + /// @param receiver The address to remove the maximum deposit limit for + function removeMaxDepositLimit(address receiver) internal onlyOwner { + delete maxDeposits[receiver]; + delete maxDepositLimitFlags[receiver]; + } + + /// @notice Calculate the accrued rewards for an address and mint any rewards + /// @param user The address to calculate the accrued rewards for + function yieldRewards(address user) internal { + UserBalanceMeta storage balanceMeta = userBalanceMeta[user]; + uint256 rewards = calculateRewards(user); + balanceMeta.lastRewardCalculation = block.timestamp; + + _deposit(yieldSource, user, rewards, rewards); + } + + /// @notice Withdraws tokens from the contract + /// @param caller The address of the caller + /// @param receiver The address of the receiver + /// @param owner The address of the owner + /// @param assets The amount of tokens to withdraw + /// @param shares The amount of shares to withdraw + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + UserBalanceMeta storage receiverUserBalanceMeta = userBalanceMeta[receiver]; + + if (receiverUserBalanceMeta.lastDepositAt + lockPeriod > block.timestamp) { + revert CantWithdrawWithinTheLockPeriod(); + } + + super._withdraw(caller, receiver, owner, assets, shares); + } +} diff --git a/contracts/talent/TalentVaultV2.sol b/contracts/talent/TalentVaultV2.sol new file mode 100644 index 00000000..231e7d98 --- /dev/null +++ b/contracts/talent/TalentVaultV2.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import "./vault-options/IVaultOption.sol"; + +contract TalentVaultV2 is ERC4626, Ownable { + using SafeERC20 for IERC20; + IERC20 public immutable token; + + // Mapping of strategy address => boolean indicating whitelist status + // This is used to prevent users from depositing tokens from untrusted strategies + mapping(address => bool) private _whitelistedStrategies; + + event VaultOptionAdded(address indexed vaultOption); + event VaultOptionRemoved(address indexed vaultOption); + + constructor(ERC20 _asset) + ERC4626(_asset) + ERC20("Staked TALENT", "sTALENT") + Ownable(msg.sender) + { + token = _asset; + } + + /** + * @dev Adds a new yield strategy to the whitelist. + * Can only be called by the contract owner (admin). + */ + function addVaultOption(address vaultOption) external onlyOwner { + require(vaultOption != address(0), "Invalid address"); + require(!_whitelistedStrategies[vaultOption], "Already whitelisted"); + + token.approve(vaultOption, type(uint256).max); + + _whitelistedStrategies[vaultOption] = true; + emit VaultOptionAdded(vaultOption); + } + + /** + * @dev Removes a yield strategy from the whitelist. + * Can only be called by the contract owner (admin). + */ + function removeVaultOption(address vaultOption) external onlyOwner { + require(_whitelistedStrategies[vaultOption], "Not whitelisted"); + + _whitelistedStrategies[vaultOption] = false; + + emit VaultOptionRemoved(vaultOption); + } + + /** + * @dev Returns true if `vaultOption` is whitelisted. + */ + function isWhitelisted(address vaultOption) public view returns (bool) { + return _whitelistedStrategies[vaultOption]; + } + + // Given the 1:1 ratio, we can simply map assets to shares directly. + function convertToShares(uint256 assets) public view virtual override returns (uint256) { + return assets; + } + + // Likewise, shares map back to the same number of assets. + function convertToAssets(uint256 shares) public view virtual override returns (uint256) { + return shares; + } + + // Total assets is simply the balance of the token in this contract. + function totalAssets() public view virtual override returns (uint256) { + return token.balanceOf(address(this)); + } + + function depositToOption( + uint256 assets, + address receiver, + address option + ) external returns (uint256 shares) { + require(_whitelistedStrategies[option], "Option not whitelisted"); + + // Deposit into the Vault which + // 1. Transfer TALENT from user -> vault + // 2. The vault mints sTALENT for the user, 1:1 + shares = deposit(assets, receiver); + // 3. Vault calls the option’s deposit function which transfers the TALENT to the option + IVaultOption(option).depositIntoVaultOption(receiver, assets); + return shares; + } + + function withdrawFromOption( + uint256 shares, + address receiver, + address owner, + address option + ) public returns (uint256 assets) { + // Check that option is whitelisted + require(_whitelistedStrategies[option], "Option not whitelisted"); + + // Option calculates principal + yield and sends it to the vault + uint256 rewardsOwed = IVaultOption(option).withdrawFromVaultOption(owner); + // Withdraw from the Vault which + // 1. Burns the user's shares + // 2. Transfers TALENT from the vault -> user + assets = withdraw(shares, receiver, owner); + + // The vault then sends rewarded TALENT to the user + bool success = token.transfer(receiver, rewardsOwed); + require(success, "reward transfer failed"); + + return assets + rewardsOwed; + } +} diff --git a/contracts/talent/vault-options/BaseAPY.sol b/contracts/talent/vault-options/BaseAPY.sol new file mode 100644 index 00000000..e64917cf --- /dev/null +++ b/contracts/talent/vault-options/BaseAPY.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./IVaultOption.sol"; + +/** + * @dev A super-simplistic “10% APY” strategy. + * Assumes no compounding. Tracks each user’s deposit time & amount. + * On withdrawal, calculates pro-rata interest and sends to the vault. + */ +contract BaseAPY is IVaultOption { + IERC20 public immutable talentToken; + address public immutable vault; // The vault that interacts with this strategy + + uint256 private constant YEAR_IN_SECONDS = 365 days; + uint256 private constant APY = 10_00; // 10% + uint256 private constant APY_PRECISION = 100_00; // 100% + + // Track deposit details for each user + struct DepositInfo { + uint256 amount; // How many TALENT deposited + uint256 depositTime; // When it was deposited + } + mapping(address => DepositInfo) public deposits; + + event DepositOption(address indexed user, uint256 amount); + event WithdrawOption(address indexed user, uint256 amount); + + modifier onlyVault() { + require(msg.sender == vault, "Not authorized"); + _; + } + + // TODO: + // 1. add min/max deposit amounts + // 2. add lock period + // 3. add max number of deposits + constructor(address _talentToken, address _vault) { + require(_talentToken != address(0), "Invalid token"); + require(_vault != address(0), "Invalid vault"); + + talentToken = IERC20(_talentToken); + vault = _vault; + } + + /** + * @notice Vault calls this when user deposits TALENT into the strategy. + */ + function depositIntoVaultOption(address user, uint256 amount) external override onlyVault { + require(amount > 0, "Cannot deposit zero"); + + // Transfer TALENT from vault to this strategy + bool success = talentToken.transferFrom(vault, address(this), amount); + require(success, "Transfer failed"); + + // If user already had some deposit, we add to it and recalc partial interest. + if (deposits[user].amount > 0) { + uint256 timeElapsed = block.timestamp - deposits[user].depositTime; + uint256 interest = (deposits[user].amount * APY * timeElapsed) / (APY_PRECISION * YEAR_IN_SECONDS); + deposits[user].amount += interest; + } + deposits[user].amount += amount; + deposits[user].depositTime = block.timestamp; + + emit DepositOption(user, amount); + } + + /** + * @notice Vault calls this when user withdraws from the strategy. + * @return interest The total TALENT owed to the user (principal + yield). + */ + function withdrawFromVaultOption(address user) external override onlyVault returns (uint256 interest) { + DepositInfo memory info = deposits[user]; + require(info.amount > 0, "No deposit for user"); + + // Calculate how long the user has been in the strategy + uint256 timeElapsed = block.timestamp - info.depositTime; + + // Simple interest = principal * APY * (timeElapsed / YEAR_IN_SECONDS) + // APY = 10% => factor = 0.10 + // but we keep math in integer form: interest = (amount * 10 * timeElapsed) / (100 * YEAR_IN_SECONDS) + interest = (info.amount * APY * timeElapsed) / (APY_PRECISION * YEAR_IN_SECONDS); + + uint256 totalOwed = info.amount + interest; + + // Reset the user deposit + deposits[user].amount = 0; + deposits[user].depositTime = 0; + + // Transfer TALENT to the vault (the vault will handle final distribution to user) + bool success = talentToken.transfer(vault, totalOwed); + require(success, "Transfer failed"); + + emit WithdrawOption(user, interest); + + return interest; + } + + /** + * @notice Preview how much TALENT (principal + yield) user would get if they withdraw now. + */ + function previewRewards(address user) external view override returns (uint256) { + DepositInfo memory info = deposits[user]; + if (info.amount == 0) { + return 0; + } + uint256 timeElapsed = block.timestamp - info.depositTime; + uint256 interest = (info.amount * APY * timeElapsed) / (APY_PRECISION * YEAR_IN_SECONDS); + return info.amount + interest; + } +} \ No newline at end of file diff --git a/contracts/talent/vault-options/IVaultOption.sol b/contracts/talent/vault-options/IVaultOption.sol new file mode 100644 index 00000000..f3e58c1b --- /dev/null +++ b/contracts/talent/vault-options/IVaultOption.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IVaultOption { + /** + * @notice Called by the vault when user deposits TALENT to this strategy. + * @param user The user who is depositing. + * @param amount The amount of TALENT (in wei) being deposited. + */ + function depositIntoVaultOption(address user, uint256 amount) external; + + /** + * @notice Called by the vault when user withdraws from this strategy. + * @dev Vault option should return the total principal + yield to the vault. + * @param user The user who is withdrawing. + * @return totalOwed The total TALENT owed to the user (principal + yield). + */ + function withdrawFromVaultOption(address user) external returns (uint256 totalOwed); + + /** + * @notice Preview how much TALENT (principal + yield) `user` has if they were to withdraw now. + * @param user The user to check. + */ + function previewRewards(address user) external view returns (uint256); +} diff --git a/hardhat.config.ts b/hardhat.config.ts index 2903a4aa..f882782e 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,5 +1,5 @@ import { task } from "hardhat/config"; - +import "hardhat-storage-layout"; import "@typechain/hardhat"; import "@nomiclabs/hardhat-ethers"; import "@nomicfoundation/hardhat-viem"; @@ -30,7 +30,12 @@ const config: HardhatUserConfig = { settings: { optimizer: { enabled: true, - runs: 1000, + runs: 4294967295, + }, + outputSelection: { + "*": { + "*": ["storageLayout"], + }, }, }, }, @@ -50,6 +55,7 @@ const config: HardhatUserConfig = { }, gasReporter: { currency: "ETH", + showMethodSig: true, }, etherscan: { // Your API keys for Etherscan diff --git a/package.json b/package.json index 016ae7a6..4515805b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dotenv": "^16.0.3", "eslint": "^8.19.0", "ethereum-waffle": "3.4.0", + "hardhat-storage-layout": "^0.1.7", "prettier": "^2.4.1", "prettier-plugin-solidity": "^1.0.0-beta.18", "solc": "^0.8.25" diff --git a/scripts/passport/deployPassportWalletRegistry.ts b/scripts/passport/deployPassportWalletRegistry.ts new file mode 100644 index 00000000..794e1ae2 --- /dev/null +++ b/scripts/passport/deployPassportWalletRegistry.ts @@ -0,0 +1,28 @@ +import { ethers, network } from "hardhat"; + +import { deployPassportWalletRegistry } from "../shared"; + +const PASSPORT_REGISTRY_ADDRESS_TESTNET = "0xa600b3356c1440B6D6e57b0B7862dC3dFB66bc43"; +const PASSPORT_REGISTRY_ADDRESS_MAINNET = "0xb477A9BD2547ad61f4Ac22113172Dd909E5B2331"; + +async function main() { + console.log(`Deploying passport registry at ${network.name}`); + + const [admin] = await ethers.getSigners(); + + console.log(`Admin will be ${admin.address}`); + + const passportRegistry = await deployPassportWalletRegistry(admin.address, PASSPORT_REGISTRY_ADDRESS_MAINNET); + + console.log(`Passport Registry Address: ${passportRegistry.address}`); + console.log(`Passport Registry owner: ${await passportRegistry.owner()}`); + + console.log("Done"); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/passport/migrateMainWallets.ts b/scripts/passport/migrateMainWallets.ts new file mode 100644 index 00000000..3502fb63 --- /dev/null +++ b/scripts/passport/migrateMainWallets.ts @@ -0,0 +1,44 @@ +import { ethers, network } from "hardhat"; +import { PassportWalletRegistry } from "../../test/shared/artifacts"; +import MAIN_WALLET_CHANGES from "../data/main-wallet-changes.json"; + +const PASSPORT_WALLET_REGISTRY_MAINNET = "0x9B729d9fC43e3746855F7E02238FB3a2A20bD899"; + +async function main() { + const [admin] = await ethers.getSigners(); + + const passportWalletRegistry = new ethers.Contract( + PASSPORT_WALLET_REGISTRY_MAINNET, + PassportWalletRegistry.abi, + admin + ); + + const data = MAIN_WALLET_CHANGES as { passport_id: number; main_wallet: string }[]; + + console.log("MIGRATING: ", data.length, "WALLETS"); + let i = 0; + + for (const item of data) { + i++; + console.log(`MIGRATING: ${i}/${data.length} - ${item.main_wallet} - ${item.passport_id}`); + const tx = await passportWalletRegistry.adminAddWallet(item.main_wallet, item.passport_id); + console.log(`TX included: https://basescan.org/tx/${tx.hash}`); + + const validationId = await passportWalletRegistry.passportId(item.main_wallet); + if (validationId.toString() !== item.passport_id.toString()) { + console.log("VALUES ARE NOT EQUAL: ", validationId.toString(), item.passport_id.toString()); + process.exit(1); + } else { + console.log("VALUES ARE EQUAL: ", validationId.toString(), item.passport_id.toString()); + } + await tx.wait(); + } + console.log("Done"); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/shared/index.ts b/scripts/shared/index.ts index 2a10e982..6f75a116 100644 --- a/scripts/shared/index.ts +++ b/scripts/shared/index.ts @@ -1,5 +1,4 @@ import { ethers } from "hardhat"; -import { zeroHash } from "viem"; import type { PassportRegistry, TalentProtocolToken, @@ -8,9 +7,10 @@ import type { TalentCommunitySale, TalentTGEUnlock, SmartBuilderScore, + PassportWalletRegistry, + TalentTGEUnlockTimestamp, + TalentVault, } from "../../typechain-types"; -import { BigNumber } from "ethers"; -import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; export async function deployPassport(owner: string): Promise { const passportRegistryContract = await ethers.getContractFactory("PassportRegistry"); @@ -21,6 +21,18 @@ export async function deployPassport(owner: string): Promise { return deployedPassport as PassportRegistry; } +export async function deployPassportWalletRegistry( + owner: string, + passportRegistry: string +): Promise { + const passportWalletRegistryContract = await ethers.getContractFactory("PassportWalletRegistry"); + + const deployedPassportWalletRegistry = await passportWalletRegistryContract.deploy(owner, passportRegistry); + await deployedPassportWalletRegistry.deployed(); + + return deployedPassportWalletRegistry as PassportWalletRegistry; +} + export async function deployTalentToken(owner: string): Promise { const talentTokenContract = await ethers.getContractFactory("TalentProtocolToken"); @@ -33,6 +45,7 @@ export async function deployTalentToken(owner: string): Promise { const talentTGEUnlockContract = await ethers.getContractFactory("TalentTGEUnlock"); - const deployedTGEUnlock = await talentTGEUnlockContract.deploy(token, merkleTreeRoot, passportBuilderScore, minimumClaimBuilderScore, owner); + const deployedTGEUnlock = await talentTGEUnlockContract.deploy( + token, + merkleTreeRoot, + passportBuilderScore, + minimumClaimBuilderScore, + owner + ); await deployedTGEUnlock.deployed(); return deployedTGEUnlock as TalentTGEUnlock; } + +export async function deployTalentTGEUnlockTimestamps( + token: string, + owner: string, + merkleTreeRoot: string, + timestamp: number +): Promise { + const talentTGEUnlockWithTimestampsContract = await ethers.getContractFactory("TalentTGEUnlockTimestamp"); + + const deployedTGEUnlock = await talentTGEUnlockWithTimestampsContract.deploy(token, merkleTreeRoot, owner, timestamp); + await deployedTGEUnlock.deployed(); + return deployedTGEUnlock as TalentTGEUnlockTimestamp; +} + +export async function deployTalentVault( + talentToken: string, + yieldSource: string, + passportBuilderScore: string, + passportWalletRegistry: string +): Promise { + const talentVaultContract = await ethers.getContractFactory("TalentVault"); + + const deployedTalentVault = await talentVaultContract.deploy( + talentToken, + yieldSource, + passportBuilderScore, + passportWalletRegistry + ); + + await deployedTalentVault.deployed(); + + return deployedTalentVault as TalentVault; +} diff --git a/scripts/talent/deployPurchasesUnlocks.ts b/scripts/talent/deployPurchasesUnlocks.ts index 4760ca89..009cbc73 100644 --- a/scripts/talent/deployPurchasesUnlocks.ts +++ b/scripts/talent/deployPurchasesUnlocks.ts @@ -9,10 +9,10 @@ import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; import distributionSetup from "../data/inAppPurchases.json"; import { createClient } from "@supabase/supabase-js"; -const TALENT_TOKEN_ADDRESS_TESTNET = "0xb669707B3784B1284f6B6a398f6b04b1AD78C74E"; -const TALENT_TOKEN_ADDRESS_MAINNET = "0x9a33406165f562E16C3abD82fd1185482E01b49a"; +const TALENT_TOKEN_ADDRESS_TESTNET = "0xb669707B3784B1284f6B6a398f6b04b1AD78C74E"; +const TALENT_TOKEN_ADDRESS_MAINNET = "0x9a33406165f562E16C3abD82fd1185482E01b49a"; -const VESTING_CATEGORY = "ecosystem_incentives_02" +const VESTING_CATEGORY = "ecosystem_incentives_02"; type BalanceMap = { [key: string]: BigNumberish; @@ -31,19 +31,19 @@ async function main() { console.log(`Admin will be ${admin.address}`); - if(!process.env.PUBLIC_SUPABASE_URL) { + if (!process.env.PUBLIC_SUPABASE_URL) { console.error("Missing PUBLIC_SUPABASE_URL"); return 0; } - if(!process.env.PUBLIC_SUPABASE_ANON_KEY) { + if (!process.env.PUBLIC_SUPABASE_ANON_KEY) { console.error("Missing PUBLIC_SUPABASE_ANON_KEY"); return 0; } const allResults = distributionSetup as { amount: string; wallet: string }[]; - console.log("Generate merkle tree") + console.log("Generate merkle tree"); const merkleBase = allResults.reduce((acc, { wallet, amount }) => { acc[wallet.toLowerCase()] = ethers.utils.parseEther(amount).toBigInt(); @@ -52,8 +52,12 @@ async function main() { const merkleTree = generateMerkleTree(merkleBase); - console.log(`Contract init args: ${TALENT_TOKEN_ADDRESS_TESTNET} ${merkleTree.root} ${admin.address}`) - const tgeUnlockDistribution = await deployTalentTGEUnlock(TALENT_TOKEN_ADDRESS_TESTNET, admin.address, merkleTree.root); + console.log(`Contract init args: ${TALENT_TOKEN_ADDRESS_TESTNET} ${merkleTree.root} ${admin.address}`); + const tgeUnlockDistribution = await deployTalentTGEUnlock( + TALENT_TOKEN_ADDRESS_TESTNET, + admin.address, + merkleTree.root + ); console.log(`TGE Unlock distribution deployed at ${tgeUnlockDistribution.address}`); const proofList = allResults.map(({ wallet, amount }) => { @@ -74,26 +78,25 @@ async function main() { console.log("Uploading proofs to database"); - const supabase = createClient(process.env.PUBLIC_SUPABASE_URL, process.env.PUBLIC_SUPABASE_ANON_KEY) + const supabase = createClient(process.env.PUBLIC_SUPABASE_URL, process.env.PUBLIC_SUPABASE_ANON_KEY); - const proofsCount = proofList.length + const proofsCount = proofList.length; for (let i = 0; i < proofsCount; i++) { - const element = proofList[i] + const element = proofList[i]; - console.log(`Uploading ${i + 1}/${proofsCount}: ${element.wallet}`) + console.log(`Uploading ${i + 1}/${proofsCount}: ${element.wallet}`); const { error } = await supabase .from("distributions") .update({ proof: element.proof }) .eq("wallet", element.wallet) - .eq("vesting_category", VESTING_CATEGORY) + .eq("vesting_category", VESTING_CATEGORY); - if(error) { + if (error) { console.error(error); } } - console.log("Done"); } diff --git a/scripts/talent/deployTalentVault.ts b/scripts/talent/deployTalentVault.ts new file mode 100644 index 00000000..6072180c --- /dev/null +++ b/scripts/talent/deployTalentVault.ts @@ -0,0 +1,44 @@ +import { ethers, network } from "hardhat"; +import { deployTalentVault } from "../shared"; + +const TALENT_TOKEN_MAINNET = "0x9a33406165f562E16C3abD82fd1185482E01b49a"; +const TALENT_TOKEN_TESTNET = "0x7c2a63e1713578d4d704b462C2dee311A59aE304"; + +const YIELD_SOURCE_MAINNET = "0x34118871f5943D6B153381C0133115c3B5b78b12"; +const YIELD_SOURCE_TESTNET = "0x33041027dd8F4dC82B6e825FB37ADf8f15d44053"; + +const PASSPORT_BUILDER_SCORE_MAINNET = "0xBBFeDA7c4d8d9Df752542b03CdD715F790B32D0B"; +const PASSPORT_BUILDER_SCORE_TESTNET = "0x5f3aA689C4DCBAe505E6F6c8548DbD9b908bA71d"; + +const WALLET_REGISTRY_MAINNET = "0x9B729d9fC43e3746855F7E02238FB3a2A20bD899"; + +async function main() { + console.log(`Deploying Talent Vault at ${network.name}`); + + const [admin] = await ethers.getSigners(); + + console.log(`Admin will be ${admin.address}`); + + const talentVault = await deployTalentVault( + TALENT_TOKEN_MAINNET, + YIELD_SOURCE_MAINNET, + PASSPORT_BUILDER_SCORE_MAINNET, + WALLET_REGISTRY_MAINNET + ); + + console.log(`Talent Vault deployed at ${talentVault.address}`); + console.log( + `Params for verification: Contract ${talentVault.address} Owner ${admin.address} Talent Token ${TALENT_TOKEN_MAINNET} Yield Source ${YIELD_SOURCE_MAINNET} Wallet Registry ${WALLET_REGISTRY_MAINNET}` + ); + + console.log("Approve the vault to spend the talent tokens: ", ethers.utils.parseEther("100000")); + + console.log("Done"); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/test/contracts/passport/PassportWalletRegistry.ts b/test/contracts/passport/PassportWalletRegistry.ts index 542fd97e..777459ea 100644 --- a/test/contracts/passport/PassportWalletRegistry.ts +++ b/test/contracts/passport/PassportWalletRegistry.ts @@ -73,8 +73,6 @@ describe("PassportWalletRegistry", () => { // Access the event logs for the "ScoreUpdated" event const event = receipt.events.find((e) => e.event === "WalletAdded"); - console.log(event); - if (!event || !event.args || event.args.length < 2) { throw new Error("WalletAdded event not found in the receipt"); } @@ -160,7 +158,6 @@ describe("PassportWalletRegistry", () => { const passportId = await passportRegistry.passportId(user1.address); - console.log(passportId); await passportWalletRegistry.connect(user1).addWallet(user2.address, passportId); const newWalletPassportId = await passportWalletRegistry.passportId(user2.address); diff --git a/test/contracts/talent/TalentRewardClaim.ts b/test/contracts/talent/TalentRewardClaim.ts index 9a8f3930..4f30e019 100644 --- a/test/contracts/talent/TalentRewardClaim.ts +++ b/test/contracts/talent/TalentRewardClaim.ts @@ -8,6 +8,7 @@ import { TalentRewardClaim, PassportRegistry, PassportBuilderScore, + PassportWalletRegistry, } from "../../../typechain-types"; import { Artifacts } from "../../shared"; import generateMerkleTree from "../../../functions/generateMerkleTree"; @@ -30,6 +31,7 @@ describe("TalentRewardClaim", () => { let talentToken: TalentProtocolToken; let passportRegistry: PassportRegistry; let passportBuilderScore: PassportBuilderScore; + let passportWalletRegistry: PassportWalletRegistry; let talentRewardClaim: TalentRewardClaim; let merkleTree: StandardMerkleTree<(string | BigNumber)[]>; let currentTimestamp: number = Math.floor(Date.now() / 1000); @@ -43,6 +45,10 @@ describe("TalentRewardClaim", () => { passportRegistry.address, admin.address, ])) as PassportBuilderScore; + passportWalletRegistry = (await deployContract(admin, Artifacts.PassportWalletRegistry, [ + admin.address, + passportRegistry.address, + ])) as PassportWalletRegistry; merkleTree = generateMerkleTree({ [user1.address]: ethers.utils.parseUnits("10000", 18), @@ -52,6 +58,7 @@ describe("TalentRewardClaim", () => { talentRewardClaim = (await deployContract(admin, Artifacts.TalentRewardClaim, [ talentToken.address, passportBuilderScore.address, + passportWalletRegistry.address, admin.address, admin.address, merkleTree.root, @@ -99,11 +106,16 @@ describe("TalentRewardClaim", () => { describe("Claiming Tokens", () => { beforeEach(async () => { - const amounts = [ethers.utils.parseUnits("10000", 18), ethers.utils.parseUnits("20000", 18)]; + const amounts = [ + ethers.utils.parseUnits("10000", 18), + ethers.utils.parseUnits("20000", 18), + ethers.utils.parseUnits("30000", 18), + ]; merkleTree = generateMerkleTree({ [user1.address]: amounts[0], [user2.address]: amounts[1], + [user3.address]: amounts[2], }); await talentRewardClaim.setMerkleRoot(merkleTree.root); @@ -179,6 +191,19 @@ describe("TalentRewardClaim", () => { expect(await talentToken.balanceOf(user1.address)).to.equal(ethers.utils.parseUnits("10000", 18)); // 5x the weekly amount }); + it("Should allow users that changed their main wallet with a builder score above 40 to claim 5x tokens", async () => { + await passportRegistry.setGenerationMode(true, 1); // Enable sequential mode + await passportRegistry.connect(user1).create("source1"); + + const passportId = await passportRegistry.passportId(user1.address); + await passportWalletRegistry.connect(user1).addWallet(user3.address, passportId); + await passportBuilderScore.setScore(passportId, 50); // Set builder score above 40 + + const proof1 = merkleTree.getProof([user3.address, ethers.utils.parseUnits("30000", 18)]); + await talentRewardClaim.connect(user3).claimTokens(proof1, ethers.utils.parseUnits("30000", 18)); + expect(await talentToken.balanceOf(user3.address)).to.equal(ethers.utils.parseUnits("10000", 18)); // 5x the weekly amount + }); + it("Should burn tokens if a user misses a claim", async () => { const initialBalance = await talentToken.balanceOf(admin.address); diff --git a/test/contracts/talent/TalentTGEUnlockTimestamp.ts b/test/contracts/talent/TalentTGEUnlockTimestamp.ts new file mode 100644 index 00000000..cceaaad7 --- /dev/null +++ b/test/contracts/talent/TalentTGEUnlockTimestamp.ts @@ -0,0 +1,160 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { TalentProtocolToken, TalentTGEUnlockTimestamp } from "../../../typechain-types"; +import { Artifacts } from "../../shared"; +import generateMerkleTree from "../../../functions/generateMerkleTree"; +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; +import { BigNumber } from "ethers"; + +chai.use(solidity); + +const { expect } = chai; +const { deployContract } = waffle; + +describe("TalentTGEUnlockTimestamp", () => { + let admin: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + let talentToken: TalentProtocolToken; + let TalentTGEUnlockTimestamp: TalentTGEUnlockTimestamp; + let merkleTree: StandardMerkleTree<(string | BigNumber)[]>; + let totalTalentAmount: BigNumber; + const unlockTimestamp = Math.floor(Date.now() / 1000) - 1000000; + + beforeEach(async () => { + [admin, user1, user2, user3] = await ethers.getSigners(); + + talentToken = (await deployContract(admin, Artifacts.TalentProtocolToken, [admin.address])) as TalentProtocolToken; + merkleTree = generateMerkleTree({ + [user1.address]: ethers.utils.parseUnits("10000", 18), + [user2.address]: ethers.utils.parseUnits("20000", 18), + }); + + TalentTGEUnlockTimestamp = (await deployContract(admin, Artifacts.TalentTGEUnlockTimestamp, [ + talentToken.address, + merkleTree.root, + admin.address, + unlockTimestamp, + ])) as TalentTGEUnlockTimestamp; + + // Approve TalentRewardClaim contract to spend tokens on behalf of the admin + totalTalentAmount = ethers.utils.parseUnits("600000000", 18); + await talentToken.connect(admin).transfer(TalentTGEUnlockTimestamp.address, totalTalentAmount); + await talentToken.unpause(); + }); + + describe("Deployment", () => { + it("Should set the right owner", async () => { + expect(await TalentTGEUnlockTimestamp.owner()).to.equal(admin.address); + }); + }); + + describe("Setup", () => { + it("Should not allow claims before contract is enabled", async () => { + const amount = ethers.utils.parseUnits("10000", 18); + const proof = merkleTree.getProof([user1.address, amount]); + + await expect(TalentTGEUnlockTimestamp.connect(user1).claim(proof, amount)).to.be.revertedWith( + "Contracts are disable" + ); + }); + + it("Should allow claims after contract is enabled", async () => { + const amount = ethers.utils.parseUnits("10000", 18); + const proof = merkleTree.getProof([user1.address, amount]); + + const talentAmount = await talentToken.balanceOf(user1.address); + expect(talentAmount).to.equal(0); + await TalentTGEUnlockTimestamp.connect(admin).enableContract(); + await TalentTGEUnlockTimestamp.connect(user1).claim(proof, amount); + expect(await talentToken.balanceOf(user1.address)).to.equal(amount); + }); + }); + + describe("Claiming Tokens", () => { + beforeEach(async () => { + const amounts = [ethers.utils.parseUnits("10000", 18), ethers.utils.parseUnits("20000", 18)]; + + merkleTree = generateMerkleTree({ + [user1.address]: amounts[0], + [user2.address]: amounts[1], + }); + + await TalentTGEUnlockTimestamp.setMerkleRoot(merkleTree.root); + await TalentTGEUnlockTimestamp.connect(admin).enableContract(); + }); + + it("Should allow users to claim tokens", async () => { + const proof1 = merkleTree.getProof([user1.address, ethers.utils.parseUnits("10000", 18)]); + + await TalentTGEUnlockTimestamp.connect(user1).claim(proof1, ethers.utils.parseUnits("10000", 18)); + expect(await talentToken.balanceOf(user1.address)).to.equal(ethers.utils.parseUnits("10000", 18)); + }); + + it("Should not allow claiming more than the amount", async () => { + const proof1 = merkleTree.getProof([user1.address, ethers.utils.parseUnits("10000", 18)]); + + await expect( + TalentTGEUnlockTimestamp.connect(user1).claim(proof1, ethers.utils.parseUnits("100000", 18)) + ).to.be.revertedWith("Invalid Allocation Proof"); + }); + + it("Should not allow the wrong user to claim", async () => { + const proof1 = merkleTree.getProof([user1.address, ethers.utils.parseUnits("10000", 18)]); + + await expect( + TalentTGEUnlockTimestamp.connect(user2).claim(proof1, ethers.utils.parseUnits("10000", 18)) + ).to.be.revertedWith("Invalid Allocation Proof"); + }); + }); + + describe("disable and withdraw from contract", () => { + it("Should not allow claims after contract is disabled", async () => { + const amount = ethers.utils.parseUnits("10000", 18); + const proof = merkleTree.getProof([user1.address, amount]); + + await TalentTGEUnlockTimestamp.connect(admin).disableContract(); + await expect(TalentTGEUnlockTimestamp.connect(user1).claim(proof, amount)).to.be.revertedWith( + "Contracts are disable" + ); + }); + + it("Should allow owner to set unlock timestamp", async () => { + await TalentTGEUnlockTimestamp.connect(admin).setUnlockTimestamp(unlockTimestamp); + expect(await TalentTGEUnlockTimestamp.unlockTimestamp()).to.equal(unlockTimestamp); + }); + + it("Should not allow non-owner to set unlock timestamp", async () => { + await expect(TalentTGEUnlockTimestamp.connect(user1).setUnlockTimestamp(unlockTimestamp)).to.be.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + + it("Should not allow claiming before unlock timestamp", async () => { + const amount = ethers.utils.parseUnits("10000", 18); + const proof = merkleTree.getProof([user1.address, amount]); + await TalentTGEUnlockTimestamp.connect(admin).setUnlockTimestamp(unlockTimestamp + 100000000); + await TalentTGEUnlockTimestamp.connect(admin).enableContract(); + await expect(TalentTGEUnlockTimestamp.connect(user1).claim(proof, amount)).to.be.revertedWith( + "Unlock period not started" + ); + }); + + it("Should allow owner to withdraw funds", async () => { + await TalentTGEUnlockTimestamp.connect(admin).disableContract(); + await TalentTGEUnlockTimestamp.connect(admin).withdraw(); + expect(await talentToken.balanceOf(admin.address)).to.equal(totalTalentAmount); + }); + + it("Should not allow non-owner to withdraw funds", async () => { + await expect(TalentTGEUnlockTimestamp.connect(user1).withdraw()).to.be.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); +}); diff --git a/test/contracts/talent/TalentVault.ts b/test/contracts/talent/TalentVault.ts new file mode 100644 index 00000000..2a04c30d --- /dev/null +++ b/test/contracts/talent/TalentVault.ts @@ -0,0 +1,945 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { + TalentProtocolToken, + TalentVault, + PassportRegistry, + PassportBuilderScore, + PassportWalletRegistry, +} from "../../../typechain-types"; +import { Artifacts } from "../../shared"; +import { ensureTimestamp } from "../../shared/utils"; + +chai.use(solidity); + +const { expect } = chai; +const { deployContract } = waffle; + +async function ensureTimeIsAfterLockPeriod() { + const lockPeriod = 31; + const oneDayAfterLockPeriod = Math.floor(Date.now() / 1000) + lockPeriod * 24 * 60 * 60; + await ensureTimestamp(oneDayAfterLockPeriod); +} + +describe("TalentVault", () => { + let admin: SignerWithAddress; + let yieldSource: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + let talentToken: TalentProtocolToken; + let passportRegistry: PassportRegistry; + let passportWalletRegistry: PassportWalletRegistry; + let passportBuilderScore: PassportBuilderScore; + let talentVault: TalentVault; + + let snapshotId: bigint; + let currentDateEpochSeconds: number; + const yieldBasePerDay = ethers.utils.parseEther("0.137"); + + before(async () => { + await ethers.provider.send("hardhat_reset", []); + + [admin, yieldSource, user1, user2, user3] = await ethers.getSigners(); + + talentToken = (await deployContract(admin, Artifacts.TalentProtocolToken, [admin.address])) as TalentProtocolToken; + passportRegistry = (await deployContract(admin, Artifacts.PassportRegistry, [admin.address])) as PassportRegistry; + passportWalletRegistry = (await deployContract(admin, Artifacts.PassportWalletRegistry, [ + admin.address, + passportRegistry.address, + ])) as PassportWalletRegistry; + passportBuilderScore = (await deployContract(admin, Artifacts.PassportBuilderScore, [ + passportRegistry.address, + admin.address, + ])) as PassportBuilderScore; + + // const adminInitialDeposit = ethers.utils.parseEther("200000"); + talentVault = (await deployContract(admin, Artifacts.TalentVault, [ + talentToken.address, + yieldSource.address, + passportBuilderScore.address, + passportWalletRegistry.address, + ])) as TalentVault; + + console.log("------------------------------------"); + console.log("Addresses:"); + console.log(`admin = ${admin.address}`); + console.log(`user1 = ${user1.address}`); + console.log(`user2 = ${user2.address}`); + console.log(`user3 = ${user3.address}`); + console.log(`talentToken = ${talentToken.address}`); + console.log(`talentVault = ${talentVault.address}`); + console.log(`yieldSource = ${yieldSource.address}`); + console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"); + + // Approve TalentVault contract to spend tokens on behalf of the admin + const totalAllowance = ethers.utils.parseUnits("600000000", 18); + await talentToken.approve(talentVault.address, totalAllowance); + await talentToken.unpause(); + + // just make sure that TV wallet has $TALENT as initial assets from admin initial deposit + await talentToken.approve(talentVault.address, ethers.constants.MaxUint256); + // await talentVault.mint(adminInitialDeposit, admin.address); + + // fund the yieldSource with lots of TALENT Balance + await talentToken.transfer(yieldSource.address, ethers.utils.parseEther("100000")); + await talentToken.connect(yieldSource).approve(talentVault.address, ethers.utils.parseEther("100000")); + }); + + beforeEach(async () => { + snapshotId = await ethers.provider.send("evm_snapshot", []); + currentDateEpochSeconds = Math.floor(Date.now() / 1000); + }); + + afterEach(async () => { + await ethers.provider.send("evm_revert", [snapshotId]); + }); + + describe("Deployment", async () => { + it("Should set the right owner", async () => { + expect(await talentVault.owner()).not.to.equal(ethers.constants.AddressZero); + expect(await talentVault.owner()).to.equal(admin.address); + }); + + it("Should set the correct initial values", async () => { + expect(await talentVault.yieldRateBase()).to.equal(5_00); + + expect(await talentVault.passportBuilderScore()).not.to.equal(ethers.constants.AddressZero); + expect(await talentVault.passportBuilderScore()).to.equal(passportBuilderScore.address); + + expect(await talentVault.yieldRewardsFlag()).to.equal(true); + + expect(await talentVault.lockPeriod()).to.equal(30 * 24 * 60 * 60); + }); + + it("reverts with InvalidAddress when _token given is 0", async () => { + await expect( + deployContract(admin, Artifacts.TalentVault, [ + ethers.constants.AddressZero, + admin.address, + ethers.utils.parseEther("500000"), + passportBuilderScore.address, + ]) + ).to.be.reverted; + }); + + it("reverts with InvalidAddress when _yieldSource given is 0", async () => { + await expect( + deployContract(admin, Artifacts.TalentVault, [ + talentToken.address, + ethers.constants.AddressZero, + ethers.utils.parseEther("500000"), + passportBuilderScore.address, + ]) + ).to.be.reverted; + }); + + it("reverts with InvalidAddress when _passportBuilderScore given is 0", async () => { + await expect( + deployContract(admin, Artifacts.TalentVault, [ + talentToken.address, + admin.address, + ethers.utils.parseEther("500000"), + ethers.constants.AddressZero, + ]) + ).to.be.reverted; + }); + }); + + describe("#name", async () => { + it("is 'TalentVault' reflects the underlying token name, i.e. of 'TalentProtocolToken'", async () => { + const name = await talentVault.name(); + + expect(name).to.equal("TalentVault"); + }); + }); + + describe("#symbol", async () => { + it("is 'sTALENT' reflects the underlying token symbol, i.e. of 'TALENT'", async () => { + const symbol = await talentVault.symbol(); + + expect(symbol).to.equal("sTALENT"); + }); + }); + + describe("#asset", async () => { + it("returns the address of the $TALENT contract", async () => { + const returnedAddress = await talentVault.asset(); + + expect(returnedAddress).not.to.equal(ethers.constants.AddressZero); + expect(returnedAddress).to.equal(talentToken.address); + }); + }); + + describe("#totalAssets", async () => { + it("returns the number of $TALENT that TalentVault Contract has as balance", async () => { + await talentToken.approve(talentVault.address, 10n); + await talentVault.deposit(10n, user1.address); + + const returnedValue = await talentVault.totalAssets(); + const balanceOfTalentVaultInTalent = await talentToken.balanceOf(talentVault.address); + + expect(returnedValue).to.equal(balanceOfTalentVaultInTalent); + }); + }); + + describe("Transferability", async () => { + describe("#transfer", async () => { + it("reverts because TalentVault is not transferable", async () => { + await expect(talentVault.transfer(user1.address, 10n)).to.be.revertedWith("TalentVaultNonTransferable"); + }); + }); + + describe("#transferFrom", async () => { + it("reverts because TalentVault is not transferable", async () => { + await talentVault.approve(admin.address, 10n); + // fire + await expect(talentVault.transferFrom(admin.address, user2.address, 10n)).to.be.revertedWith( + "TalentVaultNonTransferable" + ); + }); + }); + }); + + describe("#maxDeposit", async () => { + context("when recipient does not have a deposit limit", async () => { + it("returns the maximum uint256", async () => { + const maxDeposit = await talentVault.maxDeposit(user1.address); + + expect(maxDeposit).to.equal(ethers.constants.MaxUint256); + }); + }); + }); + + describe("#convertToShares", async () => { + it("Should convert $TALENT to $TALENTVAULT with 1-to-1 ratio", async () => { + const amountOfTalent = 10_000n; + const amountOfTalentVault = await talentVault.convertToShares(amountOfTalent); + expect(amountOfTalentVault).to.equal(amountOfTalent); + }); + }); + + describe("#convertToAssets", async () => { + it("Should convert $TALENTVAULT to $TALENT with 1-to-1 ratio", async () => { + const amountOfTalentVault = 10_000n; + const amountOfTalent = await talentVault.convertToAssets(amountOfTalentVault); + expect(amountOfTalent).to.equal(amountOfTalentVault); + }); + }); + + describe("#previewDeposit", async () => { + it("Should return $TALENTVAULT equal to the number of $TALENT given", async () => { + const amountOfTalent = 10_000n; + const amountOfTalentVault = await talentVault.previewDeposit(amountOfTalent); + expect(amountOfTalentVault).to.equal(amountOfTalent); + }); + }); + + describe("#deposit", async () => { + it("Should mint $TALENTVAULT to the given receiver, equally increase the TalentVault $TALENT balance and equally decreases the $TALENT balance of receiver", async () => { + const depositAmount = 100_000n; + await talentToken.transfer(user1.address, depositAmount); // so that sender has enough balance + const user1BalanceBefore = await talentToken.balanceOf(user1.address); + + await talentToken.connect(user1).approve(talentVault.address, depositAmount); // so that sender has approved vault + + const vaultBalanceBefore = await talentToken.balanceOf(talentVault.address); + + const user2BalanceMetaBefore = await talentVault.userBalanceMeta(user2.address); + + const user2TalentVaultBalanceBefore = await talentVault.balanceOf(user2.address); + + // fire + await expect(talentVault.connect(user1).deposit(depositAmount, user2.address)) + .to.emit(talentVault, "Deposit") + .withArgs(user1.address, user2.address, depositAmount, depositAmount); + + // user1 $TALENT balance is decreased + const user1BalanceAfter = await talentToken.balanceOf(user1.address); + const expectedUser1BalanceAfter = user1BalanceBefore.sub(depositAmount); + expect(user1BalanceAfter).to.equal(expectedUser1BalanceAfter); + + // vault $TALENT balance is increased + const vaultBalanceAfter = await talentToken.balanceOf(talentVault.address); + const expectedVaultBalanceAfter = vaultBalanceBefore.toBigInt() + depositAmount; + expect(vaultBalanceAfter).to.equal(expectedVaultBalanceAfter); + + // deposit for user2 is updated on storage + const user2BalanceMetaAfter = await talentVault.userBalanceMeta(user2.address); + expect(user2BalanceMetaAfter.depositedAmount).to.equal( + user2BalanceMetaBefore.depositedAmount.toBigInt() + depositAmount + ); + expect(user2BalanceMetaAfter.lastDepositAt.toNumber()).to.be.closeTo(currentDateEpochSeconds, 20); + + // user2 $TALENTVAULT balance is increased + const user2TalentVaultBalanceAfter = await talentVault.balanceOf(user2.address); + expect(user2TalentVaultBalanceAfter).to.equal(user2TalentVaultBalanceBefore.toBigInt() + depositAmount); + }); + + it("Should revert if $TALENT deposited is 0", async () => { + await expect(talentVault.connect(user1).deposit(0n, user1.address)).to.be.revertedWith("InvalidDepositAmount"); + }); + + it("Should revert if $TALENT deposited is greater than the max overall deposit", async () => { + await talentVault.setMaxOverallDeposit(ethers.utils.parseEther("100000")); + await expect( + talentVault.connect(user1).deposit(ethers.utils.parseEther("100001"), user1.address) + ).to.be.revertedWith("MaxOverallDepositReached"); + }); + + it("Should allow deposit of amount equal to the max overall deposit", async () => { + const maxOverallDeposit = ethers.utils.parseEther("100000"); + const totalAssetsBefore = await talentVault.totalAssets(); + + await talentToken.transfer(user1.address, maxOverallDeposit.sub(totalAssetsBefore)); + await talentToken.connect(user1).approve(talentVault.address, maxOverallDeposit.sub(totalAssetsBefore)); + await talentVault.setMaxOverallDeposit(maxOverallDeposit.sub(totalAssetsBefore)); + await talentVault.connect(user1).deposit(maxOverallDeposit.sub(totalAssetsBefore), user1.address); + + expect(await talentVault.totalAssets()).to.equal(maxOverallDeposit); + }); + + it("Should allow deposit of amount greater than the max overall deposit if its increased", async () => { + const maxOverallDeposit = ethers.utils.parseEther("100000"); + const totalAssetsBefore = await talentVault.totalAssets(); + + await talentToken.transfer(user1.address, maxOverallDeposit.sub(totalAssetsBefore)); + await talentToken.connect(user1).approve(talentVault.address, maxOverallDeposit.sub(totalAssetsBefore)); + await talentVault.setMaxOverallDeposit(maxOverallDeposit.sub(totalAssetsBefore)); + await talentVault.connect(user1).deposit(maxOverallDeposit.sub(totalAssetsBefore), user1.address); + + expect(await talentVault.totalAssets()).to.equal(maxOverallDeposit); + + const nextDepositAmount = ethers.utils.parseEther("1"); + await talentVault.setMaxOverallDeposit(maxOverallDeposit.add(nextDepositAmount)); + await talentToken.transfer(user1.address, nextDepositAmount); + await talentToken.connect(user1).approve(talentVault.address, nextDepositAmount); + await talentVault.connect(user1).deposit(nextDepositAmount, user1.address); + + expect(await talentVault.totalAssets()).to.be.closeTo( + maxOverallDeposit.add(nextDepositAmount), + ethers.utils.parseEther("0.01") + ); + }); + + it("Should not allow deposit of amount that the sender does not have", async () => { + const balanceOfUser1 = 100_000n; + + await talentToken.transfer(user1.address, balanceOfUser1); + + const depositAmount = 100_001n; + + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + + await expect(talentVault.connect(user1).deposit(depositAmount, user2.address)).to.be.revertedWith( + `ERC20InsufficientBalance("${user1.address}", ${balanceOfUser1}, ${depositAmount})` + ); + }); + + it("Should not allow deposit of amount bigger than the allowed by the sender to be spent by the talent contract", async () => { + const depositAmount = 100_000n; + + await talentToken.transfer(user1.address, depositAmount); // so that user1 has enough balance + + const approvedAmount = depositAmount - 1n; + + await talentToken.connect(user1).approve(talentVault.address, approvedAmount); + + // fire + + await expect(talentVault.connect(user1).deposit(depositAmount, user2.address)).to.be.revertedWith( + `ERC20InsufficientAllowance("${talentVault.address}", ${approvedAmount}, ${depositAmount})` + ); + }); + + it("Should allow deposit of amount equal to the allowed by the sender to be spent by the talent contract", async () => { + const depositAmount = ethers.utils.parseEther("1000"); + + await talentToken.transfer(user1.address, depositAmount); + + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + + // fire + + await expect(talentVault.connect(user1).deposit(depositAmount, user2.address)).not.to.be.reverted; + }); + }); + + describe("#setMaxMint", async () => { + context("when called by the owner", async () => { + it("sets the maximum mint for the receiver", async () => { + await talentVault.setMaxMint(user1.address, 10n); + + const mint = await talentVault.maxMint(user1.address); + + expect(mint).to.equal(10n); + }); + }); + + context("when called by a non-owner", async () => { + it("reverts", async () => { + await expect(talentVault.connect(user1).setMaxMint(user2.address, 10n)).to.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); + }); + + describe("#removeMaxMintLimit", async () => { + context("when called by the owner", async () => { + it("removes the maximum mint for the receiver", async () => { + await talentVault.removeMaxMintLimit(user1.address); + + const mint = await talentVault.maxMint(user1.address); + + expect(mint).to.equal(ethers.constants.MaxUint256); + }); + }); + + context("when called by a non-owner", async () => { + it("reverts", async () => { + await expect(talentVault.connect(user1).removeMaxMintLimit(user2.address)).to.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); + }); + + describe("#setYieldSource", async () => { + context("when called by the owner", async () => { + it("sets the yield source", async () => { + await talentVault.setYieldSource(user1.address); + + const yieldSource = await talentVault.yieldSource(); + + expect(yieldSource).to.equal(user1.address); + }); + }); + + context("when called by a non-owner", async () => { + it("reverts", async () => { + await expect(talentVault.connect(user1).setYieldSource(user2.address)).to.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); + }); + + describe("#maxMint", async () => { + context("when recipient does not have a mint limit", async () => { + it("returns the maximum uint256", async () => { + const maxMint = await talentVault.maxMint(user1.address); + + expect(maxMint).to.equal(ethers.constants.MaxUint256); + }); + }); + + context("when recipient has a mint limit", async () => { + it("returns it", async () => { + await talentVault.setMaxMint(user1.address, 5n); + + const maxMint = await talentVault.maxMint(user1.address); + + expect(maxMint).to.equal(5n); + }); + }); + }); + + describe("#previewMint", async () => { + it("Should return $TALENT equal to the number of $TALENTVAULT given", async () => { + const amountOfTalentVault = 10_000n; + const amountOfTalent = await talentVault.previewMint(amountOfTalentVault); + expect(amountOfTalent).to.equal(amountOfTalentVault); + }); + }); + + describe("#mint", async () => { + it("Should mint $TALENTVAULT to the given receiver, equally increase the TalentVault $TALENT balance and equally decrease the $TALENT balance of receiver", async () => { + const depositAmountInTalentVault = 10_000n; + const equivalentDepositAmountInTalent = depositAmountInTalentVault; + + await talentToken.connect(user1).approve(talentVault.address, depositAmountInTalentVault); + await talentToken.transfer(user1.address, depositAmountInTalentVault); // so that it has enough balance + const user1BalanceBefore = await talentToken.balanceOf(user1.address); + const user1BalanceInTalentVaultBefore = await talentVault.balanceOf(user1.address); + const vaultBalanceBefore = await talentToken.balanceOf(talentVault.address); + const userBalanceMetaBefore = await talentVault.userBalanceMeta(user1.address); + const depositedAmountBefore = userBalanceMetaBefore.depositedAmount; + + // fire (admin deposits to itself) + await expect(talentVault.connect(user1).mint(depositAmountInTalentVault, user1.address)) + .to.emit(talentVault, "Deposit") + .withArgs(user1.address, user1.address, equivalentDepositAmountInTalent, depositAmountInTalentVault); + + // vault balance in TALENT is increased + const vaultBalanceAfter = await talentToken.balanceOf(talentVault.address); + const expectedVaultBalanceAfter = vaultBalanceBefore.toBigInt() + depositAmountInTalentVault; + expect(vaultBalanceAfter).to.equal(expectedVaultBalanceAfter); + + // user1 balance in TALENT decreases + const user1BalanceAfter = await talentToken.balanceOf(user1.address); + expect(user1BalanceAfter).to.equal(user1BalanceBefore.toBigInt() - depositAmountInTalentVault); + + // user1 balance in TalentVault increases (mint result) + const user1BalanceInTalentVaultAfter = await talentVault.balanceOf(user1.address); + expect(user1BalanceInTalentVaultAfter).to.equal( + user1BalanceInTalentVaultBefore.toBigInt() + equivalentDepositAmountInTalent + ); + + // user1 depositedAmount is increased + const userBalanceMeta = await talentVault.userBalanceMeta(user1.address); + const depositedAmountAfter = userBalanceMeta.depositedAmount; + expect(depositedAmountAfter).to.equal(depositedAmountBefore.toBigInt() + equivalentDepositAmountInTalent); + expect(userBalanceMeta.lastDepositAt.toNumber()).to.be.closeTo(currentDateEpochSeconds, 20); + }); + + it("Should revert if $TALENT deposited is 0", async () => { + await expect(talentVault.connect(user1).deposit(0n, user1.address)).to.be.revertedWith("InvalidDepositAmount"); + }); + }); + + describe("#maxWithdraw", async () => { + it("returns the balance of $TALENTVAULT of the given owner", async () => { + // just setting up some non-zero values to make test more solid + const depositAmount = 10_000n; + await talentToken.transfer(user1.address, depositAmount); + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + await talentVault.connect(user1).deposit(depositAmount, user1.address); + const balance = await talentVault.balanceOf(user1.address); + + // fire + const maxWithdraw = await talentVault.maxWithdraw(user1.address); + + expect(maxWithdraw).to.equal(balance); + }); + }); + + describe("#previewWithdraw", async () => { + it("Should return $TALENTVAULT equal to the number of $TALENT given", async () => { + const amountOfTalent = 10_000n; + const amountOfTalentVault = await talentVault.previewWithdraw(amountOfTalent); + expect(amountOfTalentVault).to.equal(amountOfTalent); + }); + }); + + describe("#withdraw", async () => { + context("when last deposit was within the last 7 days", async () => { + it("reverts", async () => { + await talentToken.transfer(user1.address, 10n); + await talentToken.connect(user1).approve(talentVault.address, 10n); + await talentVault.connect(user1).deposit(10n, user1.address); + + // fire + await expect(talentVault.connect(user1).withdraw(10n, user1.address, user1.address)).to.be.revertedWith( + "CantWithdrawWithinTheLockPeriod" + ); + }); + }); + + it("burns $TALENTVAULT from owner, increases $TALENT balance of receiver, decreases $TALENT balance of TalentVault", async () => { + const depositTalent = 10_000n; + + await talentToken.transfer(user1.address, depositTalent); + await talentToken.connect(user1).approve(talentVault.address, depositTalent); + + let trx = await talentVault.connect(user1).deposit(depositTalent, user1.address); + await trx.wait(); + + const user1TalentVaultBalanceBefore = await talentVault.balanceOf(user1.address); + const user1TalentBalanceBefore = await talentToken.balanceOf(user1.address); + const talentVaultTalentBalanceBefore = await talentToken.balanceOf(talentVault.address); + + await ensureTimeIsAfterLockPeriod(); + + // fire + trx = await talentVault.connect(user1).withdraw(depositTalent, user1.address, user1.address); + const receipt = await trx.wait(); + + if (!receipt.events) { + throw new Error("No events found"); + } + + const withdrawEvent = receipt.events.find((event) => event.event === "Withdraw"); + + if (!withdrawEvent || !withdrawEvent.args) { + throw new Error("Withdraw event not found"); + } + + const talentVaultWithDrawn = withdrawEvent.args[4]; + + expect(talentVaultWithDrawn).to.equal(depositTalent); + + // user1 $TALENTVAULT balance decreases + const user1TalentVaultBalanceAfter = await talentVault.balanceOf(user1.address); + expect(user1TalentVaultBalanceAfter).to.equal(user1TalentVaultBalanceBefore.toBigInt() - depositTalent); + + // user1 $TALENT balance increases + const user1TalentBalanceAfter = await talentToken.balanceOf(user1.address); + expect(user1TalentBalanceAfter).to.equal(user1TalentBalanceBefore.toBigInt() + depositTalent); + + // TalentVault $TALENT balance decreases + const talentVaultTalentBalanceAfter = await talentToken.balanceOf(talentVault.address); + expect(talentVaultTalentBalanceAfter).to.equal(talentVaultTalentBalanceBefore.toBigInt() - depositTalent); + }); + }); + + describe("#maxRedeem", async () => { + it("returns the balance of $TALENTVAULT of the given owner", async () => { + // just setting up some non-zero values to make test more solid + const depositAmount = 10_000n; + await talentToken.transfer(user1.address, depositAmount); + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + await talentVault.connect(user1).deposit(depositAmount, user1.address); + const balance = await talentVault.balanceOf(user1.address); + + // fire + const maxRedeem = await talentVault.maxRedeem(user1.address); + + expect(maxRedeem).to.equal(balance); + }); + }); + + describe("#previewRedeem", async () => { + it("Should return $TALENT equal to the number of $TALENTVAULT given", async () => { + const amountOfTalentVault = 10_000n; + const amountOfTalent = await talentVault.previewRedeem(amountOfTalentVault); + expect(amountOfTalent).to.equal(amountOfTalentVault); + }); + }); + + describe("#redeem", async () => { + context("when last deposit was within the last 7 days", async () => { + it("reverts", async () => { + await talentToken.transfer(user1.address, 10n); + await talentToken.connect(user1).approve(talentVault.address, 10n); + await talentVault.connect(user1).deposit(10n, user1.address); + + // fire + await expect(talentVault.connect(user1).withdraw(10n, user1.address, user1.address)).to.be.revertedWith( + "CantWithdrawWithinTheLockPeriod" + ); + }); + }); + + it("burns $TALENTVAULT from owner, increases $TALENT balance of receiver, decreases $TALENT balance of TalentVault", async () => { + const depositTalent = 10_000n; + const equivalentDepositTalentVault = depositTalent; + + await talentToken.transfer(user1.address, depositTalent); + await talentToken.connect(user1).approve(talentVault.address, depositTalent); + let trx = await talentVault.connect(user1).deposit(depositTalent, user1.address); + await trx.wait(); + + const user1TalentVaultBalanceBefore = await talentVault.balanceOf(user1.address); + const user1TalentBalanceBefore = await talentToken.balanceOf(user1.address); + const talentVaultTalentBalanceBefore = await talentToken.balanceOf(talentVault.address); + + await ensureTimeIsAfterLockPeriod(); + + // fire + trx = await talentVault.connect(user1).redeem(equivalentDepositTalentVault, user1.address, user1.address); + const receipt = await trx.wait(); + + if (!receipt.events) { + throw new Error("No events found"); + } + + const withdrawEvent = receipt.events.find((event) => event.event === "Withdraw"); + + if (!withdrawEvent || !withdrawEvent.args) { + throw new Error("Withdraw event not found"); + } + + const talentWithDrawn = withdrawEvent.args[4]; + + expect(talentWithDrawn).to.equal(equivalentDepositTalentVault); + + // user1 $TALENTVAULT balance decreases + const user1TalentVaultBalanceAfter = await talentVault.balanceOf(user1.address); + expect(user1TalentVaultBalanceAfter).to.equal(user1TalentVaultBalanceBefore.toBigInt() - depositTalent); + + // user1 $TALENT balance increases + const user1TalentBalanceAfter = await talentToken.balanceOf(user1.address); + expect(user1TalentBalanceAfter).to.equal(user1TalentBalanceBefore.toBigInt() + depositTalent); + + // TalentVault $TALENT balance decreases + const talentVaultTalentBalanceAfter = await talentToken.balanceOf(talentVault.address); + expect(talentVaultTalentBalanceAfter).to.equal(talentVaultTalentBalanceBefore.toBigInt() - depositTalent); + }); + }); + + describe("#refreshForAddress", async () => { + context("when address does not have a deposit", async () => { + it("just updates the last reward calculation", async () => { + const lastRewardCalculationBefore = (await talentVault.userBalanceMeta(user3.address)).lastRewardCalculation; + + expect(lastRewardCalculationBefore).to.equal(0); + + // fire + await talentVault.refreshForAddress(user3.address); + + const lastRewardCalculation = (await talentVault.userBalanceMeta(user3.address)).lastRewardCalculation; + + expect(lastRewardCalculation).not.to.equal(0); + }); + }); + + // Make sure user balance is updated according to yielded rewards. This is done in the + // tests below, in the Rewards Calculation tests, where we call #refresh + }); + + // withdrawAll + // + // $TALENT for user is increased by their $TALENTVAULT balance + // which is updated with the yield rewards. + // + // TalentVault $TALENT balance is reduced by the originally deposited amount + // + // yieldSource $TALENT balance is reduced by the yieldRewards + // + // user $TALENTVAULT balance goes to 0. + + describe("#withdrawAll", async () => { + it("withdraw all the $TALENTVAULT and converts them to $TALENT", async () => { + const depositAmount = ethers.utils.parseEther("1000"); + // from admin we make user1 have some $TALENT + await talentToken.transfer(user1.address, depositAmount); + // user1 approves talentVault to spend $TALENT + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + // user1 deposits to TalentVault + // This makes user1 $TALENT to be decreased by depositAmount + // and TalentVault $TALENT to be increased by depositAmount + // and user1 $TALENTVAULT to be increased by depositAmount + await talentVault.connect(user1).deposit(depositAmount, user1.address); + + const talentVaultTalentBalanceBefore = await talentToken.balanceOf(talentVault.address); + const yieldSourceTalentBalanceBefore = await talentToken.balanceOf(yieldSource.address); + + const user1TalentVaultBalanceBefore = await talentVault.balanceOf(user1.address); + expect(user1TalentVaultBalanceBefore).to.equal(depositAmount); + + // Simulate time passing + ensureTimestamp(currentDateEpochSeconds + 31536000); // 1 year ahead + + const yieldedRewards = yieldBasePerDay.mul(90); // 5% rewards but over 90 days + + // this is manually calculated, but it is necessary for this test. + const expectedUser1TalentVaultBalanceAfter1Year = depositAmount.add(yieldedRewards); + + // fire + await talentVault.connect(user1).withdrawAll(); + + // TalentVault $TALENT balance is reduced by the originally deposited amount + const talentVaultTalentBalanceAfter = await talentToken.balanceOf(talentVault.address); + const expectedTalentVaultTalentBalanceAfter = talentVaultTalentBalanceBefore.sub(depositAmount); + expect(talentVaultTalentBalanceAfter).to.equal(expectedTalentVaultTalentBalanceAfter); + + // user1 $TALENT balance is increased + const user1TalentBalanceAfter = await talentToken.balanceOf(user1.address); + expect(user1TalentBalanceAfter).to.be.closeTo( + expectedUser1TalentVaultBalanceAfter1Year, + ethers.utils.parseEther("0.01") + ); + + // user1 $TALENTVAULT balance goes to 0 + const user1TalentVaultBalanceAfter = await talentVault.balanceOf(user1.address); + expect(user1TalentVaultBalanceAfter).to.equal(0); + + // yieldSource $TALENT balance is decreased by the yieldRewards + const yieldSourceTalentBalanceAfter = await talentToken.balanceOf(yieldSource.address); + const expectedYieldSourceTalentBalanceAfter = yieldSourceTalentBalanceBefore.sub(yieldedRewards); + expect(yieldSourceTalentBalanceAfter).to.be.closeTo( + expectedYieldSourceTalentBalanceAfter, + ethers.utils.parseEther("0.01") + ); + }); + }); + + describe("Rewards Calculation", async () => { + it("Should calculate rewards correctly", async () => { + const depositAmount = ethers.utils.parseEther("1000"); + await talentToken.transfer(user1.address, depositAmount); + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + await talentVault.connect(user1).deposit(depositAmount, user1.address); + + // Simulate time passing + ensureTimestamp(currentDateEpochSeconds + 31536000); // 1 year ahead + + const expectedRewards = yieldBasePerDay.mul(90); // 5% rewards but over 90 days + + // fire + await talentVault.connect(user1).refresh(); + + const userBalance = await talentVault.balanceOf(user1.address); + expect(userBalance).to.be.closeTo(depositAmount.add(expectedRewards), ethers.utils.parseEther("0.01")); + + const userLastRewardCalculation = (await talentVault.userBalanceMeta(user1.address)).lastRewardCalculation; + const oneYearAfterEpochSeconds = currentDateEpochSeconds + 31536000; + + expect(userLastRewardCalculation.toNumber()).to.equal(oneYearAfterEpochSeconds); + }); + + context("when yielding rewards is stopped", async () => { + it("does not yield any rewards but it updates the lastRewardCalculation", async () => { + const depositAmount = ethers.utils.parseEther("1000"); + await talentToken.transfer(user1.address, depositAmount); + const user1BalanceBefore = await talentToken.balanceOf(user1.address); + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + await talentVault.connect(user1).deposit(depositAmount, user1.address); + + await talentVault.stopYieldingRewards(); + + // Simulate time passing + + ensureTimestamp(currentDateEpochSeconds + 31536000); // 1 year ahead + + // fire + await talentVault.connect(user1).refresh(); + + const user1BalanceAfter = await talentVault.balanceOf(user1.address); + expect(user1BalanceAfter).to.equal(user1BalanceBefore); + + const userLastRewardCalculation = (await talentVault.userBalanceMeta(user1.address)).lastRewardCalculation; + const oneYearAfterEpochSeconds = currentDateEpochSeconds + 31536000; + + expect(userLastRewardCalculation.toNumber()).to.equal(oneYearAfterEpochSeconds); + }); + }); + + it("Should calculate rewards correctly for builders with scores below 60", async () => { + await passportRegistry.setGenerationMode(true, 1); // Enable sequential mode + await passportRegistry.connect(user1).create("source1"); + + const passportId = await passportRegistry.passportId(user1.address); + const depositAmount = ethers.utils.parseEther("1000"); + await talentToken.transfer(user1.address, depositAmount); + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + await talentVault.connect(user1).deposit(depositAmount, user1.address); + + // Simulate time passing + ensureTimestamp(currentDateEpochSeconds + 31536000); // 1 year ahead + await passportBuilderScore.setScore(passportId, 55); // Set builder score below 50 + + // fire + await talentVault.connect(user1).refresh(); + + const expectedRewards = yieldBasePerDay.mul(90); // 5% rewards but over 90 days + const userBalance = await talentVault.balanceOf(user1.address); + expect(userBalance).to.be.closeTo(depositAmount.add(expectedRewards), ethers.utils.parseEther("0.1")); + }); + + it("Should calculate rewards correctly for builders with scores above 60 (inclusive)", async () => { + await passportRegistry.setGenerationMode(true, 1); // Enable sequential mode + await passportRegistry.connect(user1).create("source1"); + + const passportId = await passportRegistry.passportId(user1.address); + await passportBuilderScore.setScore(passportId, 60); // Set builder score above 60 + const depositAmount = ethers.utils.parseEther("1000"); + await talentToken.transfer(user1.address, depositAmount); + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + await talentVault.connect(user1).deposit(depositAmount, user1.address); + + // Simulate time passing + ensureTimestamp(currentDateEpochSeconds + 31536000); // 1 year ahead + await passportBuilderScore.setScore(passportId, 60); // Set builder score above 60 + + // fire + await talentVault.connect(user1).refresh(); + + const expectedRewards = yieldBasePerDay.mul(2).mul(90); // 10% rewards but over 90 days + const userBalance = await talentVault.balanceOf(user1.address); + expect(userBalance).to.be.closeTo(depositAmount.add(expectedRewards), ethers.utils.parseEther("0.1")); + }); + }); + + describe("#setYieldRate", async () => { + it("Should allow the owner to update the yield rate", async () => { + const newYieldRate = 15_00; // 15% + await talentVault.connect(admin).setYieldRate(newYieldRate); + expect(await talentVault.yieldRateBase()).to.equal(newYieldRate); + }); + + it("Should not allow non-owners to update the yield rate", async () => { + const newYieldRate = 15_00; // 15% + await expect(talentVault.connect(user1).setYieldRate(newYieldRate)).to.be.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); + + describe("#stopYieldingRewards", async () => { + context("when called by an non-owner account", async () => { + it("reverts", async () => { + await expect(talentVault.connect(user1).stopYieldingRewards()).to.be.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); + + context("when called by the owner account", async () => { + it("stops yielding rewards", async () => { + await talentVault.stopYieldingRewards(); + + expect(await talentVault.yieldRewardsFlag()).to.equal(false); + }); + }); + }); + + describe("#maxOverallDeposit", async () => { + context("when called by an non-owner account", async () => { + it("reverts", async () => { + await expect( + talentVault.connect(user1).setMaxOverallDeposit(ethers.utils.parseEther("100000")) + ).to.be.revertedWith(`OwnableUnauthorizedAccount("${user1.address}")`); + }); + }); + + context("when called by the owner account", async () => { + it("sets the max overall deposit", async () => { + await talentVault.setMaxOverallDeposit(ethers.utils.parseEther("500000")); + + expect(await talentVault.maxOverallDeposit()).to.equal(ethers.utils.parseEther("500000")); + }); + }); + }); + + describe("#startYieldingRewards", async () => { + context("when called by an non-owner account", async () => { + it("reverts", async () => { + await expect(talentVault.connect(user1).startYieldingRewards()).to.be.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); + + context("when called by the owner account", async () => { + it("starts yielding rewards", async () => { + await talentVault.startYieldingRewards(); + + expect(await talentVault.yieldRewardsFlag()).to.equal(true); + }); + }); + }); + + describe("#setLockPeriod", async () => { + context("when called by a non-owner account", async () => { + it("reverts", async () => { + await expect(talentVault.connect(user1).setLockPeriod(3)).to.be.revertedWith( + `OwnableUnauthorizedAccount("${user1.address}")` + ); + }); + }); + + context("when called by the owner account", async () => { + it("sets the lock period as days given", async () => { + await talentVault.setLockPeriod(10); + + expect(await talentVault.lockPeriod()).to.equal(10 * 24 * 60 * 60); + }); + }); + }); +}); diff --git a/test/contracts/talent/TalentVaultV2.ts b/test/contracts/talent/TalentVaultV2.ts new file mode 100644 index 00000000..8a04391e --- /dev/null +++ b/test/contracts/talent/TalentVaultV2.ts @@ -0,0 +1,127 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { TalentProtocolToken, TalentVaultV2, PassportRegistry, PassportBuilderScore } from "../../../typechain-types"; +import { Artifacts } from "../../shared"; +import { ensureTimestamp } from "../../shared/utils"; + +chai.use(solidity); + +const { expect } = chai; +const { deployContract } = waffle; + +async function ensureTimeIsAfterLockPeriod() { + const lockPeriod = 8; + const oneDayAfterLockPeriod = Math.floor(Date.now() / 1000) + lockPeriod * 24 * 60 * 60; + await ensureTimestamp(oneDayAfterLockPeriod); +} + +describe("TalentVaultV2", () => { + let admin: SignerWithAddress; + let yieldSource: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + let talentToken: TalentProtocolToken; + let talentVault: TalentVaultV2; + + beforeEach(async () => { + await ethers.provider.send("hardhat_reset", []); + + [admin, yieldSource, user1, user2, user3] = await ethers.getSigners(); + + talentToken = (await deployContract(admin, Artifacts.TalentProtocolToken, [admin.address])) as TalentProtocolToken; + + talentVault = (await deployContract(admin, Artifacts.TalentVaultV2, [talentToken.address])) as TalentVaultV2; + + console.log("------------------------------------"); + console.log("Addresses:"); + console.log(`admin = ${admin.address}`); + console.log(`user1 = ${user1.address}`); + console.log(`user2 = ${user2.address}`); + console.log(`user3 = ${user3.address}`); + console.log(`talentToken = ${talentToken.address}`); + console.log(`talentVault = ${talentVault.address}`); + console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"); + + await talentToken.unpause(); + }); + + it("should deposit TALENT and mint sTALENT at 1:1 ratio", async () => { + // transfer TALENT to user + const depositAmount = ethers.utils.parseEther("1000"); + await talentToken.connect(admin).transfer(user1.address, depositAmount); + + // Approve the vault to spend user's TALENT + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + + // Check initial balances + const userInitialTalent = await talentToken.balanceOf(user1.address); + const userInitialShares = await talentVault.balanceOf(user1.address); + + // User deposits 100 TALENT + const depositedAmount = ethers.utils.parseEther("100"); + await talentVault.connect(user1).deposit(depositedAmount, user1.address); + + // Check the user's final balance of TALENT + const userFinalTalent = await talentToken.balanceOf(user1.address); + const userFinalShares = await talentVault.balanceOf(user1.address); + + expect(userInitialTalent.sub(depositedAmount)).to.eq(userFinalTalent); + expect(userFinalShares.sub(userInitialShares)).to.eq(depositedAmount); + }); + + it("should redeem sTALENT back to TALENT at 1:1 ratio", async () => { + // User redeems 50 sTALENT + const redeemAmount = ethers.utils.parseEther("50"); + + // transfer TALENT to user + const depositAmount = ethers.utils.parseEther("1000"); + await talentToken.connect(admin).transfer(user1.address, depositAmount); + + // Approve the vault to spend user's TALENT + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + + // deposit TALENT + await talentVault.connect(user1).deposit(depositAmount, user1.address); + + const userInitialTalent = await talentToken.balanceOf(user1.address); + const userInitialShares = await talentVault.balanceOf(user1.address); + + // redeem sTALENT + await talentVault.connect(user1).redeem(redeemAmount, user1.address, user1.address); + + const userFinalTalent = await talentToken.balanceOf(user1.address); + const userFinalShares = await talentVault.balanceOf(user1.address); + + expect(userFinalTalent.sub(userInitialTalent)).to.eq(redeemAmount); + expect(userInitialShares.sub(userFinalShares)).to.eq(redeemAmount); + }); + + it("should set the vault option as true when a vault option is added", async () => { + await talentVault.connect(admin).addVaultOption(user2.address); + expect(await talentVault.isWhitelisted(user2.address)).to.be.true; + }); + + it("should set the vault option as false when a vault option is removed", async () => { + await talentVault.connect(admin).addVaultOption(user2.address); + expect(await talentVault.isWhitelisted(user2.address)).to.be.true; + + await talentVault.connect(admin).removeVaultOption(user2.address); + expect(await talentVault.isWhitelisted(user2.address)).to.be.false; + }); + + it("should emit VaultOptionAdded event when a vault option is added", async () => { + const addVaultOptionTx = talentVault.connect(admin).addVaultOption(user2.address); + await expect(addVaultOptionTx).to.emit(talentVault, "VaultOptionAdded").withArgs(user2.address); + }); + + it("should emit VaultOptionRemoved event when a vault option is removed", async () => { + await talentVault.connect(admin).addVaultOption(user2.address); + const removeVaultOptionTx = talentVault.connect(admin).removeVaultOption(user2.address); + await expect(removeVaultOptionTx).to.emit(talentVault, "VaultOptionRemoved").withArgs(user2.address); + }); +}); diff --git a/test/contracts/talent/vault-integration/BaseAPY.ts b/test/contracts/talent/vault-integration/BaseAPY.ts new file mode 100644 index 00000000..2cfe1dc7 --- /dev/null +++ b/test/contracts/talent/vault-integration/BaseAPY.ts @@ -0,0 +1,97 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { TalentProtocolToken, TalentVaultV2, BaseAPY } from "../../../../typechain-types"; +import { Artifacts } from "../../../shared"; +import { ensureTimestamp } from "../../../shared/utils"; + +chai.use(solidity); + +const { expect } = chai; +const { deployContract } = waffle; + +async function ensureTimeIsAfterLockPeriod() { + const lockPeriod = 8; + const oneDayAfterLockPeriod = Math.floor(Date.now() / 1000) + lockPeriod * 24 * 60 * 60; + await ensureTimestamp(oneDayAfterLockPeriod); +} + +describe("TalentVaultV2", () => { + let admin: SignerWithAddress; + let yieldSource: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + let talentToken: TalentProtocolToken; + let talentVault: TalentVaultV2; + let baseAPY: BaseAPY; + + beforeEach(async () => { + await ethers.provider.send("hardhat_reset", []); + + [admin, yieldSource, user1, user2, user3] = await ethers.getSigners(); + + talentToken = (await deployContract(admin, Artifacts.TalentProtocolToken, [admin.address])) as TalentProtocolToken; + + talentVault = (await deployContract(admin, Artifacts.TalentVaultV2, [talentToken.address])) as TalentVaultV2; + + baseAPY = (await deployContract(admin, Artifacts.BaseAPY, [talentToken.address, talentVault.address])) as BaseAPY; + + console.log("------------------------------------"); + console.log("Addresses:"); + console.log(`admin = ${admin.address}`); + console.log(`user1 = ${user1.address}`); + console.log(`user2 = ${user2.address}`); + console.log(`user3 = ${user3.address}`); + console.log(`talentToken = ${talentToken.address}`); + console.log(`talentVault = ${talentVault.address}`); + console.log(`baseAPY = ${baseAPY.address}`); + console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"); + + await talentToken.unpause(); + }); + + it("should deposit TALENT and mint sTALENT at 1:1 ratio", async () => { + // whitelist baseAPY + await talentVault.connect(admin).addVaultOption(baseAPY.address); + + // transfer TALENT to user + const depositAmount = ethers.utils.parseEther("1000"); + await talentToken.connect(admin).transfer(user1.address, depositAmount); + + // Approve the vault to spend user's TALENT + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + + // transfer TALENT to baseAPY + const rewardAmount = ethers.utils.parseEther("100"); + await talentToken.connect(admin).transfer(baseAPY.address, rewardAmount); + + // Check initial balances + const userInitialTalent = await talentToken.balanceOf(user1.address); + const userInitialShares = await talentVault.balanceOf(user1.address); + const baseAPYInitialTalent = await talentToken.balanceOf(baseAPY.address); + + // User deposits 100 TALENT to the baseAPY position + const depositedAmount = ethers.utils.parseEther("100"); + await talentVault.connect(user1).depositToOption(depositedAmount, user1.address, baseAPY.address); + + // Check the user's final balance of TALENT + const userFinalTalent = await talentToken.balanceOf(user1.address); + const userFinalShares = await talentVault.balanceOf(user1.address); + const baseAPYFinalTalent = await talentToken.balanceOf(baseAPY.address); + const vaultFinalTalent = await talentToken.balanceOf(talentVault.address); + + // user spent 100 TALENT, and received 100 sTALENT + expect(userInitialTalent.sub(depositedAmount)).to.eq(userFinalTalent); + expect(userFinalShares.sub(userInitialShares)).to.eq(depositedAmount); + + // baseAPY holds the Talent transferred from user + expect(baseAPYFinalTalent.sub(baseAPYInitialTalent)).to.eq(depositedAmount); + + // vault holds 0 TALENT because it was transferred to baseAPY + expect(vaultFinalTalent).to.eq(0); + }); +}); diff --git a/test/shared/artifacts.ts b/test/shared/artifacts.ts index faadab73..308172cf 100644 --- a/test/shared/artifacts.ts +++ b/test/shared/artifacts.ts @@ -9,6 +9,10 @@ import SmartBuilderScore from "../../artifacts/contracts/passport/SmartBuilderSc import PassportSources from "../../artifacts/contracts/passport/PassportSources.sol/PassportSources.json"; import TalentTGEUnlock from "../../artifacts/contracts/talent/TalentTGEUnlock.sol/TalentTGEUnlock.json"; import PassportWalletRegistry from "../../artifacts/contracts/passport/PassportWalletRegistry.sol/PassportWalletRegistry.json"; +import TalentTGEUnlockTimestamp from "../../artifacts/contracts/talent/TalentTGEUnlockTimestamp.sol/TalentTGEUnlockTimestamp.json"; +import TalentVault from "../../artifacts/contracts/talent/TalentVault.sol/TalentVault.json"; +import TalentVaultV2 from "../../artifacts/contracts/talent/TalentVaultV2.sol/TalentVaultV2.json"; +import BaseAPY from "../../artifacts/contracts/talent/vault-options/BaseAPY.sol/BaseAPY.json"; export { PassportRegistry, @@ -22,4 +26,8 @@ export { PassportSources, TalentTGEUnlock, PassportWalletRegistry, + TalentTGEUnlockTimestamp, + TalentVault, + TalentVaultV2, + BaseAPY, }; diff --git a/yarn.lock b/yarn.lock index 4f6c4751..39eff526 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3370,6 +3370,13 @@ concat-stream@^1.5.1, concat-stream@^1.6.0, concat-stream@^1.6.2: readable-stream "^2.2.2" typedarray "^0.0.6" +console-table-printer@^2.9.0: + version "2.12.1" + resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.12.1.tgz#4a9646537a246a6d8de57075d4fae1e08abae267" + integrity sha512-wKGOQRRvdnd89pCeH96e2Fn4wkbenSP6LMHfjfyNLMbGuHEFbMqQNuxXqd0oXG9caIOQ1FTvc5Uijp9/4jujnQ== + dependencies: + simple-wcswidth "^1.0.1" + content-disposition@0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -5537,6 +5544,13 @@ hardhat-gas-reporter@^1.0.9: eth-gas-reporter "^0.2.25" sha1 "^1.1.1" +hardhat-storage-layout@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/hardhat-storage-layout/-/hardhat-storage-layout-0.1.7.tgz#ad8a5afd8593ee51031eb1dd9476b4a2ed981785" + integrity sha512-q723g2iQnJpRdMC6Y8fbh/stG6MLHKNxa5jq/ohjtD5znOlOzQ6ojYuInY8V4o4WcPyG3ty4hzHYunLf66/1+A== + dependencies: + console-table-printer "^2.9.0" + hardhat@2.22.4, hardhat@^2.22.4: version "2.22.4" resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.22.4.tgz#766227b6cefca5dbf4fd15ab5b5a68138fa13baf" @@ -8800,6 +8814,11 @@ simple-get@^2.7.0: once "^1.3.1" simple-concat "^1.0.0" +simple-wcswidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.0.1.tgz#8ab18ac0ae342f9d9b629604e54d2aa1ecb018b2" + integrity sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg== + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"