diff --git a/.tool-versions b/.tool-versions index c6743f9b..fb44f4ff 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ nodejs 20.12.2 +yarn 1.22.19 solidity 0.8.24 diff --git a/contracts/talent/TalentVaultV3.sol b/contracts/talent/TalentVaultV3.sol new file mode 100644 index 00000000..8b5133c4 --- /dev/null +++ b/contracts/talent/TalentVaultV3.sol @@ -0,0 +1,357 @@ +// 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"; + +/// @title Talent Protocol Vault Token Contract V3 +/// @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 without builder score logic. +contract TalentVaultV3 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 rate for the contract, represented as a percentage. + /// @dev Represented with 2 decimal places, e.g. 10_00 for 10% + uint256 public yieldRate; + + /// @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 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 V3 contract + /// @param _token The token that will be deposited into the contract + /// @param _yieldSource The wallet paying for the yield + constructor( + IERC20 _token, + address _yieldSource + ) ERC4626(_token) ERC20("TalentVaultV3", "sTALENT3") Ownable(msg.sender) { + if ( + address(_token) == address(0) || + address(_yieldSource) == address(0) + ) { + revert InvalidAddress(); + } + + token = _token; + yieldRate = 5_00; + yieldSource = _yieldSource; + yieldRewardsFlag = true; + yieldAccrualDeadline = block.timestamp + 90 days; + lockPeriod = 30 days; + maxOverallDeposit = 1_000_000 ether; + } + + // ------------------- 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 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 > yieldRate, "Yield rate cannot be decreased"); + + yieldRate = _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 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 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; + } + + 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 ownerUserBalanceMeta = userBalanceMeta[owner]; + + if (ownerUserBalanceMeta.lastDepositAt + lockPeriod > block.timestamp) { + revert CantWithdrawWithinTheLockPeriod(); + } + + super._withdraw(caller, receiver, owner, assets, shares); + } +} diff --git a/scripts/shared/index.ts b/scripts/shared/index.ts index b1283426..36f1c6e7 100644 --- a/scripts/shared/index.ts +++ b/scripts/shared/index.ts @@ -10,6 +10,7 @@ import type { PassportWalletRegistry, TalentTGEUnlockTimestamp, TalentVault, + TalentVaultV3, } from "../../typechain-types"; export async function deployPassport(owner: string): Promise { @@ -163,3 +164,19 @@ export async function deployTalentVault( return deployedTalentVault as TalentVault; } + +export async function deployTalentVaultV3( + talentToken: string, + yieldSource: string +): Promise { + const talentVaultV3Contract = await ethers.getContractFactory("TalentVaultV3"); + + const deployedTalentVaultV3 = await talentVaultV3Contract.deploy( + talentToken, + yieldSource + ); + + await deployedTalentVaultV3.deployed(); + + return deployedTalentVaultV3 as TalentVaultV3; +} diff --git a/scripts/talent/deployTalentVaultV3.ts b/scripts/talent/deployTalentVaultV3.ts new file mode 100644 index 00000000..f928abcd --- /dev/null +++ b/scripts/talent/deployTalentVaultV3.ts @@ -0,0 +1,152 @@ +import { ethers, network, run } from "hardhat"; +import { deployTalentVaultV3 } from "../shared"; + +const TALENT_TOKEN_MAINNET = "0x9a33406165f562E16C3abD82fd1185482E01b49a"; +const TALENT_TOKEN_TESTNET = "0x7c2a63e1713578d4d704b462C2dee311A59aE304"; + +const YIELD_SOURCE_MAINNET = "0x34118871f5943D6B153381C0133115c3B5b78b12"; +const YIELD_SOURCE_TESTNET = "0x33041027dd8F4dC82B6e825FB37ADf8f15d44053"; + +async function main() { + console.log("=".repeat(60)); + console.log(`Deploying Talent Vault V3 to network: ${network.name}`); + console.log("=".repeat(60)); + + const [admin] = await ethers.getSigners(); + console.log(`\nDeployer address: ${admin.address}`); + + // Select addresses based on network + let talentToken: string; + let yieldSource: string; + + if (network.name === "base") { + console.log("Network: Base Mainnet"); + talentToken = TALENT_TOKEN_MAINNET; + yieldSource = YIELD_SOURCE_MAINNET; + } else if (network.name === "baseSepolia") { + console.log("Network: Base Sepolia Testnet"); + talentToken = TALENT_TOKEN_TESTNET; + yieldSource = YIELD_SOURCE_TESTNET; + } else if (network.name === "hardhat" || network.name === "localhost") { + console.log("Network: Local Development"); + console.log("⚠️ WARNING: Using mainnet addresses for local testing"); + talentToken = TALENT_TOKEN_MAINNET; + yieldSource = YIELD_SOURCE_MAINNET; + } else { + throw new Error(`❌ Unsupported network: ${network.name}. Supported networks: base, baseSepolia`); + } + + console.log(`\nUsing Talent Token: ${talentToken}`); + console.log(`Using Yield Source: ${yieldSource}`); + + // Validate addresses are actual contracts (skip for local networks) + if (network.name !== "hardhat" && network.name !== "localhost") { + console.log(`\nValidating contract addresses...`); + + const tokenCode = await ethers.provider.getCode(talentToken); + if (tokenCode === "0x") { + throw new Error(`❌ Talent Token address ${talentToken} is not a contract on ${network.name}`); + } + console.log(`✅ Talent Token verified at ${talentToken}`); + + const yieldSourceCode = await ethers.provider.getCode(yieldSource); + if (yieldSourceCode === "0x") { + throw new Error(`❌ Yield Source address ${yieldSource} is not a contract on ${network.name}`); + } + console.log(`✅ Yield Source verified at ${yieldSource}`); + } + + console.log(`\nDeploying TalentVaultV3...`); + const talentVaultV3 = await deployTalentVaultV3( + talentToken, + yieldSource + ); + + console.log(`\n✅ Talent Vault V3 deployed successfully!`); + console.log(`Contract address: ${talentVaultV3.address}`); + + // Post-deployment verification + console.log(`\nVerifying deployment configuration...`); + const deployedYieldSource = await talentVaultV3.yieldSource(); + const deployedToken = await talentVaultV3.token(); + const deployedOwner = await talentVaultV3.owner(); + const deployedYieldRate = await talentVaultV3.yieldRate(); + const deployedLockPeriod = await talentVaultV3.lockPeriod(); + const deployedMaxDeposit = await talentVaultV3.maxOverallDeposit(); + const deployedYieldAccrualDeadline = await talentVaultV3.yieldAccrualDeadline(); + + if (deployedYieldSource.toLowerCase() !== yieldSource.toLowerCase()) { + throw new Error(`❌ Yield source mismatch! Expected ${yieldSource}, got ${deployedYieldSource}`); + } + if (deployedToken.toLowerCase() !== talentToken.toLowerCase()) { + throw new Error(`❌ Token mismatch! Expected ${talentToken}, got ${deployedToken}`); + } + if (deployedOwner.toLowerCase() !== admin.address.toLowerCase()) { + throw new Error(`❌ Owner mismatch! Expected ${admin.address}, got ${deployedOwner}`); + } + + console.log(`✅ Yield Source: ${deployedYieldSource}`); + console.log(`✅ Token: ${deployedToken}`); + console.log(`✅ Owner: ${deployedOwner}`); + console.log(`✅ Yield Rate: ${deployedYieldRate.toString()} (${deployedYieldRate.toNumber() / 100}%)`); + console.log(`✅ Lock Period: ${deployedLockPeriod.toString()} seconds (${deployedLockPeriod.toNumber() / 86400} days)`); + console.log(`✅ Max Overall Deposit: ${ethers.utils.formatEther(deployedMaxDeposit)} TALENT`); + + const currentTime = Math.floor(Date.now() / 1000); + const daysUntilDeadline = Math.floor((deployedYieldAccrualDeadline.toNumber() - currentTime) / 86400); + console.log(`✅ Yield Accrual Deadline: ${new Date(deployedYieldAccrualDeadline.toNumber() * 1000).toISOString()} (${daysUntilDeadline} days from now)`); + + // Attempt automatic verification on block explorers + if (network.name !== "hardhat" && network.name !== "localhost") { + console.log(`\n${"=".repeat(60)}`); + console.log(`Attempting contract verification on block explorer...`); + console.log(`${"=".repeat(60)}`); + + try { + await run("verify:verify", { + address: talentVaultV3.address, + constructorArguments: [talentToken, yieldSource], + }); + console.log(`✅ Contract verified successfully on block explorer`); + } catch (error: any) { + if (error.message.includes("Already Verified")) { + console.log(`ℹ️ Contract already verified on block explorer`); + } else { + console.log(`⚠️ Automatic verification failed:`, error.message); + console.log(`\nManual verification parameters:`); + console.log(`Contract address: ${talentVaultV3.address}`); + console.log(`Constructor arguments: ["${talentToken}", "${yieldSource}"]`); + } + } + } + + // Important post-deployment instructions + console.log(`\n${"=".repeat(60)}`); + console.log(`POST-DEPLOYMENT CHECKLIST`); + console.log(`${"=".repeat(60)}`); + console.log(`\n⚠️ IMPORTANT: Complete these steps before users can deposit:\n`); + console.log(`1. Ensure yield source wallet has sufficient TALENT tokens`); + console.log(` Yield Source Address: ${yieldSource}`); + console.log(` Recommended balance: At least ${ethers.utils.formatEther(ethers.utils.parseEther("100000"))} TALENT\n`); + console.log(`2. Approve the vault to spend tokens from yield source:`); + console.log(` - Connect to yield source wallet`); + console.log(` - Call: TalentToken.approve("${talentVaultV3.address}", )`); + console.log(` - Recommended approval: ${ethers.utils.formatEther(ethers.utils.parseEther("100000"))} TALENT\n`); + console.log(`3. Monitor yield accrual deadline (currently ${daysUntilDeadline} days)`); + console.log(` - Extend before expiry using: setYieldAccrualDeadline(newTimestamp)\n`); + console.log(`4. Test deployment with a small deposit before announcing\n`); + console.log(`5. Consider adjusting max overall deposit if needed:`); + console.log(` - Current: ${ethers.utils.formatEther(deployedMaxDeposit)} TALENT`); + console.log(` - Adjust with: setMaxOverallDeposit(newAmount)\n`); + + console.log(`${"=".repeat(60)}`); + console.log(`✅ Deployment Complete!`); + console.log(`${"=".repeat(60)}`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/test/contracts/talent/TalentVaultV3.ts b/test/contracts/talent/TalentVaultV3.ts new file mode 100644 index 00000000..04f4f1d7 --- /dev/null +++ b/test/contracts/talent/TalentVaultV3.ts @@ -0,0 +1,929 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { + TalentProtocolToken, + TalentVaultV3, +} 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("TalentVaultV3", () => { + let admin: SignerWithAddress; + let yieldSource: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + let talentToken: TalentProtocolToken; + let talentVault: TalentVaultV3; + + 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; + + talentVault = (await deployContract(admin, Artifacts.TalentVaultV3, [ + talentToken.address, + yieldSource.address, + ])) as TalentVaultV3; + + 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); + + // 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.yieldRate()).to.equal(5_00); + 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.TalentVaultV3, [ + ethers.constants.AddressZero, + admin.address, + ]) + ).to.be.reverted; + }); + + it("reverts with InvalidAddress when _yieldSource given is 0", async () => { + await expect( + deployContract(admin, Artifacts.TalentVaultV3, [ + talentToken.address, + ethers.constants.AddressZero, + ]) + ).to.be.reverted; + }); + }); + + describe("#name", async () => { + it("is 'TalentVaultV3' reflects the underlying token name, i.e. of 'TalentProtocolToken'", async () => { + const name = await talentVault.name(); + + expect(name).to.equal("TalentVaultV3"); + }); + }); + + describe("#symbol", async () => { + it("is 'sTALENT3' reflects the underlying token symbol, i.e. of 'TALENT'", async () => { + const symbol = await talentVault.symbol(); + + expect(symbol).to.equal("sTALENT3"); + }); + }); + + 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); + }); + }); + }); + + 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 with standard 5% yield rate", 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 + + // 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")); + }); + }); + + 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.yieldRate()).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); + }); + }); + }); + + describe("Lock Period Bypass Vulnerability", async () => { + it("Should prevent lock period bypass via redeem()", async () => { + // Setup: User1 deposits tokens and gets locked shares + 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); + + const user1Shares = await talentVault.balanceOf(user1.address); + const user1Meta = await talentVault.userBalanceMeta(user1.address); + + // Verify user1 is in lock period + const lockEndTime = Number(user1Meta.lastDepositAt) + (30 * 24 * 3600); + const now = Math.floor(Date.now() / 1000); + const isLocked = now < lockEndTime; + + expect(isLocked).to.be.true; + + // User1 approves user2 (attacker) to spend their shares + await talentVault.connect(user1).approve(user2.address, user1Shares); + + // Test: Attempted bypass should fail + await expect( + talentVault.connect(user2).redeem(user1Shares, user2.address, user1.address) + ).to.be.revertedWith("CantWithdrawWithinTheLockPeriod"); + + // Verify user1's shares are still there + const user1SharesAfter = await talentVault.balanceOf(user1.address); + expect(user1SharesAfter).to.equal(user1Shares); + }); + + it("Should prevent lock period bypass via withdraw()", async () => { + // Setup: User1 deposits tokens and gets locked shares + const depositAmount = ethers.utils.parseEther("500"); + await talentToken.transfer(user1.address, depositAmount); + await talentToken.connect(user1).approve(talentVault.address, depositAmount); + await talentVault.connect(user1).deposit(depositAmount, user1.address); + + const user1Shares = await talentVault.balanceOf(user1.address); + const user1Meta = await talentVault.userBalanceMeta(user1.address); + + // Verify user1 is in lock period + const lockEndTime = Number(user1Meta.lastDepositAt) + (30 * 24 * 3600); + const now = Math.floor(Date.now() / 1000); + const isLocked = now < lockEndTime; + + expect(isLocked).to.be.true; + + // User1 approves user3 (attacker) to spend their shares + await talentVault.connect(user1).approve(user3.address, user1Shares); + + // Convert shares to assets (1:1 ratio in this vault) + const assetsToWithdraw = user1Shares; + + // Test: Attempted bypass should fail + await expect( + talentVault.connect(user3).withdraw(assetsToWithdraw, user3.address, user1.address) + ).to.be.revertedWith("CantWithdrawWithinTheLockPeriod"); + + // Verify user1's shares are still there + const user1SharesAfter = await talentVault.balanceOf(user1.address); + expect(user1SharesAfter).to.equal(user1Shares); + }); + }); +}); diff --git a/test/shared/artifacts.ts b/test/shared/artifacts.ts index 3e28f522..b1e2a9de 100644 --- a/test/shared/artifacts.ts +++ b/test/shared/artifacts.ts @@ -13,6 +13,7 @@ import PassportWalletRegistry from "../../artifacts/contracts/passport/PassportW 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 TalentVaultV3 from "../../artifacts/contracts/talent/TalentVaultV3.sol/TalentVaultV3.json"; import BaseAPY from "../../artifacts/contracts/talent/vault-options/BaseAPY.sol/BaseAPY.json"; import MultiSendETH from "../../artifacts/contracts/utils/MultiSendETH.sol/MultiSendETH.json"; @@ -32,6 +33,7 @@ export { TalentTGEUnlockTimestamp, TalentVault, TalentVaultV2, + TalentVaultV3, BaseAPY, MultiSendETH, };