Files
cuna/contracts/CunaFinanceBsc.sol
2025-10-01 02:11:28 +02:00

1320 lines
54 KiB
Solidity

// 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: bsc_cuna.sol
contract CunaFinanceBsc 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;
// BSC USDT token address for stake rewards and marketplace payments
address private constant BSC_TOKEN = 0x55d398326f99059fF775485246999027B3197955;
uint256 private stakeIdCounter;
uint256 private vestingStakeIdCounter;
// Track total withdraw liabilities by token address (all tokens including BSC_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(BSC_TOKEN).safeTransfer(msg.sender, _amount);
emit FundsWithdrawn(msg.sender, BSC_TOKEN, _amount);
}
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 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: BSC_TOKEN
}));
// Increment withdraw liabilities for BSC_TOKEN
withdrawLiabilities[BSC_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
// Decrement withdraw liabilities for all tokens
withdrawLiabilities[token] -= amount;
// Transfer tokens to user based on the specified token
IERC20(token).safeTransfer(msg.sender, amount);
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: BSC_TOKEN
}));
// Increment withdraw liabilities for BSC_TOKEN
withdrawLiabilities[BSC_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(BSC_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) external onlyBot {
// for (uint256 i = 0; i < vestings[user].length; ++i) {
// Vesting storage vesting = vestings[user][i];
// // 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: BSC_TOKEN
}));
}
// Only update liabilities and emit event if something was actually claimed
if (totalBonusClaimed > 0) {
// Increment withdraw liabilities for BSC_TOKEN
withdrawLiabilities[BSC_TOKEN] += totalBonusClaimed;
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 999; // Different value from bsc_paca to distinguish contracts
}
}