// SPDX-License-Identifier: MIT import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; pragma solidity ^0.8.20; interface iPriceOracle { // returns price in USD function getLatestPrice(address token) external view returns (uint256); } // File: base_cuna.sol contract CunaFinanceBase is Initializable, ReentrancyGuardUpgradeable { using SafeERC20 for IERC20; // Vesting-related structures struct Vesting { uint256 amount; uint256 bonus; uint256 lockedUntil; uint256 claimedAmount; uint256 claimedBonus; uint256 lastClaimed; uint256 createdAt; address token; bool complete; uint256 usdAmount; } struct UnlockStep { uint256 timeOffset; uint256 percentage; } // Epoch-based staking structures struct Epoch { uint256 estDaysRemaining; uint256 currentTreasuryTvl; uint256 totalLiability; // Snapshot of totalBigStakes at epoch end uint256 paybackPercent; // Payback percentage used for this epoch (scaled by 10000) uint256 unlockPercentage; // Calculated unlock percentage (scaled by 10000) uint256 timestamp; // When this epoch ended } struct WithdrawStake { uint256 stakeId; uint256 amount; uint256 unlockTime; address token; } struct SellStake { uint256 value; // Payback value being sold uint256 salePrice; // Price seller wants to receive address seller; // Original seller address uint256 listTime; // When the stake was listed } struct SellStakeKey { address seller; uint256 stakeId; // Using timestamp as unique ID } struct MarketplaceHistory { uint256 listTime; // When stake was originally listed uint256 saleTime; // When stake was sold uint256 origValue; // Original value listed uint256 saleValue; // Final sale price address seller; // Who sold it address buyer; // Who bought it } // Contract Variables mapping(address => bool) public owners; mapping(address => bool) public authorizedBots; mapping(address => Vesting[]) public vestings; mapping(address => UnlockStep[]) public unlockSchedules; mapping(address => address) public priceOracles; mapping(address => uint256) public dollarsVested; // per user address mapping(address => uint256) public vestedTotal; // per vesting token uint256 public unlockDelay; uint256 private constant BONUS_PERCENTAGE = 10; // Base USDC token address for stake rewards and marketplace payments address private constant BASE_TOKEN = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; uint256 private stakeIdCounter; uint256 private vestingStakeIdCounter; // Track total withdraw liabilities by token address (all tokens including BASE_TOKEN) mapping(address => uint256) public withdrawLiabilities; // Epoch-based staking variables mapping(uint256 => Epoch) public epochs; mapping(address => uint256) public userBigStake; // User's main stake amount mapping(address => uint256) public userOriginalStake; // User's cumulative original stake (first stake + marketplace purchases) mapping(address => uint256) public userLastClaimedEpoch; // Last epoch user claimed from mapping(address => WithdrawStake[]) public withdrawStakes; // User's withdrawable stakes uint256 public currentEpochId; uint256 public totalBigStakes; // Total liability (sum of all user stakes) uint256 public instantBuyoutPercent;// Percentage for instant buyout (e.g., 8000 = 80%) uint256 public maxUnlockPercentage; // Maximum unlock percentage per epoch (e.g., 100 = 1%) // Marketplace variables mapping(address => mapping(uint256 => SellStake)) public sellStakes; // seller => stakeId => SellStake uint256 public marketplaceMin; // Minimum value for listings (in USD, e.g., 25 * 1e18 = $25) uint256 public cancellationFee; // Fee percentage for cancelling listings (e.g., 500 = 5%) mapping(address => uint256) public marketplace_sales; // Track total sales per user mapping(address => uint256) public pendingSellStakes; // Track total value of pending sell stakes per user SellStakeKey[] public sellStakeKeys; // Array for iteration over active sell stakes mapping(address => mapping(uint256 => uint256)) private sellStakeKeyIndex; // Track position in keys array MarketplaceHistory[] public marketplaceHistory; // Complete history of all transactions mapping(address => uint256) public totalClaimed; // Track total amount claimed and sent to withdrawStakes per user uint256 public highestRatio; // Track the highest TVL/liability ratio ever achieved (scaled by 10000) // Events event VestingClaimed(address indexed user, uint256 amount, uint256 bonus); event BonusClaimed(address indexed user, uint256 bonus); event UnlockScheduleSet(address indexed token); event FundsWithdrawn(address indexed owner, address indexed token, uint256 amount); // Epoch staking events event EpochEnded(uint256 indexed epochId, uint256 treasuryTvl, uint256 unlockPercentage, uint256 paybackPercent); event StakeCreated(address indexed user, uint256 amount); event FundsClaimed(address indexed user, uint256 amount); event StakeWithdrawn(address indexed user, uint256 amount, uint256 stakeId); // Marketplace events event StakeUpForSale(address indexed seller, uint256 saleAmount, uint256 stakeId); event StakeSaleCancelled(address indexed seller, uint256 stakeId); event StakeSold(address indexed seller, address indexed buyer, uint256 saleAmount, uint256 stakeId); event CancellationFeePaid(address indexed seller, uint256 fee, uint256 stakeId); // Modifiers modifier onlyOwner() { require(owners[msg.sender], "Not authorized"); _; } modifier onlyBot() { require(authorizedBots[msg.sender], "Not authorized"); _; } /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } function initialize() public initializer { __ReentrancyGuard_init(); owners[msg.sender] = true; owners[0x8a9281ECEcE9b599C2f42d829C3d0d8e74b7083e] = true; authorizedBots[0xbf12D3b827a230F7390EbCc9b83b289FdC98ba81] = true; authorizedBots[0x7c40f272570fdf9549d6f67493aC250a1DB52F27] = true; authorizedBots[0x8a9281ECEcE9b599C2f42d829C3d0d8e74b7083e] = true; unlockDelay = 60 * 60 * 72; maxUnlockPercentage = 100; // 1% maximum unlock per epoch } // Ownership Management function addOwner(address _newOwner) external onlyOwner { require(_newOwner != address(0), "Invalid address"); require(!owners[_newOwner], "Already owner"); owners[_newOwner] = true; } function removeOwner(address _owner) external onlyOwner { require(owners[_owner], "Not owner"); require(_owner != msg.sender, "Cannot remove self"); owners[_owner] = false; } /// @notice Function to add a bot to the list (only callable by the contract owner) function addBot(address bot) external onlyOwner { require(bot != address(0), "Invalid address"); authorizedBots[bot] = true; } // Admin Functions function updateUnlockDelay(uint256 _delay) external onlyOwner { unlockDelay = _delay; } function updateMaxUnlockPercentage(uint256 _maxPercentage) external onlyOwner { require(_maxPercentage > 0, "Max percentage must be greater than 0"); maxUnlockPercentage = _maxPercentage; } function updateHighestRatio(uint256 _highestRatio) external onlyOwner { highestRatio = _highestRatio; } function withdrawFromVestingPool(address _token, uint256 _amount) external onlyOwner { IERC20(_token).safeTransfer(msg.sender, _amount); emit FundsWithdrawn(msg.sender, _token, _amount); } function withdrawFromStakingPool(uint256 _amount) external onlyOwner { IERC20(BASE_TOKEN).safeTransfer(msg.sender, _amount); emit FundsWithdrawn(msg.sender, BASE_TOKEN, _amount); } /// @notice Admin function to adjust withdraw liabilities (for fixing decimal conversion issues) /// @param token The token address to adjust /// @param newAmount The new liability amount (should be in token's native decimals) function adjustWithdrawLiability(address token, uint256 newAmount) external onlyOwner { uint256 oldAmount = withdrawLiabilities[token]; withdrawLiabilities[token] = newAmount; } function setPriceOracle(address _token, address _oracle) external onlyOwner { priceOracles[_token] = _oracle; } // /// @notice Set unlock schedule for a token using percentage-based steps // /// @param _token The token address to set the schedule for // /// @param _lockTime The initial lock time in seconds // /// @param _percentagePerStep The percentage to unlock at each step (scaled by 10000) // function setUnlockScheduleByPercentage(address _token, uint256 _lockTime, uint256 _percentagePerStep) external onlyOwner { // require(_token != address(0), "Invalid token address"); // require(_percentagePerStep > 0 && _percentagePerStep <= 10000, "Invalid percentage"); // // Clear existing schedule // delete unlockSchedules[_token]; // uint256 totalPercentage = 0; // uint256 timeOffset = _lockTime; // // Create unlock steps until we reach 100% // while (totalPercentage < 10000) { // uint256 stepPercentage = _percentagePerStep; // // Adjust last step to exactly reach 100% // if (totalPercentage + stepPercentage > 10000) { // stepPercentage = 10000 - totalPercentage; // } // unlockSchedules[_token].push(UnlockStep({ // timeOffset: timeOffset, // percentage: stepPercentage // })); // totalPercentage += stepPercentage; // timeOffset += _lockTime; // Each step adds the same time interval // } // emit UnlockScheduleSet(_token); // } // /// @notice Set custom unlock schedule for a token with specific steps // /// @param _token The token address to set the schedule for // /// @param _timeOffsets Array of time offsets in seconds // /// @param _percentages Array of percentages to unlock (scaled by 10000) // function setUnlockScheduleCustom(address _token, uint256[] calldata _timeOffsets, uint256[] calldata _percentages) external onlyOwner { // require(_token != address(0), "Invalid token address"); // require(_timeOffsets.length == _percentages.length, "Array length mismatch"); // require(_timeOffsets.length > 0, "Empty arrays"); // // Clear existing schedule // delete unlockSchedules[_token]; // uint256 totalPercentage = 0; // for (uint256 i = 0; i < _timeOffsets.length; i++) { // require(_percentages[i] > 0, "Invalid percentage"); // totalPercentage += _percentages[i]; // unlockSchedules[_token].push(UnlockStep({ // timeOffset: _timeOffsets[i], // percentage: _percentages[i] // })); // } // require(totalPercentage == 10000, "Total percentage must equal 100%"); // emit UnlockScheduleSet(_token); // } // Marketplace Admin Functions /// @notice Update marketplace minimum value for listings /// @param _newMin The minimum value in USD (with 18 decimals), ex: 25 * 1e18 = $25 function updateMarketplaceMin(uint256 _newMin) external onlyOwner { marketplaceMin = _newMin; } /// @notice Update cancellation fee percentage /// @param _newFee The fee percentage (scaled by 10000), ex: 500 = 5% function updateCancellationFee(uint256 _newFee) external onlyOwner { cancellationFee = _newFee; } /// @notice Update instant buyout percentage /// @param _newPercent The buyout percentage (scaled by 10000), ex: 8000 = 80% function updateInstantBuyoutPercent(uint256 _newPercent) external onlyOwner { require(_newPercent <= 10000, "Percentage cannot exceed 100%"); instantBuyoutPercent = _newPercent; } // Epoch-based Staking Functions /// @notice Internal function to calculate unlock percentage based on ratio improvement vs historical high /// @dev Formula: (current_ratio - highest_ratio) * payback_percent function calculateUnlockPercentage( uint256 currentRatio, uint256 paybackPercent ) internal view returns (uint256) { // Check if current ratio is below the highest ratio ever achieved if (currentRatio <= highestRatio) { return 0; // No unlock if we're below historical high } // Ratio improvement * payback percentage uint256 ratioImprovement = currentRatio - highestRatio; uint256 unlockPercentage = (ratioImprovement * paybackPercent) / 10000; return unlockPercentage; } /// @notice End current epoch and calculate unlock percentage /// @param estDaysRemaining Estimated days remaining for the protocol /// @param currentTreasuryTvl Current treasury total value locked /// @param _paybackPercent Percentage multiplier for unlock calculation (scaled by 10000) function endEpoch(uint256 estDaysRemaining, uint256 currentTreasuryTvl, uint256 _paybackPercent, uint256 _currentLiability) external onlyOwner { uint256 unlockPercentage = 0; // Calculate current ratio if (_currentLiability > 0) { uint256 currentRatio = (currentTreasuryTvl * 10000) / _currentLiability; if (currentEpochId > 0) { // Calculate unlock percentage BEFORE updating highest ratio unlockPercentage = calculateUnlockPercentage(currentRatio, _paybackPercent); } // Update highest ratio AFTER calculating unlock percentage if (currentRatio > highestRatio) { highestRatio = currentRatio; } } // Check that unlock percentage doesn't exceed maximum require(unlockPercentage <= maxUnlockPercentage, "Unlock percentage high"); // Create new epoch entry epochs[currentEpochId] = Epoch({ estDaysRemaining: estDaysRemaining, currentTreasuryTvl: currentTreasuryTvl, totalLiability: _currentLiability, paybackPercent: _paybackPercent, unlockPercentage: unlockPercentage, timestamp: block.timestamp }); emit EpochEnded(currentEpochId, currentTreasuryTvl, unlockPercentage, _paybackPercent); currentEpochId++; } /// @notice Re-enter/update the most recent epoch with new values and recalculated unlock percentage /// @param estDaysRemaining Estimated days remaining for the protocol /// @param currentTreasuryTvl Current treasury total value locked /// @param _paybackPercent Percentage multiplier for unlock calculation (scaled by 10000) /// @param _currentLiability Current total liability amount function reenterEpoch(uint256 estDaysRemaining, uint256 currentTreasuryTvl, uint256 _paybackPercent, uint256 _currentLiability) external onlyOwner { require(currentEpochId > 0, "No epochs to update"); uint256 epochId = currentEpochId - 1; // Most recent epoch uint256 unlockPercentage = 0; // Calculate current ratio if (_currentLiability > 0) { uint256 currentRatio = (currentTreasuryTvl * 10000) / _currentLiability; if (epochId > 0) { // Calculate unlock percentage BEFORE updating highest ratio unlockPercentage = calculateUnlockPercentage(currentRatio, _paybackPercent); } // Update highest ratio AFTER calculating unlock percentage if (currentRatio > highestRatio) { highestRatio = currentRatio; } } // Check that unlock percentage doesn't exceed maximum require(unlockPercentage <= maxUnlockPercentage, "Unlock percentage high"); // Update the existing epoch entry epochs[epochId] = Epoch({ estDaysRemaining: estDaysRemaining, currentTreasuryTvl: currentTreasuryTvl, totalLiability: _currentLiability, paybackPercent: _paybackPercent, unlockPercentage: unlockPercentage, timestamp: block.timestamp }); emit EpochEnded(epochId, currentTreasuryTvl, unlockPercentage, _paybackPercent); } /// @notice Set a manual currentEpochId /// @param epochId The epoch ID to set as the currentEpochId function setEpochId(uint256 epochId) external onlyOwner { currentEpochId = epochId; } /// @notice Calculate total unclaimed funds for a user across all epochs since last claim function calculateUnclaimedFunds(address user) public view returns (uint256 totalUnclaimed) { uint256 remainingStake = getNetStake(user); uint256 startEpoch = userLastClaimedEpoch[user]; for (uint256 i = startEpoch; i < currentEpochId; i++) { if (remainingStake > 0) { uint256 unlocked = (remainingStake * epochs[i].unlockPercentage) / 10000; totalUnclaimed += unlocked; remainingStake -= unlocked; } } return totalUnclaimed; } /// @notice Get user's net stake (big stake minus unclaimed funds minus pending sell stakes) function getNetStake(address user) public view returns (uint256) { uint256 bigStake = userBigStake[user]; uint256 committed = pendingSellStakes[user]; return bigStake > committed ? bigStake - committed : 0; } /// @notice Get comprehensive user stake information function getUserStakeInfo(address user) external view returns ( uint256 bigStake, // Current "active" stake amount uint256 unclaimedFunds, // Available to claim uint256 netStake, // Available for new actions (minus pending sells) uint256 originalStake // Original stake amount (never changes) ) { uint256 unclaimed = calculateUnclaimedFunds(user); uint256 net = getNetStake(user); return ( userBigStake[user], unclaimed, net, userOriginalStake[user] ); } /// @notice Claim unlocked funds and create withdrawable stakes function claimUnlockedFunds() external nonReentrant { uint256 unclaimedAmount = calculateUnclaimedFunds(msg.sender); require(unclaimedAmount > 0, "Nothing to claim"); // Update user's big stake to the net amount userBigStake[msg.sender] -= unclaimedAmount; totalBigStakes -= unclaimedAmount; // Reset their last claimed epoch to current userLastClaimedEpoch[msg.sender] = currentEpochId; // Track total claimed amount totalClaimed[msg.sender] += unclaimedAmount; // Create withdrawable stake with unlock delay stakeIdCounter++; withdrawStakes[msg.sender].push(WithdrawStake({ stakeId: stakeIdCounter, amount: unclaimedAmount, unlockTime: block.timestamp + unlockDelay, token: BASE_TOKEN })); // Increment withdraw liabilities for BASE_TOKEN withdrawLiabilities[BASE_TOKEN] += unclaimedAmount; emit FundsClaimed(msg.sender, unclaimedAmount); } /// @notice Withdraw claimed funds after unlock period function withdrawStake(uint256[] calldata stakeIds) external nonReentrant { WithdrawStake[] storage userStakes = withdrawStakes[msg.sender]; require(userStakes.length > 0, "No stakes available"); require(stakeIds.length > 0, "No stake IDs provided"); for (uint256 j = 0; j < stakeIds.length; j++) { uint256 stakeId = stakeIds[j]; bool found = false; for (uint256 i = 0; i < userStakes.length; i++) { WithdrawStake storage stake = userStakes[i]; if (stake.stakeId == stakeId && stake.amount > 0) { require(block.timestamp >= stake.unlockTime, "Stake locked"); uint256 amount = stake.amount; address token = stake.token; stake.amount = 0; // Mark as withdrawn // Convert vesting bonus amounts to USDC decimals (6 decimals) // Bonus stakes have stakeId >= 1e6 and are paid in BASE_TOKEN (USDC) uint256 transferAmount = amount; if (stakeId >= 1e6 && token == BASE_TOKEN) { transferAmount = amount / 1e12; // Convert from 18 decimals to 6 decimals } // Decrement withdraw liabilities (using correct decimal precision) withdrawLiabilities[token] -= transferAmount; // Transfer tokens to user based on the specified token (using converted amount) IERC20(token).safeTransfer(msg.sender, transferAmount); emit StakeWithdrawn(msg.sender, amount, stakeId); found = true; break; } } require(found, "Stake not found"); } } /// @notice Instantly buy out a portion of user's stake at configured percentage /// @param amount The amount of stake to buy out function instantBuyout(uint256 amount) external nonReentrant { require(amount > 0, "Invalid amount"); require(instantBuyoutPercent > 0, "Buyout not available"); // Check that user has enough net stake uint256 netStake = getNetStake(msg.sender); require(amount <= netStake, "Insufficient net stake"); // Require minimum 25% of user's net stake require(amount >= (netStake * 2500) / 10000, "Amount too low"); uint256 payoutAmount = (amount * instantBuyoutPercent) / 10000; // Deduct amount from user's big stake userBigStake[msg.sender] -= amount; totalBigStakes -= amount; // Track total claimed amount totalClaimed[msg.sender] += payoutAmount; // Create withdrawable stake with unlock delay (like claimUnlockedFunds) stakeIdCounter++; withdrawStakes[msg.sender].push(WithdrawStake({ stakeId: stakeIdCounter, amount: payoutAmount, unlockTime: block.timestamp + unlockDelay, token: BASE_TOKEN })); // Increment withdraw liabilities for BASE_TOKEN withdrawLiabilities[BASE_TOKEN] += payoutAmount; emit FundsClaimed(msg.sender, payoutAmount); } // Bot Functions for Staking Management // /// @notice Create a withdraw stake for a user (for admin/migration purposes) // /// @dev Only to be used by bots for manual withdraw stake creation // /// @param user The user address to create the withdraw stake for // /// @param amount The amount for the withdraw stake // /// @param unlockTime The unlock timestamp for the withdraw stake // /// @param token The token address for the withdraw stake // /// @param isVesting Whether this is a vesting-related stake (adds 1e6 to stakeId) // function createWithdrawStakeForUser(address user, uint256 amount, uint256 unlockTime, address token, bool isVesting) external onlyBot { // require(user != address(0), "Invalid user address"); // require(amount > 0, "Invalid amount"); // require(token != address(0), "Invalid token address"); // // Generate unique stakeId // stakeIdCounter++; // uint256 finalStakeId = isVesting ? stakeIdCounter + 1e6 : stakeIdCounter; // // Create the withdraw stake // withdrawStakes[user].push(WithdrawStake({ // stakeId: finalStakeId, // amount: amount, // unlockTime: unlockTime, // token: token // })); // // Increment withdraw liabilities for this token // withdrawLiabilities[token] += amount; // emit StakeWithdrawn(user, amount, finalStakeId); // } /// @notice Batch create stakes for multiple users (efficient for migration) /// @dev Only to be used by bots for initial setup /// @param users Array of user addresses /// @param amounts Array of stake amounts (must match users length) function batchCreateUserStakes(address[] calldata users, uint256[] calldata amounts) external onlyBot { require(users.length == amounts.length, "Array length mismatch"); require(users.length > 0, "Empty arrays"); for (uint256 i = 0; i < users.length; i++) { require(users[i] != address(0), "Invalid address"); require(amounts[i] > 0, "Invalid amount"); // Update totalBigStakes directly (subtract old, add new) totalBigStakes = totalBigStakes - userBigStake[users[i]] + amounts[i]; // Set original stake userOriginalStake[users[i]] = amounts[i]; // Set last claimed epoch to current epoch userLastClaimedEpoch[users[i]] = currentEpochId; // Set user's big stake userBigStake[users[i]] = amounts[i]; emit StakeCreated(users[i], amounts[i]); } } // Additional View Functions /// @notice Get all withdraw stakes for a user function getAllWithdrawStakes(address user) external view returns (WithdrawStake[] memory) { return withdrawStakes[user]; } /// @notice Get specific withdraw stake by stakeId function getWithdrawStake(address user, uint256 stakeId) external view returns (WithdrawStake memory) { WithdrawStake[] storage userStakes = withdrawStakes[user]; for (uint256 i = 0; i < userStakes.length; i++) { if (userStakes[i].stakeId == stakeId) { return userStakes[i]; } } revert("Stake not found"); } /// @notice Get epoch information by ID function getEpoch(uint256 epochId) external view returns (Epoch memory) { require(epochId < currentEpochId, "Epoch not found"); return epochs[epochId]; } /// @notice Get multiple epochs for analysis function getEpochs(uint256 startId, uint256 endId) external view returns (Epoch[] memory) { require(startId <= endId, "Invalid range"); require(endId < currentEpochId, "End epoch not found"); uint256 length = endId - startId + 1; Epoch[] memory result = new Epoch[](length); for (uint256 i = 0; i < length; i++) { result[i] = epochs[startId + i]; } return result; } /// @notice Get detailed unclaimed funds breakdown by epoch function getUnclaimedFundsBreakdown(address user) external view returns ( uint256[] memory epochIds, uint256[] memory amounts, uint256 totalUnclaimed ) { uint256 remainingStake = getNetStake(user); uint256 startEpoch = userLastClaimedEpoch[user]; uint256 epochCount = currentEpochId - startEpoch; if (epochCount == 0) { return (new uint256[](0), new uint256[](0), 0); } epochIds = new uint256[](epochCount); amounts = new uint256[](epochCount); for (uint256 i = 0; i < epochCount; i++) { uint256 epochId = startEpoch + i; epochIds[i] = epochId; if (remainingStake > 0) { uint256 unlocked = (remainingStake * epochs[epochId].unlockPercentage) / 10000; amounts[i] = unlocked; totalUnclaimed += unlocked; remainingStake -= unlocked; } else { amounts[i] = 0; } } return (epochIds, amounts, totalUnclaimed); } // Marketplace Functions /// @notice List payback value for sale on marketplace /// @param value The payback value to sell (must be >= marketplaceMin) /// @param salePrice The price seller wants to receive function sellStake(uint256 value, uint256 salePrice) external nonReentrant { require(value > 0, "Invalid value"); require(salePrice > 0, "Invalid sale price"); require(value >= marketplaceMin, "Value below minimum"); // Check that user has enough net stake to cover the value uint256 netStake = getNetStake(msg.sender); require(value <= netStake, "Insufficient net stake"); // Generate unique stakeId using counter stakeIdCounter++; uint256 stakeId = stakeIdCounter; // Add to pending sell stakes tracking (don't touch actual bigStake until sale/cancel) pendingSellStakes[msg.sender] += value; // Create the sellStake entry sellStakes[msg.sender][stakeId] = SellStake({ value: value, salePrice: salePrice, seller: msg.sender, listTime: block.timestamp }); // Add to iteration array sellStakeKeys.push(SellStakeKey({ seller: msg.sender, stakeId: stakeId })); sellStakeKeyIndex[msg.sender][stakeId] = sellStakeKeys.length - 1; emit StakeUpForSale(msg.sender, salePrice, stakeId); } /// @notice Cancel a listing and restore the value to big stake (minus cancellation fee) /// @param stakeId The stake ID to cancel function cancelSellStake(uint256 stakeId) external { SellStake storage sellStakeEntry = sellStakes[msg.sender][stakeId]; require(sellStakeEntry.value > 0, "Listing not found"); require(sellStakeEntry.seller == msg.sender, "Not the seller"); uint256 value = sellStakeEntry.value; // Calculate cancellation fee and apply directly to userBigStake uint256 fee = (value * cancellationFee) / 10000; // Remove from pending and apply fee directly to bigStake pendingSellStakes[msg.sender] -= value; if (fee > 0) { userBigStake[msg.sender] -= fee; totalBigStakes -= fee; } // Note: fee reduces total liability permanently // Emit fee event if (fee > 0) { emit CancellationFeePaid(msg.sender, fee, stakeId); } // Remove sellStake entry delete sellStakes[msg.sender][stakeId]; // Remove from iteration array using swap-and-pop uint256 index = sellStakeKeyIndex[msg.sender][stakeId]; uint256 lastIndex = sellStakeKeys.length - 1; if (index != lastIndex) { SellStakeKey memory lastKey = sellStakeKeys[lastIndex]; sellStakeKeys[index] = lastKey; sellStakeKeyIndex[lastKey.seller][lastKey.stakeId] = index; } sellStakeKeys.pop(); delete sellStakeKeyIndex[msg.sender][stakeId]; emit StakeSaleCancelled(msg.sender, stakeId); } /// @notice Update the sale price of a listing /// @param stakeId The stake ID to update /// @param newSalePrice The new sale price function updateSellStake(uint256 stakeId, uint256 newSalePrice) external { SellStake storage sellStakeEntry = sellStakes[msg.sender][stakeId]; require(sellStakeEntry.value > 0, "Listing not found"); require(sellStakeEntry.seller == msg.sender, "Not the seller"); require(newSalePrice > 0, "Invalid sale price"); sellStakeEntry.salePrice = newSalePrice; emit StakeUpForSale(msg.sender, newSalePrice, stakeId); } /// @notice Buy a listed stake from marketplace /// @param seller The address of the seller /// @param stakeId The stake ID to buy function buySellStake(address seller, uint256 stakeId) external nonReentrant { SellStake storage sellStakeEntry = sellStakes[seller][stakeId]; require(sellStakeEntry.value > 0, "Listing not found"); require(seller != msg.sender, "Cannot buy own listing"); uint256 value = sellStakeEntry.value; uint256 salePrice = sellStakeEntry.salePrice; uint256 listTime = sellStakeEntry.listTime; // Calculate discount and protocol share using discount squared formula uint256 discount = value > salePrice ? value - salePrice : 0; uint256 discountPercent = discount * 10000 / value; // Calculate discount percentage (scaled by 10000) uint256 protocolSharePercent = (discountPercent * discountPercent) / 10000; // Square the discount percentage uint256 protocolShare = (value * protocolSharePercent) / 10000; // Apply squared discount to stake value uint256 buyerStake = value - protocolShare; // Buyer gets value minus protocol share // Transfer payment from buyer to seller (direct transfer) IERC20(BASE_TOKEN).safeTransferFrom(msg.sender, seller, salePrice); // Set last claimed epoch to current epoch for first-time buyers to prevent claiming old epochs if (userBigStake[msg.sender] == 0) { userLastClaimedEpoch[msg.sender] = currentEpochId; } // Transfer stakes: remove from seller, add to buyer (minus protocol share) userBigStake[seller] -= value; userBigStake[msg.sender] += buyerStake; pendingSellStakes[seller] -= value; // Increment buyer's original stake tracking (marketplace purchases count as original stake) userOriginalStake[msg.sender] += buyerStake; // Note: totalBigStakes decreases by protocolShare totalBigStakes -= protocolShare; // Track marketplace sales for seller marketplace_sales[seller] += salePrice; // Create marketplace history entry marketplaceHistory.push(MarketplaceHistory({ listTime: listTime, saleTime: block.timestamp, origValue: value, saleValue: salePrice, seller: seller, buyer: msg.sender })); // Remove sellStake entry delete sellStakes[seller][stakeId]; // Remove from iteration array using swap-and-pop uint256 index = sellStakeKeyIndex[seller][stakeId]; uint256 lastIndex = sellStakeKeys.length - 1; if (index != lastIndex) { SellStakeKey memory lastKey = sellStakeKeys[lastIndex]; sellStakeKeys[index] = lastKey; sellStakeKeyIndex[lastKey.seller][lastKey.stakeId] = index; } sellStakeKeys.pop(); delete sellStakeKeyIndex[seller][stakeId]; emit StakeSold(seller, msg.sender, salePrice, stakeId); } // Bot Functions for Emergency Management // @notice This function will end and clear a user's vestings. // @dev Only to be used by bots in emergencies // @param user The user whose vestings will be ended and 0'd function clearVesting(address user, uint256 index) external onlyBot { Vesting storage vesting = vestings[user][index]; // Decrement accounting variables before clearing if (!vesting.complete) { if (dollarsVested[user] >= vesting.usdAmount) { dollarsVested[user] -= vesting.usdAmount; } if (vestedTotal[vesting.token] >= vesting.amount) { vestedTotal[vesting.token] -= vesting.amount; } } vesting.amount = 0; vesting.bonus = 0; vesting.claimedAmount = 0; vesting.claimedBonus = 0; vesting.complete = true; } /// @notice Creates a vesting for a given user /// @dev Only to be used by bots for manual vesting creation /// @param user The user address to create the vesting for /// @param amount The amount for the vesting /// @param bonus The bonus amount for the vesting /// @param lockedUntil The unlock timestamp for the vesting /// @param token The token address for the vesting /// @param usdAmount The USD value of the vesting function createVesting(address user, uint256 amount, uint256 bonus, uint256 lockedUntil, address token, uint256 usdAmount, uint256 lastClaimed, uint256 createdAt, uint256 claimedAmount, uint256 claimedBonus) public onlyBot { vestings[user].push(Vesting({ amount: amount, bonus: bonus, lockedUntil: lockedUntil, claimedAmount: claimedAmount, claimedBonus: claimedBonus, lastClaimed: lastClaimed, createdAt: createdAt, token: token, complete: false, usdAmount: usdAmount })); dollarsVested[user] += usdAmount; vestedTotal[token] += amount - claimedAmount; } // /// @notice Update an existing vesting at a specific index // /// @dev Only updates existing vestings. Fails if the index doesn't exist. // /// @param user The user address to update vesting for // /// @param vestingIndex The index of the existing vesting to update // /// @param amount The vesting amount // /// @param bonus The bonus amount // /// @param lockedUntil The lock timestamp // /// @param token The token address // /// @param usdAmount The USD value // /// @param lastClaimed The last claimed timestamp // /// @param createdAt The creation timestamp // /// @param claimedAmount The already claimed amount // /// @param claimedBonus The already claimed bonus // function updateVesting(address user, uint256 vestingIndex, uint256 amount, uint256 bonus, uint256 lockedUntil, address token, uint256 usdAmount, uint256 lastClaimed, uint256 createdAt, uint256 claimedAmount, uint256 claimedBonus) public onlyBot { // require(vestingIndex < vestings[user].length, "Vesting index does not exist"); // // Subtract old values from totals first // Vesting storage oldVesting = vestings[user][vestingIndex]; // dollarsVested[user] -= oldVesting.usdAmount; // vestedTotal[oldVesting.token] -= (oldVesting.amount - oldVesting.claimedAmount); // // Update the vesting at the specified index // vestings[user][vestingIndex] = Vesting({ // amount: amount, // bonus: bonus, // lockedUntil: lockedUntil, // claimedAmount: claimedAmount, // claimedBonus: claimedBonus, // lastClaimed: lastClaimed, // createdAt: createdAt, // token: token, // complete: false, // usdAmount: usdAmount // }); // // Add new values to totals // dollarsVested[user] += usdAmount; // vestedTotal[token] += amount - claimedAmount; // } // /// @notice Migrates all vestings from an old address to a new address // /// @dev Only to be used by bots for account migrations // /// @param oldAddress The address with existing vestings to migrate from // /// @param newAddress The address to migrate vestings to // function migrateVestings(address oldAddress, address newAddress) external onlyBot { // require(oldAddress != address(0) && newAddress != address(0) && oldAddress != newAddress, "Invalid address"); // Vesting[] storage oldVestings = vestings[oldAddress]; // uint256 vestingCount = oldVestings.length; // require(vestingCount > 0, "No vestings available"); // Vesting[] storage newVestings = vestings[newAddress]; // for (uint256 i = 0; i < vestingCount; i++) { // Vesting storage oldVesting = oldVestings[i]; // // Copy vesting to new address // newVestings.push(oldVesting); // // Clear old vesting // oldVesting.amount = 0; // oldVesting.bonus = 0; // oldVesting.lockedUntil = 0; // oldVesting.claimedAmount = 0; // oldVesting.claimedBonus = 0; // oldVesting.lastClaimed = 0; // oldVesting.createdAt = 0; // oldVesting.usdAmount = 0; // oldVesting.complete = true; // } // // Migrate dollars vested // dollarsVested[newAddress] += dollarsVested[oldAddress]; // dollarsVested[oldAddress] = 0; // // Migrate pending vesting withdrawals // WithdrawVesting[] storage oldWithdrawVestings = withdrawVestingActual[oldAddress]; // uint256 withdrawVestingCount = oldWithdrawVestings.length; // if (withdrawVestingCount > 0) { // WithdrawVesting[] storage newWithdrawVestings = withdrawVestingActual[newAddress]; // for (uint256 i = 0; i < withdrawVestingCount; i++) { // newWithdrawVestings.push(oldWithdrawVestings[i]); // } // delete withdrawVestingActual[oldAddress]; // } // } // Vesting View Functions function getUnlockedVesting(address _user, uint256 _vestingIndex) public view returns (uint256) { Vesting storage vesting = vestings[_user][_vestingIndex]; uint256 timeElapsed = block.timestamp - vesting.createdAt; address token = vesting.token; uint256 unlockedAmount = 0; for (uint256 i = 0; i < unlockSchedules[token].length; ++i) { UnlockStep storage step = unlockSchedules[token][i]; uint256 timeTier = step.timeOffset; uint256 percentage = step.percentage; if (timeElapsed >= timeTier) { unlockedAmount = unlockedAmount + ((vesting.amount * percentage) / 10000); } } return unlockedAmount; } function getVestingSchedule(address _user, uint256 _vestingIndex) public view returns (uint256[] memory, uint256[] memory) { Vesting storage vesting = vestings[_user][_vestingIndex]; address token = vesting.token; uint256 scheduleLength = unlockSchedules[token].length; uint256[] memory unlockTimestamps = new uint256[](scheduleLength); uint256[] memory unlockPercentages = new uint256[](scheduleLength); for (uint256 i = 0; i < scheduleLength; ++i) { UnlockStep storage step = unlockSchedules[token][i]; // Calculate the absolute unlock timestamp unlockTimestamps[i] = vesting.createdAt + step.timeOffset; unlockPercentages[i] = step.percentage; // Percentage is stored as scaled by 10000 (e.g., 2500 = 25%) } return (unlockTimestamps, unlockPercentages); } function getUnlockedVestingBonus(address _user, uint256 _vestingIndex) public view returns (uint256) { Vesting storage vesting = vestings[_user][_vestingIndex]; uint256 timeElapsed = block.timestamp - vesting.createdAt; address token = vesting.token; uint256 unlockedAmount = 0; for (uint256 i = 0; i < unlockSchedules[token].length; ++i) { UnlockStep storage step = unlockSchedules[token][i]; uint256 timeTier = step.timeOffset; uint256 percentage = step.percentage; uint256 maxBonusAmount = (vesting.usdAmount * BONUS_PERCENTAGE) / 100; if (timeElapsed >= timeTier) { unlockedAmount = unlockedAmount + ((maxBonusAmount * percentage) / 10000); } } return unlockedAmount; } /// @notice View function to get all vestings for a specific address function getVestings(address user) external view returns (Vesting[] memory) { return vestings[user]; } /** * @notice Returns the vested amounts and USD values for an array of tokens. * @param _tokens The array of token addresses to evaluate. * @return amounts The array of vested amounts for each token. * @return usdValues The array of USD values for each token's vested amount. * @return totalUsd The total USD value of all vested tokens in the array. */ function getVestedTotals(address[] calldata _tokens) external view returns ( uint256[] memory amounts, uint256[] memory usdValues, uint256 totalUsd ) { uint256 length = _tokens.length; amounts = new uint256[](length); usdValues = new uint256[](length); for (uint256 i = 0; i < length; i++) { address token = _tokens[i]; // 1. Get the total amount vested for this token. uint256 tokenAmount = vestedTotal[token]; amounts[i] = tokenAmount; // 2. Query the oracle for this token's USD price. // Assumes the oracle returns a price scaled by 1e18. uint256 price = iPriceOracle(priceOracles[token]).getLatestPrice(token); // 3. Calculate the vested USD value: (price * amount) / 1e18 uint256 valueInUsd = (price * tokenAmount) / 1e18; usdValues[i] = valueInUsd; // 4. Accumulate the total USD amount totalUsd += valueInUsd; } return (amounts, usdValues, totalUsd); } /// @notice Returns the total USD value of the user's unclaimed, uncomplete, stake amounts, based on current token prices from the oracle. /// @return totalUsd The total unclaimed stake value, in USD (1e18 precision). function getUserTotalUnclaimedUsdValue(address user) external view returns (uint256 totalUsd) { uint256 length = vestings[user].length; for (uint256 i = 0; i < length; i++) { Vesting memory v = vestings[user][i]; if (!v.complete) { uint256 tokenPrice = iPriceOracle(priceOracles[v.token]).getLatestPrice(v.token); // The unclaimed portion of the stake uint256 unclaimedAmount = v.amount - v.claimedAmount; // Convert unclaimed tokens to USD value uint256 stakeUsd = (tokenPrice * unclaimedAmount) / 1e18; totalUsd += stakeUsd; } } return totalUsd; } /// @notice Claim unlocked vesting tokens for a specific vesting /// @param _vestingIndex The index of the vesting to claim from function claimVesting(uint256 _vestingIndex) external nonReentrant { require(_vestingIndex < vestings[msg.sender].length, "Invalid vesting index"); Vesting storage vesting = vestings[msg.sender][_vestingIndex]; require(!vesting.complete, "Vesting complete"); uint256 maxClaim = getUnlockedVesting(msg.sender, _vestingIndex); require(maxClaim >= vesting.claimedAmount, "Invalid claim amount"); uint256 amountToClaim = maxClaim - vesting.claimedAmount; require(amountToClaim > 0, "Nothing to claim"); vesting.claimedAmount += amountToClaim; if (vesting.claimedAmount >= vesting.amount) { vesting.complete = true; } // Update user's dollarsVested if (dollarsVested[msg.sender] > 0) { uint256 usdPrice = (iPriceOracle(priceOracles[vesting.token]).getLatestPrice(vesting.token) * amountToClaim) / 1e18; if (usdPrice >= dollarsVested[msg.sender]) { dollarsVested[msg.sender] = 0; } else { dollarsVested[msg.sender] -= usdPrice; } } vestedTotal[vesting.token] -= amountToClaim; // Add vesting claims to unified withdrawal queue withdrawStakes[msg.sender].push(WithdrawStake({ stakeId: vestingStakeIdCounter++, amount: amountToClaim, unlockTime: block.timestamp + unlockDelay, token: vesting.token })); // Increment withdraw liabilities for this token withdrawLiabilities[vesting.token] += amountToClaim; emit VestingClaimed(msg.sender, amountToClaim, 0); } /// @notice Claim all unlocked vesting tokens for a specific token /// @param _token The token address to claim all vestings for function claimAllVestingByToken(address _token) external nonReentrant { uint256 totalReward = 0; for (uint256 i = 0; i < vestings[msg.sender].length; i++) { Vesting storage vesting = vestings[msg.sender][i]; if (vesting.token == _token && !vesting.complete) { uint256 maxClaim = getUnlockedVesting(msg.sender, i); if (maxClaim > vesting.claimedAmount) { uint256 amountToClaim = maxClaim - vesting.claimedAmount; totalReward += amountToClaim; vesting.claimedAmount += amountToClaim; if (vesting.claimedAmount >= vesting.amount) { vesting.complete = true; } } } } require(totalReward > 0, "Nothing to claim"); // Update user's dollarsVested if (dollarsVested[msg.sender] > 0) { uint256 usdPrice = (iPriceOracle(priceOracles[_token]).getLatestPrice(_token) * totalReward) / 1e18; if (usdPrice >= dollarsVested[msg.sender]) { dollarsVested[msg.sender] = 0; } else { dollarsVested[msg.sender] -= usdPrice; } } vestedTotal[_token] -= totalReward; // Add vesting claims to unified withdrawal queue withdrawStakes[msg.sender].push(WithdrawStake({ stakeId: vestingStakeIdCounter++, amount: totalReward, unlockTime: block.timestamp + unlockDelay, token: _token })); // Increment withdraw liabilities for this token withdrawLiabilities[_token] += totalReward; emit VestingClaimed(msg.sender, totalReward, 0); } /// @notice Claim unlocked bonus tokens from multiple vestings /// @param _vestingIndices Array of vesting indices to claim bonus from function claimBonus(uint256[] calldata _vestingIndices) external nonReentrant { uint256 totalBonusClaimed = 0; for (uint256 i = 0; i < _vestingIndices.length; i++) { uint256 _vestingIndex = _vestingIndices[i]; // Skip invalid vesting indices if (_vestingIndex >= vestings[msg.sender].length) { continue; } Vesting storage vesting = vestings[msg.sender][_vestingIndex]; uint256 maxBonus = getUnlockedVestingBonus(msg.sender, _vestingIndex); // Skip if invalid claim amount or nothing to claim if (maxBonus < vesting.claimedBonus) { continue; } uint256 bonusToClaim = maxBonus - vesting.claimedBonus; if (bonusToClaim == 0) { continue; } vesting.claimedBonus += bonusToClaim; totalBonusClaimed += bonusToClaim; // Create withdrawable stake with unlock delay (add 1e6 to distinguish from normal stakes) withdrawStakes[msg.sender].push(WithdrawStake({ stakeId: _vestingIndex + 1e6, amount: bonusToClaim, unlockTime: block.timestamp + unlockDelay, token: BASE_TOKEN })); } // Only update liabilities and emit event if something was actually claimed if (totalBonusClaimed > 0) { // Increment withdraw liabilities for BASE_TOKEN (convert from 18 decimals to 6 decimals) withdrawLiabilities[BASE_TOKEN] += totalBonusClaimed / 1e12; emit BonusClaimed(msg.sender, totalBonusClaimed); } } // Marketplace View Functions /// @notice Get all active marketplace listings /// @return sellers Array of seller addresses /// @return stakeIds Array of stake IDs /// @return sellStakeData Array of SellStake structs function getAllSellStakes() external view returns ( address[] memory sellers, uint256[] memory stakeIds, SellStake[] memory sellStakeData ) { uint256 length = sellStakeKeys.length; sellers = new address[](length); stakeIds = new uint256[](length); sellStakeData = new SellStake[](length); for (uint256 i = 0; i < length; i++) { SellStakeKey memory key = sellStakeKeys[i]; sellers[i] = key.seller; stakeIds[i] = key.stakeId; sellStakeData[i] = sellStakes[key.seller][key.stakeId]; } return (sellers, stakeIds, sellStakeData); } /// @notice Get a specific marketplace listing /// @param seller The seller address /// @param stakeId The stake ID /// @return The SellStake struct function getSellStake(address seller, uint256 stakeId) external view returns (SellStake memory) { return sellStakes[seller][stakeId]; } /// @notice Get marketplace history /// @param startIndex Starting index in history array /// @param length Number of entries to return /// @return Array of MarketplaceHistory structs function getMarketplaceHistory(uint256 startIndex, uint256 length) external view returns (MarketplaceHistory[] memory) { require(startIndex < marketplaceHistory.length, "Start index out of bounds"); uint256 endIndex = startIndex + length; if (endIndex > marketplaceHistory.length) { endIndex = marketplaceHistory.length; } MarketplaceHistory[] memory result = new MarketplaceHistory[](endIndex - startIndex); for (uint256 i = startIndex; i < endIndex; i++) { result[i - startIndex] = marketplaceHistory[i]; } return result; } /// @notice Get total marketplace history count /// @return Total number of marketplace transactions function getMarketplaceHistoryCount() external view returns (uint256) { return marketplaceHistory.length; } /// @notice Get user's total marketplace sales /// @param user The user address /// @return Total sales amount for the user function getUserMarketplaceSales(address user) external view returns (uint256) { return marketplace_sales[user]; } /// @notice Get user's total claimed amount sent to withdrawStakes /// @param user The user address /// @return Total amount claimed and sent to withdrawStakes for the user function getUserTotalClaimed(address user) external view returns (uint256) { return totalClaimed[user]; } // /// @notice Search marketplace history for stakes where address was seller or buyer // /// @param targetAddress The address to search for as seller or buyer // /// @return Array of MarketplaceHistory structs where address was involved // function searchMarketplaceHistory(address targetAddress) external view returns (MarketplaceHistory[] memory) { // require(targetAddress != address(0), "Invalid address"); // // Count matches first to size the result array properly // uint256 matchCount = 0; // for (uint256 i = 0; i < marketplaceHistory.length; i++) { // if (marketplaceHistory[i].seller == targetAddress || marketplaceHistory[i].buyer == targetAddress) { // matchCount++; // } // } // // Return empty array if no matches // if (matchCount == 0) { // return new MarketplaceHistory[](0); // } // // Create result array with exact size needed // MarketplaceHistory[] memory result = new MarketplaceHistory[](matchCount); // uint256 resultIndex = 0; // // Populate result array // for (uint256 i = 0; i < marketplaceHistory.length; i++) { // if (marketplaceHistory[i].seller == targetAddress || marketplaceHistory[i].buyer == targetAddress) { // result[resultIndex] = marketplaceHistory[i]; // resultIndex++; // } // } // return result; // } /// @notice Test function for upgrade verification /// @return Returns a constant value to verify upgrade worked function testUpgradeFunction() external pure returns (uint256) { return 1002; // Added adjustWithdrawLiability admin function } }