Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
nodejs 20.12.2
yarn 1.22.19
solidity 0.8.24
357 changes: 357 additions & 0 deletions contracts/talent/TalentVaultV3.sol
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +167 to +173
Copy link
Collaborator

@pmatsinopoulos pmatsinopoulos Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially, the accrual deadline is 90 days after the contract deployment. Are we going to call this function again before the 90 days to make it further in the future?


/// @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;
}
Comment on lines +177 to +185
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think that it might be useful to emit corresponding events?


/// @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;
}
Comment on lines +215 to +235
Copy link
Collaborator

@pmatsinopoulos pmatsinopoulos Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides checking that the deposit doesn't make the total assets cross the maxOverallDeposit, I think that we should check that the deposit doesn't cross the limit that each address might have via the maxMint(address) function. Am i wrong? Otherwise, one might deposit more that they might be allowed 🤔 ... In other words, maybe the balanceMeta.depositedAmount += assets; should not bring the balanceMeta.depositedAmount in a value which is greater than the maximum amount that an address can deposit based on the maxMint(address) ?


/// @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);
}
}
17 changes: 17 additions & 0 deletions scripts/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
PassportWalletRegistry,
TalentTGEUnlockTimestamp,
TalentVault,
TalentVaultV3,
} from "../../typechain-types";

export async function deployPassport(owner: string): Promise<PassportRegistry> {
Expand Down Expand Up @@ -163,3 +164,19 @@ export async function deployTalentVault(

return deployedTalentVault as TalentVault;
}

export async function deployTalentVaultV3(
talentToken: string,
yieldSource: string
): Promise<TalentVaultV3> {
const talentVaultV3Contract = await ethers.getContractFactory("TalentVaultV3");

const deployedTalentVaultV3 = await talentVaultV3Contract.deploy(
talentToken,
yieldSource
);

await deployedTalentVaultV3.deployed();

return deployedTalentVaultV3 as TalentVaultV3;
}
Loading
Loading