diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..60556ca4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,128 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview +SubQuery Network Contracts - A comprehensive smart contract suite powering the decentralized SubQuery Network indexing infrastructure. Built with Solidity 0.8.15, Hardhat, and TypeScript. + +## Essential Commands + +### Development Workflow +```bash +yarn install # Install dependencies +yarn build # Build contracts + TypeScript SDK +yarn test # Run full test suite +yarn test:coverage # Run tests with coverage analysis +yarn lint # Lint all code (Solidity + TypeScript) +yarn format # Format all code (Prettier) +``` + +### Building Components +```bash +yarn build:contract # Compile Solidity contracts only +yarn build:ts # Build TypeScript SDK only +yarn build:abi # Generate ABI files +``` + +### Testing & Quality +```bash +yarn test:single # Run specific test file +yarn coverage # Generate test coverage report +yarn test:fuzz # Run Echidna fuzz testing +``` + +### Deployment & Verification +```bash +yarn deploy --testnet # Deploy to testnet (Polygon Mumbai + Sepolia) +yarn deploy --kepler # Deploy to Kepler network (staging) +yarn deploy --mainnet # Deploy to mainnet (Ethereum + Base) +yarn upgrade --testnet # Upgrade contracts with proxy pattern +yarn verify --testnet # Verify contracts on block explorers +``` + +### Documentation +```bash +yarn docs:generate # Generate contract documentation +yarn docs:build # Build VuePress documentation site +yarn docs:dev # Serve docs locally +``` + +## Architecture Overview + +### Multi-Chain Design +- **Layer 1 (Root)**: Ethereum mainnet/Sepolia - token contracts, governance +- **Layer 2 (Child)**: Polygon/Base - main protocol contracts +- **Bridge Integration**: L1↔L2 token transfers via standard bridges + +### Contract Organization +``` +/contracts/ +├── [Root contracts] - 35+ core contracts (Settings, Staking, IndexerRegistry, etc.) +├── /interfaces/ - 24 interface definitions +├── /l2/ - Layer 2 specific implementations +├── /root/ - Layer 1 (root chain) contracts +├── /utils/ - Utility and helper contracts +└── /archive/ - Legacy contract versions +``` + +### Key Contracts +- **Settings.sol** - Central configuration registry for all protocol parameters +- **Staking.sol** - Core delegation and staking mechanism +- **IndexerRegistry.sol** - Indexer registration and management +- **ProjectRegistry.sol** - Project registration system +- **RewardsDistributor.sol** - Automated reward distribution logic +- **ConsumerHost.sol** - Consumer interaction and payment layer +- **PlanManager.sol** - Service plan management + +### Upgrade Pattern +All main contracts use **OpenZeppelin's upgradeable proxy pattern**. When modifying contracts: +1. Ensure storage layout compatibility +2. Test upgrade scripts thoroughly +3. Use upgrade helpers in `/scripts/upgrade.ts` + +### SDK Structure +- **TypeScript SDK**: `/src/` - Auto-generated TypeChain bindings + wrapper SDK +- **Rust SDK**: `/rust/` - ethers-rs based SDK for Rust applications +- **Contract Types**: Auto-generated in `/src/typechain/` + +### Testing Framework +- **32 comprehensive test files** using Hardhat + Waffle + Chai +- **Helper utilities**: `/test/setup.ts`, `/test/helper.ts` for common test patterns +- **Mock contracts**: `/contracts/mocks/` for isolated testing +- **E2E tests**: Full workflow validation across contract interactions + +### Development Setup +- **Node Environment**: CommonJS with ES2020 target +- **Package Manager**: Yarn 3.6.4 (use `yarn` not `npm`) +- **TypeScript**: Strict mode enabled with comprehensive type checking +- **Pre-commit hooks**: Husky + lint-staged for code quality + +### Network Configuration +Networks are configured in `hardhat.config.ts`: +- **testnet**: Polygon Mumbai + Sepolia +- **kepler**: Polygon Mainnet (staging environment) +- **mainnet**: Ethereum + Base +- **local**: Hardhat network for development + +### Deployment Tracking +- **Published contracts**: `/publish/*.json` files track deployed addresses +- **Network configs**: `/scripts/config/` contains network-specific settings +- **Deployment verification**: Automated Etherscan/Polygonscan verification + +### Code Quality Standards +- **Solidity**: Uses Solhint with custom rules (`.solhint.json`) +- **TypeScript**: ESLint with TypeScript rules +- **Formatting**: Prettier for both Solidity and TypeScript +- **Coverage**: Aim for high test coverage, check with `yarn test:coverage` + +### Security Considerations +- **Access Control**: Most contracts use Ownable pattern with multi-sig governance +- **Proxy Safety**: Be careful with storage layout when upgrading contracts +- **Audit Integration**: Security audits stored in `/audits/` directory +- **Fuzz Testing**: Echidna configuration for property-based testing + +### Common Development Patterns +- **Contract Initialization**: Use `__contractName_init()` pattern for upgradeable contracts +- **Event Emission**: All state changes should emit appropriate events +- **Error Handling**: Use custom errors (introduced in Solidity 0.8.4) for gas efficiency +- **Interface Compliance**: Always implement corresponding interfaces from `/contracts/interfaces/` \ No newline at end of file diff --git a/contracts/DelegationPool.sol b/contracts/DelegationPool.sol new file mode 100644 index 00000000..e2f78dc7 --- /dev/null +++ b/contracts/DelegationPool.sol @@ -0,0 +1,836 @@ +// Copyright (C) 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.15; + +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; +import '@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol'; +import '@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol'; + +import './interfaces/ISettings.sol'; +import './interfaces/IStakingManager.sol'; +import './interfaces/IRewardsDistributor.sol'; +import './interfaces/IEraManager.sol'; +import './utils/MathUtil.sol'; +import './Constants.sol'; +import './Staking.sol'; + +/** + * @title DelegationPool + * @notice ## Overview + * A Delegation Pool contract that allows aggregation of SQT to be pooled together and used to delegate to indexers. + * Delegation should behave the same as if the user had delegated directly to an indexer. There is a manager (owner) of the pool + * that determines the delegation strategy to indexers. + * + * ## Details + * + * ### Delegation + * Users delegate their SQT to the pool and receive pool shares (ERC20 tokens) representing their stake in the pool. The pool manager can then + * allocate those funds to the indexers they choose to optimise rewards. + * + * ### Undelegation + * Users can then trigger undelegation which will burn their shares and + * start a withdrawal process that then allows the user to withdraw their SQT after the lock period which is the same duration as if they had + * directly delegated. + * + * ### Withdrawals + * After the lock period, users can withdraw their SQT and rewards from the pool, minus and pool and unbonding fees. + * + * ### Managers + * The pool manager is responsible for making sure the available assets in the pool are delegated to indexers to earn rewards. + * The manager can do all the same functionality as delegating directly to an indexer such as delegate, undelegate, redelegate and claim rewards. + * The manager can also trigger auto-compounding of rewards which claims rewards from all indexers as well as set a reward fee. + */ +contract DelegationPool is + Initializable, + OwnableUpgradeable, + ReentrancyGuardUpgradeable, + ERC20Upgradeable +{ + using SafeERC20 for IERC20; + using MathUtil for uint256; + + // -- Storage -- + + /// @notice Settings contract for getting other contract addresses + ISettings public settings; + + /// @notice Array of indexers that pool has delegated to + address[] public activeIndexers; + + /// @notice Mapping to track if indexer is in activeIndexers array + mapping(address => bool) public isActiveIndexer; + + /// @notice Mapping of user unbonding requests + mapping(address => UnbondRequest[]) public unbondingRequests; + + /// @notice Mapping to track total withdrawn unbond requests per user + mapping(address => uint256) public withdrawnLength; + + /// @notice Available SQT in the pool (not yet delegated) + uint256 public availableAssets; + + /// @notice Fee percentage in per-million (1000000 = 100%, 10000 = 1%) + uint256 public feePerMill; + + /// @notice Accumulated fees available for withdrawal by controller + uint256 public accumulatedFees; + + /// @notice Cached share price (assets per share) for current era, scaled by 1e18 + uint256 public currentEraSharePrice; + + /// @notice Last era when share price was updated + uint256 public lastPriceUpdateEra; + + /// @notice Total assets at last price update + uint256 public totalAssetsAtLastUpdate; + + /// @notice Total shares at last price update + uint256 public totalSharesAtLastUpdate; + + /// @notice Total amount of unbond fees expected to be lost when withdrawing from Staking + uint256 public expectedUnbondFees; + + /// @notice Total amount that needs to be undelegated from indexers (reserved for user withdrawals) + /// This tracks assets still counted in delegated amounts but reserved for withdrawals + uint256 public pendingUndelegationsForUsers; + + /// @notice Accumulated unbond fees collected from available asset withdrawals (to be sent to treasury) + uint256 public accumulatedUnbondFees; + + /// @notice Struct for unbonding requests + struct UnbondRequest { + uint256 amount; // Amount of SQT to be withdrawn + uint256 startTime; // When unbonding started + bool completed; // Whether withdrawal is completed + } + + // -- Events -- + + /// @notice Emitted when user delegates to the pool + event Delegated(address indexed user, uint256 amount, uint256 shares); + + /// @notice Emitted when user starts undelegation + event UndelegationStarted(address indexed user, uint256 shares, uint256 amount); + + /// @notice Emitted when user completes withdrawal + event Withdrawn(address indexed user, uint256 amount); + + /// @notice Emitted when manager delegates to an indexer + event ManagerDelegated(address indexed indexer, uint256 amount); + + /// @notice Emitted when manager undelegates from an indexer + event ManagerUndelegated(address indexed indexer, uint256 amount); + + /// @notice Emitted when manager redelegates between indexers + event ManagerRedelegated( + address indexed fromIndexer, + address indexed toIndexer, + uint256 amount + ); + + /// @notice Emitted when rewards are auto-compounded + event RewardsCompounded(uint256 totalRewards); + + /// @notice Emitted when fee percentage is updated + event FeeRateUpdated(uint256 newFeePerMill); + + /// @notice Emitted when fees are collected by controller + event FeesCollected(address indexed controller, uint256 amount); + + /// @notice Emitted when fees are deducted from rewards + event FeesDeducted(uint256 rewardAmount, uint256 feeAmount); + + /// @notice Emitted when manager needs to undelegate from indexers to fulfill withdrawal + event UndelegationRequired( + address indexed user, + uint256 requiredAmount, + uint256 remainingAmount + ); + + /// @notice Emitted when share price is updated for a new era + event SharePriceUpdated(uint256 indexed era, uint256 pricePerShare); + + /// @notice Emitted when unbond fee is tracked for accounting + event UnbondFeeTracked(uint256 amount, uint256 totalExpected); + + // -- Functions -- + + /** + * @dev Initialize this contract. + * @param _settings Address of the Settings contract + * @param _feePerMill Fee percentage in per-million (1000000 = 100%, 10000 = 1%) + */ + function initialize(ISettings _settings, uint256 _feePerMill) public initializer { + __Ownable_init(); + __ReentrancyGuard_init(); + __ERC20_init('SubQuery Delegation Pool Share', 'SQTdp'); + settings = _settings; + feePerMill = _feePerMill; + // Initialize share price at 1:1 ratio (1e18 = 1 SQT per share with 18 decimals precision) + currentEraSharePrice = 1e18; + lastPriceUpdateEra = 0; + } + + /** + * @dev Update the settings contract address + * @param _settings New settings contract address + */ + function updateSettings(ISettings _settings) external onlyOwner { + settings = _settings; + } + + /** + * @dev Set the fee percentage for reward collection + * @param _feePerMill Fee percentage in per-million (1000000 = 100%, 10000 = 1%) + */ + function setFeeRate(uint256 _feePerMill) external onlyOwner { + feePerMill = _feePerMill; + emit FeeRateUpdated(_feePerMill); + } + + /** + * @dev Collect a specific amount of accumulated fees + * @param _amount Amount of fees to collect + */ + function collectFees(uint256 _amount) external onlyOwner { + require(_amount > 0, 'DP013'); + require(accumulatedFees >= _amount, 'DP014'); + + IERC20 sqToken = IERC20(settings.getContractAddress(SQContracts.SQToken)); + + accumulatedFees -= _amount; + sqToken.safeTransfer(msg.sender, _amount); + + emit FeesCollected(msg.sender, _amount); + } + + /** + * @dev Collect all accumulated fees + */ + function collectAllFees() external onlyOwner { + require(accumulatedFees > 0, 'DP014'); + + IERC20 sqToken = IERC20(settings.getContractAddress(SQContracts.SQToken)); + uint256 amount = accumulatedFees; + + accumulatedFees = 0; + sqToken.safeTransfer(msg.sender, amount); + + emit FeesCollected(msg.sender, amount); + } + + /** + * @dev Add tokens to the delegation pool and receive shares + * @param _amount Amount of SQT tokens to delegate + */ + function delegate(uint256 _amount) external nonReentrant { + require(_amount > 0, 'DP001'); + + IERC20 sqToken = IERC20(settings.getContractAddress(SQContracts.SQToken)); + require(sqToken.balanceOf(msg.sender) >= _amount, 'DP002'); + + // Update share price if era has changed before calculating shares + _updateSharePriceIfNeeded(); + + // Calculate shares to mint using era-locked price + uint256 sharesToMint = _calculateSharesToMint(_amount); + + // Transfer tokens from user + sqToken.safeTransferFrom(msg.sender, address(this), _amount); + + // Update state + _mint(msg.sender, sharesToMint); + + // Update the pending undelegations to use this new deposit first and offset the available assets + if (pendingUndelegationsForUsers > 0) { + // Use new deposit to cover pending undelegations first + if (_amount >= pendingUndelegationsForUsers) { + // Fully cover pending undelegations + availableAssets += (_amount - pendingUndelegationsForUsers); + pendingUndelegationsForUsers = 0; + } else { + // Partially cover pending undelegations + pendingUndelegationsForUsers -= _amount; + // No change to availableAssets as all new deposit is used + } + } else { + availableAssets += _amount; + } + + emit Delegated(msg.sender, _amount, sharesToMint); + } + + /** + * @dev Start undelegation process by burning shares + * @param shareAmount Number of shares to burn for undelegation + */ + function undelegate(uint256 shareAmount) external nonReentrant { + require(shareAmount > 0, 'DP003'); + require(balanceOf(msg.sender) >= shareAmount, 'DP004'); + + // Calculate SQT amount to undelegate + uint256 sqtAmount = _calculateAssetsFromShares(shareAmount); + require(sqtAmount > 0, 'DP005'); + + // Burn user shares + _burn(msg.sender, shareAmount); + + uint256 unbondFeeRate = _getUnbondFeeRate(); + + // Handle undelegation based on available assets + if (availableAssets >= sqtAmount) { + // Calculate unbond fee (applies to all undelegations) + uint256 fee = MathUtil.mulDiv(unbondFeeRate, sqtAmount, PER_MILL); + uint256 netAmount = sqtAmount - fee; + + // Direct withdrawal from available assets + availableAssets -= sqtAmount; + accumulatedUnbondFees += fee; + _addUnbondRequest(msg.sender, netAmount, block.timestamp); + } else { + // Need to undelegate from indexers via Staking + // Amount from available assets is the whole amount because they would all be consumed by this undelegation + uint256 amountFromAvailable = availableAssets; + uint256 amountFromIndexers = sqtAmount - amountFromAvailable; + + // Calculate fees from each source + uint256 feeFromAvailable = MathUtil.mulDiv( + unbondFeeRate, + amountFromAvailable, + PER_MILL + ); + uint256 feeFromIndexers = MathUtil.mulDiv(unbondFeeRate, amountFromIndexers, PER_MILL); + + // Calculate net amounts after fees + uint256 netFromAvailable = amountFromAvailable - feeFromAvailable; + uint256 netFromIndexers = amountFromIndexers - feeFromIndexers; + + // Track fees: accumulated (from available) and expected (from indexers) + accumulatedUnbondFees += feeFromAvailable; + expectedUnbondFees += feeFromIndexers; + emit UnbondFeeTracked(feeFromIndexers, expectedUnbondFees); + + // Consume available assets (gross amount including fee) + availableAssets -= amountFromAvailable; + + // Create unbond request for net total and trigger indexer undelegation + uint256 netTotal = netFromAvailable + netFromIndexers; + _addUnbondRequest(msg.sender, netTotal, block.timestamp); + + if (amountFromIndexers > 0) { + // Track GROSS amount from indexers (still counted in delegated amounts) + pendingUndelegationsForUsers += amountFromIndexers; + emit UndelegationRequired(msg.sender, netTotal, amountFromIndexers); + } + } + + emit UndelegationStarted(msg.sender, shareAmount, sqtAmount); + } + + /** + * @dev Withdraw matured unbonding requests + */ + function withdraw() external nonReentrant { + require(unbondingRequests[msg.sender].length > withdrawnLength[msg.sender], 'DP006'); + + IERC20 sqToken = IERC20(settings.getContractAddress(SQContracts.SQToken)); + + uint256 totalWithdrawable = 0; + uint256 currentWithdrawnLength = withdrawnLength[msg.sender]; + + // Process up to 10 mature requests + for ( + uint256 i = currentWithdrawnLength; + i < unbondingRequests[msg.sender].length && i < currentWithdrawnLength + 10; + i++ + ) { + UnbondRequest storage request = unbondingRequests[msg.sender][i]; + + if (request.completed) { + continue; + } + + // Check if unbonding period has passed + uint256 lockPeriod = _getLockPeriod(); + if (block.timestamp - request.startTime < lockPeriod) { + break; // Stop at first non-mature request + } + totalWithdrawable += request.amount; + request.completed = true; + withdrawnLength[msg.sender]++; + } + + require(totalWithdrawable > 0, 'DP007'); + + // Transfer accumulated fees to treasury if any + if (accumulatedUnbondFees > 0) { + address treasury = settings.getContractAddress(SQContracts.Treasury); + uint256 feesToTransfer = accumulatedUnbondFees; + accumulatedUnbondFees = 0; + sqToken.safeTransfer(treasury, feesToTransfer); + } + + // Transfer net amount to user (fee already deducted when undelegating) + sqToken.safeTransfer(msg.sender, totalWithdrawable); + + emit Withdrawn(msg.sender, totalWithdrawable); + } + + /** + * @dev Manager delegates pool funds to an indexer + * @param _runner Indexer address to delegate to + * @param _amount Amount of SQT to delegate + */ + function managerDelegate(address _runner, uint256 _amount) external onlyOwner { + require(_runner != address(0), 'DP008'); + require(_amount > 0, 'DP001'); + require(availableAssets >= _amount, 'DP009'); + + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + IERC20 sqToken = IERC20(settings.getContractAddress(SQContracts.SQToken)); + + // Approve both Staking and StakingManager contracts to handle different implementations + address stakingContract = settings.getContractAddress(SQContracts.Staking); + sqToken.approve(stakingContract, _amount); + sqToken.approve(address(stakingManager), _amount); + + // Delegate through StakingManager + stakingManager.delegate(_runner, _amount); + + // Update pool state + availableAssets -= _amount; + + // Add to active indexers if not already present + if (!isActiveIndexer[_runner]) { + activeIndexers.push(_runner); + isActiveIndexer[_runner] = true; + } + + emit ManagerDelegated(_runner, _amount); + } + + /** + * @dev Manager undelegates pool funds from an indexer + * @param _runner Indexer address to undelegate from + * @param _amount Amount of SQT to undelegate + */ + function managerUndelegate(address _runner, uint256 _amount) external onlyOwner { + require(_runner != address(0), 'DP008'); + require(_amount > 0, 'DP001'); + require(getDelegatedToIndexer(_runner) >= _amount, 'DP010'); + + // Limit manager undelegation to only the amount reserved for user withdrawals + // This is done to simplify accounting as tracking assets to go back to availableAssets would be complex + // Managers should instead redelegate if they want to move funds between indexers + require(_amount <= pendingUndelegationsForUsers, 'DP015'); + + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + + // Undelegate through StakingManager + stakingManager.undelegate(_runner, _amount); + + // Reduce the pending undelegations + pendingUndelegationsForUsers -= _amount; + + // Clean up indexer from active list if no more delegation + _cleanupIndexerIfEmpty(_runner); + + emit ManagerUndelegated(_runner, _amount); + } + + /** + * @dev Withdraw any indexer unbonding assets, so users can withdraw their assets + * Anyone can call this function to trigger the withdrawal + */ + function managerWithdraw() external { + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + + stakingManager.widthdraw(); + } + + /** + * @dev Manager redelegates from one indexer to another + * @param _fromRunner Source indexer address + * @param _toRunner Destination indexer address + * @param _amount Amount of SQT to redelegate + */ + function managerRedelegate( + address _fromRunner, + address _toRunner, + uint256 _amount + ) external onlyOwner { + require(_fromRunner != address(0) && _toRunner != address(0), 'DP008'); + require(_fromRunner != _toRunner, 'DP011'); + require(_amount > 0, 'DP001'); + require(getDelegatedToIndexer(_fromRunner) >= _amount, 'DP010'); + + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + + // Redelegate through StakingManager + stakingManager.redelegate(_fromRunner, _toRunner, _amount); + + // Add destination to active indexers if not present + if (!isActiveIndexer[_toRunner]) { + activeIndexers.push(_toRunner); + isActiveIndexer[_toRunner] = true; + } + + // Clean up source indexer if no more delegation + _cleanupIndexerIfEmpty(_fromRunner); + + emit ManagerRedelegated(_fromRunner, _toRunner, _amount); + } + + function managerCancelUnbonding(uint256 unbondReqId) external onlyOwner { + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + + // Cancel unbonding through StakingManager + stakingManager.cancelUnbonding(unbondReqId); + } + + /** + * @dev Automatic compound rewards for all active delegations + */ + function autoCompound() external { + require(activeIndexers.length > 0, 'DP012'); + + // Update share price for new era BEFORE claiming rewards + // This ensures any new deposits use the old price (no reward benefit) + _updateSharePriceIfNeeded(); + + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + IERC20 sqToken = IERC20(settings.getContractAddress(SQContracts.SQToken)); + + uint256 rewardsBefore = sqToken.balanceOf(address(this)); + stakingManager.batchStakeReward(activeIndexers); + uint256 rewardsAfter = sqToken.balanceOf(address(this)); + + uint256 totalRewards = rewardsAfter - rewardsBefore; + + if (totalRewards == 0) { + return; // No rewards to compound + } + + // Calculate and deduct fees from rewards + uint256 feeAmount = 0; + if (feePerMill > 0) { + feeAmount = (totalRewards * feePerMill) / PER_MILL; + accumulatedFees += feeAmount; + emit FeesDeducted(totalRewards, feeAmount); + } + + // Add remaining rewards to available assets for compounding + uint256 compoundAmount = totalRewards - feeAmount; + availableAssets += compoundAmount; + + // With ERC20 shares, rewards automatically compound through increased asset base + // The share value increases rather than minting new shares + // This maintains existing share holders' proportional ownership while increasing their value + + emit RewardsCompounded(totalRewards); + } + + // -- Views -- + + // /** + // * @dev Get user's delegation amount in the pool + // * @param _user User address + // * @return User's delegation amount in SQT + // */ + function getDelegationAmount(address _user) external view returns (uint256) { + if (totalSupply() == 0) return 0; + return _calculateAssetsFromShares(balanceOf(_user)); + } + + /** + * @dev Get total assets managed by the pool + * @return Total SQT assets in the pool + */ + function getTotalAssets() external view returns (uint256) { + return _calculateTotalAssets(); + } + + /** + * @dev Get number of active indexers + * @return Number of indexers the pool has delegated to + */ + function getActiveIndexersCount() external view returns (uint256) { + return activeIndexers.length; + } + + /** + * @dev Get active indexers list + * @return Array of active indexer addresses + */ + function getActiveIndexers() external view returns (address[] memory) { + return activeIndexers; + } + + /** + * @dev Get total pending rewards available for compounding + * @return Total pending rewards across all active indexers + */ + function getPendingRewards() external view returns (uint256) { + if (activeIndexers.length == 0) { + return 0; + } + + address rewardsDistributorAddress = settings.getContractAddress( + SQContracts.RewardsDistributor + ); + + // Return 0 if RewardsDistributor is not configured + if (rewardsDistributorAddress == address(0)) { + return 0; + } + + IRewardsDistributor rewardsDistributor = IRewardsDistributor(rewardsDistributorAddress); + + uint256 totalPendingRewards = 0; + for (uint256 i = 0; i < activeIndexers.length; i++) { + uint256 pendingReward = rewardsDistributor.userRewards( + activeIndexers[i], + address(this) + ); + totalPendingRewards += pendingReward; + } + + return totalPendingRewards; + } + + /** + * @dev Get amount delegated to specific indexer + * @param _indexer Indexer address + * @return Amount delegated to the indexer + */ + function getDelegatedToIndexer(address _indexer) public view returns (uint256) { + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + + // Use the after (current era) amount here so we get the amount locked, even if its not active yet + return stakingManager.getAfterDelegationAmount(address(this), _indexer); + } + + /** + * @dev Get pending unbond requests for user + * @param _user User address + * @return Array of unbond requests + */ + function getPendingUnbonds(address _user) external view returns (UnbondRequest[] memory) { + uint256 pendingCount = unbondingRequests[_user].length - withdrawnLength[_user]; + UnbondRequest[] memory pending = new UnbondRequest[](pendingCount); + + uint256 index = 0; + for (uint256 i = withdrawnLength[_user]; i < unbondingRequests[_user].length; i++) { + if (!unbondingRequests[_user][i].completed) { + pending[index] = unbondingRequests[_user][i]; + index++; + } + } + + return pending; + } + + /** + * @dev Calculate expected shares for a given SQT amount + * @param _amount Amount of SQT + * @return Expected shares to be minted + */ + function previewDeposit(uint256 _amount) external view returns (uint256) { + return _calculateSharesToMint(_amount); + } + + /** + * @dev Calculate expected SQT amount for given shares + * @param _shares Number of shares + * @return Expected SQT amount to be received + */ + function previewWithdraw(uint256 _shares) external view returns (uint256) { + return _calculateAssetsFromShares(_shares); + } + + /** + * @dev Get accumulated fees available for collection + * @return Amount of accumulated fees + */ + function getAccumulatedFees() external view returns (uint256) { + return accumulatedFees; + } + + /** + * @dev Get current fee rate + * @return Fee percentage in per-million + */ + function getFeeRate() external view returns (uint256) { + return feePerMill; + } + + /** + * @dev Get the current era share price + * @return Share price with 18 decimals precision (1e18 = 1 SQT per share) + */ + function getCurrentSharePrice() external view returns (uint256) { + return currentEraSharePrice; + } + + /** + * @dev Get the era of last price update + * @return Era number when share price was last updated + */ + function getLastPriceUpdateEra() external view returns (uint256) { + return lastPriceUpdateEra; + } + + // -- Internal Helper Functions -- + + /** + * @dev Calculate shares to mint for given SQT amount + * Uses era-locked share price to prevent reward manipulation + */ + function _calculateSharesToMint(uint256 _amount) internal view returns (uint256) { + if (totalSupply() == 0) { + return _amount; // 1:1 ratio for first deposit + } + + // Use the current era's locked share price + // shares = (amount * 1e18) / pricePerShare + if (currentEraSharePrice > 0) { + return (_amount * 1e18) / currentEraSharePrice; + } + + // Fallback to current calculation if price not set (should rarely happen) + uint256 totalAssets = _calculateTotalAssets(); + if (totalAssets == 0) { + return _amount; + } + return (_amount * totalSupply()) / totalAssets; + } + + /** + * @dev Calculate SQT assets from share amount + */ + function _calculateAssetsFromShares(uint256 _shares) internal view returns (uint256) { + if (totalSupply() == 0 || _shares == 0) { + return 0; + } + + uint256 totalAssets = _calculateTotalAssets(); + return (_shares * totalAssets) / totalSupply(); + } + + /** + * @dev Calculate total assets under management + * Accounts for: + * - Pending undelegations from indexers (assets reserved for withdrawal but still counted in delegated amounts) + * - Expected unbond fees that will be lost when withdrawing from Staking + * - Accumulated unbond fees (collected but not yet sent to treasury) + */ + function _calculateTotalAssets() internal view returns (uint256) { + uint256 totalDelegated = 0; + + // Sum up all delegated amounts + for (uint256 i = 0; i < activeIndexers.length; i++) { + totalDelegated += getDelegatedToIndexer(activeIndexers[i]); + } + + uint256 grossAssets = availableAssets + totalDelegated; + + // Subtract: + // - pending undelegations from indexers (reserved for withdrawals, no longer backing shares) + // - expected fees that will be lost when manager withdraws from Staking + // - accumulated fees (still in availableAssets but reserved for treasury) + uint256 totalDeductions = pendingUndelegationsForUsers + + expectedUnbondFees + + accumulatedUnbondFees; + + if (grossAssets > totalDeductions) { + return grossAssets - totalDeductions; + } + + return 0; + } + + /** + * @dev Add unbond request for user + */ + function _addUnbondRequest(address _user, uint256 _amount, uint256 _startTime) internal { + unbondingRequests[_user].push( + UnbondRequest({ amount: _amount, startTime: _startTime, completed: false }) + ); + } + + /** + * @dev Get lock period from Staking contract + */ + function _getLockPeriod() internal view returns (uint256) { + // Get lock period from Staking contract through StakingManager + Staking staking = Staking(settings.getContractAddress(SQContracts.Staking)); + return staking.lockPeriod(); + } + + /** + * @dev Get unbond fee rate from Staking contract + */ + function _getUnbondFeeRate() internal view returns (uint256) { + Staking staking = Staking(settings.getContractAddress(SQContracts.Staking)); + return staking.unbondFeeRate(); + } + + /** + * @dev Remove indexer from active list if delegation is zero + */ + function _cleanupIndexerIfEmpty(address _indexer) internal { + if (getDelegatedToIndexer(_indexer) == 0 && isActiveIndexer[_indexer]) { + // Find and remove from array + for (uint256 i = 0; i < activeIndexers.length; i++) { + if (activeIndexers[i] == _indexer) { + activeIndexers[i] = activeIndexers[activeIndexers.length - 1]; + activeIndexers.pop(); + break; + } + } + isActiveIndexer[_indexer] = false; + } + } + + /** + * @dev Updates share price checkpoint if we're in a new era + * This prevents reward manipulation by locking share prices within each era + */ + function _updateSharePriceIfNeeded() internal { + IEraManager eraManager = IEraManager(settings.getContractAddress(SQContracts.EraManager)); + uint256 currentEra = eraManager.eraNumber(); + + // Only update if era has changed and we have shares outstanding + if (currentEra > lastPriceUpdateEra && totalSupply() > 0) { + uint256 totalAssets = _calculateTotalAssets(); + + // Calculate new share price: (totalAssets * 1e18) / totalSupply + // This gives us price per share with 18 decimals precision + currentEraSharePrice = (totalAssets * 1e18) / totalSupply(); + + lastPriceUpdateEra = currentEra; + totalAssetsAtLastUpdate = totalAssets; + totalSharesAtLastUpdate = totalSupply(); + + emit SharePriceUpdated(currentEra, currentEraSharePrice); + } + } +} diff --git a/contracts/RewardsBooster.sol b/contracts/RewardsBooster.sol index 2b3431d0..6cfc69ed 100644 --- a/contracts/RewardsBooster.sol +++ b/contracts/RewardsBooster.sol @@ -429,7 +429,7 @@ contract RewardsBooster is Initializable, OwnableUpgradeable, IRewardsBooster, S address _account ) public returns (ProjectType) { DeploymentPool storage deploymentPool = deploymentPools[_deploymentId]; - uint _amount = deploymentPool.accountBooster[_account]; + uint256 _amount = deploymentPool.accountBooster[_account]; ProjectType projectType = IProjectRegistry( settings.getContractAddress(SQContracts.ProjectRegistry) diff --git a/contracts/RewardsPool.sol b/contracts/RewardsPool.sol index b55b060b..3dd582fd 100644 --- a/contracts/RewardsPool.sol +++ b/contracts/RewardsPool.sol @@ -49,11 +49,11 @@ contract RewardsPool is IRewardsPool, Initializable, OwnableUpgradeable, SQParam /// @notice Runner Deployment struct RunnerDeployment { // unclaimed deployments count - uint unclaim; + uint256 unclaim; // deployments list bytes32[] deployments; // deployments list index - mapping(bytes32 => uint) index; + mapping(bytes32 => uint256) index; } /// @notice Era Reward Pool @@ -278,8 +278,8 @@ contract RewardsPool is IRewardsPool, Initializable, OwnableUpgradeable, SQParam function _batchCollect(uint256 era, address runner) private { EraPool storage eraPool = pools[era]; RunnerDeployment storage runnerDeployment = eraPool.unclaimedDeployments[runner]; - uint lastIndex = runnerDeployment.unclaim; - for (uint i = lastIndex; i > 0; i--) { + uint256 lastIndex = runnerDeployment.unclaim; + for (uint256 i = lastIndex; i > 0; i--) { bytes32 deploymentId = runnerDeployment.deployments[i]; _collect(era, deploymentId, runner); } @@ -308,7 +308,7 @@ contract RewardsPool is IRewardsPool, Initializable, OwnableUpgradeable, SQParam } RunnerDeployment storage runnerDeployment = eraPool.unclaimedDeployments[runner]; - uint index = runnerDeployment.index[deploymentId]; + uint256 index = runnerDeployment.index[deploymentId]; bytes32 lastDeployment = runnerDeployment.deployments[runnerDeployment.unclaim]; runnerDeployment.deployments[index] = lastDeployment; runnerDeployment.deployments.pop(); diff --git a/contracts/interfaces/IStakingManager.sol b/contracts/interfaces/IStakingManager.sol index 5f722951..8015fbad 100644 --- a/contracts/interfaces/IStakingManager.sol +++ b/contracts/interfaces/IStakingManager.sol @@ -8,6 +8,20 @@ interface IStakingManager { function unstake(address _runner, uint256 _amount) external; + function delegate(address _runner, uint256 _amount) external; + + function undelegate(address _runner, uint256 _amount) external; + + function redelegate(address _fromRunner, address _toRunner, uint256 _amount) external; + + function cancelUnbonding(uint256 unbondReqId) external; + + function widthdraw() external; + + function stakeReward(address _runner) external; + + function batchStakeReward(address[] calldata _runners) external; + function slashRunner(address _runner, uint256 _amount) external; function getTotalStakingAmount(address _runner) external view returns (uint256); diff --git a/contracts/mocks/MockStakingManager.sol b/contracts/mocks/MockStakingManager.sol new file mode 100644 index 00000000..2bd33aa0 --- /dev/null +++ b/contracts/mocks/MockStakingManager.sol @@ -0,0 +1,202 @@ +// Copyright (C) 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.15; + +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import '../interfaces/IStakingManager.sol'; + +/** + * @title MockStakingManager + * @notice Mock implementation of IStakingManager for testing DelegationPool auto-compound functionality + */ +contract MockStakingManager is IStakingManager { + using SafeERC20 for IERC20; + + IERC20 public sqToken; + + // Track delegations: delegator => runner => amount + mapping(address => mapping(address => uint256)) public delegations; + + // Track rewards per runner + mapping(address => uint256) public rewardsPerRunner; + + // Track total delegations per runner + mapping(address => uint256) public totalDelegationsPerRunner; + + event Delegated(address indexed delegator, address indexed runner, uint256 amount); + event Undelegated(address indexed delegator, address indexed runner, uint256 amount); + event Redelegated( + address indexed delegator, + address indexed fromRunner, + address indexed toRunner, + uint256 amount + ); + event RewardsClaimed(address indexed delegator, address[] runners, uint256 totalRewards); + + constructor(address _sqToken) { + sqToken = IERC20(_sqToken); + } + + /** + * @notice Set rewards available for a specific runner + * @dev Test helper function to simulate reward accumulation + */ + function setRunnerRewards(address _runner, uint256 _rewards) external { + rewardsPerRunner[_runner] = _rewards; + } + + /** + * @notice Fund this contract with SQT tokens for distributing rewards + * @dev Test helper function + */ + function fundRewards(uint256 _amount) external { + sqToken.safeTransferFrom(msg.sender, address(this), _amount); + } + + // IStakingManager implementation + + function stake(address _runner, uint256 _amount) external override { + revert('Not implemented in mock'); + } + + function unstake(address _runner, uint256 _amount) external override { + revert('Not implemented in mock'); + } + + function delegate(address _runner, uint256 _amount) external override { + require(_amount > 0, 'Amount must be greater than 0'); + + // Transfer tokens from delegator to this contract + sqToken.safeTransferFrom(msg.sender, address(this), _amount); + + // Update delegations + delegations[msg.sender][_runner] += _amount; + totalDelegationsPerRunner[_runner] += _amount; + + emit Delegated(msg.sender, _runner, _amount); + } + + function undelegate(address _runner, uint256 _amount) external override { + require(_amount > 0, 'Amount must be greater than 0'); + require(delegations[msg.sender][_runner] >= _amount, 'Insufficient delegation'); + + // Update delegations + delegations[msg.sender][_runner] -= _amount; + totalDelegationsPerRunner[_runner] -= _amount; + + // Transfer tokens back to delegator + sqToken.safeTransfer(msg.sender, _amount); + + emit Undelegated(msg.sender, _runner, _amount); + } + + function redelegate(address _fromRunner, address _toRunner, uint256 _amount) external override { + require(_amount > 0, 'Amount must be greater than 0'); + require(delegations[msg.sender][_fromRunner] >= _amount, 'Insufficient delegation'); + + // Update delegations + delegations[msg.sender][_fromRunner] -= _amount; + delegations[msg.sender][_toRunner] += _amount; + totalDelegationsPerRunner[_fromRunner] -= _amount; + totalDelegationsPerRunner[_toRunner] += _amount; + + emit Redelegated(msg.sender, _fromRunner, _toRunner, _amount); + } + + function widthdraw() external { + // No-op this is handled in undelegate currently + } + + function cancelUnbonding(uint256 unbondReqId) external override { + revert('Not implemented in mock'); + } + + function stakeReward(address _runner) external override { + uint256 rewards = _calculateDelegatorRewards(msg.sender, _runner); + if (rewards > 0) { + // Reset rewards for this runner for this delegator + rewardsPerRunner[_runner] = 0; + + // Transfer rewards to delegator + sqToken.safeTransfer(msg.sender, rewards); + } + } + + function batchStakeReward(address[] calldata _runners) external override { + uint256 totalRewards = 0; + + for (uint256 i = 0; i < _runners.length; i++) { + uint256 rewards = _calculateDelegatorRewards(msg.sender, _runners[i]); + if (rewards > 0) { + totalRewards += rewards; + // Reset rewards for this runner + rewardsPerRunner[_runners[i]] = 0; + } + } + + if (totalRewards > 0) { + // Transfer total rewards to delegator + sqToken.safeTransfer(msg.sender, totalRewards); + emit RewardsClaimed(msg.sender, _runners, totalRewards); + } + } + + function slashRunner(address _runner, uint256 _amount) external override { + revert('Not implemented in mock'); + } + + function getTotalStakingAmount(address _runner) external view override returns (uint256) { + return totalDelegationsPerRunner[_runner]; + } + + function getEffectiveTotalStake(address _runner) external view override returns (uint256) { + return totalDelegationsPerRunner[_runner]; + } + + function getAfterDelegationAmount( + address _delegator, + address _runner + ) external view override returns (uint256) { + return delegations[_delegator][_runner]; + } + + function getDelegationAmount( + address _delegator, + address _runner + ) external view override returns (uint256) { + return delegations[_delegator][_runner]; + } + + function getEraDelegationAmount( + address _delegator, + address _runner, + uint256 _era + ) external view override returns (uint256) { + return delegations[_delegator][_runner]; + } + + // Internal helper functions + + /** + * @notice Calculate rewards for a specific delegator on a specific runner + * @dev Proportionally distributes runner rewards based on delegator's share + */ + function _calculateDelegatorRewards( + address _delegator, + address _runner + ) internal view returns (uint256) { + uint256 delegatorAmount = delegations[_delegator][_runner]; + if (delegatorAmount == 0) return 0; + + uint256 totalRunnerDelegations = totalDelegationsPerRunner[_runner]; + if (totalRunnerDelegations == 0) return 0; + + uint256 runnerRewards = rewardsPerRunner[_runner]; + if (runnerRewards == 0) return 0; + + // Calculate proportional share of rewards + return (runnerRewards * delegatorAmount) / totalRunnerDelegations; + } +} diff --git a/package.json b/package.json index 962b9dd1..5e35c9ff 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "docs:generate": "hardhat docgen", "debug": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register -r dotenv/config", "deploy": "ts-node --transpileOnly scripts/deploy.ts", + "deploy:delegation-pool": "ts-node --transpileOnly scripts/delegationPool.ts", "upgrade": "ts-node --transpileOnly scripts/upgrade.ts", "setup": "ts-node --transpileOnly scripts/startup.ts", "scan-verify:testnet": "hardhat publishChild --deployment publish/testnet.json --network base-sepolia --networkpair testnet ", diff --git a/publish/revertcode.json b/publish/revertcode.json index d4c426cb..8cc1bbf3 100644 --- a/publish/revertcode.json +++ b/publish/revertcode.json @@ -221,5 +221,20 @@ "RB014": "Caller is not a controller of the account", "RB015": "Deployment booster is too small, need add more", "RB016": "Deployment booster already too small, need remove all", - "OPD01": "l2token address is empty" + "OPD01": "l2token address is empty", + "DP001": "Amount must be greater than 0", + "DP002": "Insufficient balance", + "DP003": "Share amount must be greater than 0", + "DP004": "Insufficient shares", + "DP005": "No assets to undelegate", + "DP006": "No unbond request to withdraw", + "DP007": "No mature withdrawals", + "DP008": "Invalid runner address", + "DP009": "Insufficient available assets", + "DP010": "Insufficient delegated amount", + "DP011": "Cannot redelegate to same runner", + "DP012": "No active delegations", + "DP013": "Invalid fee amount", + "DP014": "Insufficient accumulated fees", + "DP015": "No additional undelegations", } diff --git a/scripts/contracts.ts b/scripts/contracts.ts index fb2b4380..730fc94e 100644 --- a/scripts/contracts.ts +++ b/scripts/contracts.ts @@ -67,6 +67,7 @@ import { Airdropper__factory, SubnetProjectVote, SubnetProjectVote__factory, + DelegationPool, } from '../src'; export type Contracts = { @@ -106,6 +107,7 @@ export type Contracts = { airdropperLite: AirdropperLite; l2Vesting: L2Vesting; subnetProjectVote: SubnetProjectVote; + delegationPool: DelegationPool; }; export const UPGRADEBAL_CONTRACTS: Partial< diff --git a/scripts/delegationPool.ts b/scripts/delegationPool.ts new file mode 100644 index 00000000..6d1d5c77 --- /dev/null +++ b/scripts/delegationPool.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs'; +import setup from './setup'; +import { Wallet } from 'ethers'; +import { ContractDeployment, CONTRACT_FACTORY, ProxyAdmin__factory } from '../src'; +import { deployProxy } from './deployContracts'; +import { getLogger } from './logger'; + +const logger = getLogger('Delegation Pool Deployment'); + +export async function deployDelegationPool({ wallet, deployment }: { wallet: Wallet; deployment: ContractDeployment }) { + const proxyAdmin = ProxyAdmin__factory.connect(deployment.child.ProxyAdmin.address, wallet); + + const confirms = 1; + + const [contract, innerAddress] = await deployProxy(proxyAdmin, CONTRACT_FACTORY.DelegationPool, wallet, confirms); + + logger.info(`🚀 Contract address: ${contract.address}`); + + const tx = await contract.initialize(deployment.child.Settings.address, 10000 /* 1% fee */); + logger.info(`🔎 Tx hash: ${tx.hash}`); + await tx.wait(confirms); + logger.info(`🚀 Contract initialized`); + + return contract; +} + +async function run() { + const { name, wallet, target, childProvider } = await setup(); + + if (target !== 'child') { + throw new Error(`Invalid target specified: ${target}`); + } + + const filePath = `${__dirname}/../publish/${name}.json`; + const deployment = JSON.parse(fs.readFileSync(filePath, { encoding: 'utf8' })); + + const connectedWallet = wallet.connect(childProvider); + + console.log('PROVIDER', connectedWallet.provider); + + await deployDelegationPool({ + wallet: connectedWallet, + deployment, + }); +} + +run(); diff --git a/scripts/deployContracts.ts b/scripts/deployContracts.ts index c8286dbc..f84cbc2b 100644 --- a/scripts/deployContracts.ts +++ b/scripts/deployContracts.ts @@ -59,6 +59,7 @@ import { L2Vesting, UniswapPriceOracle, SubnetProjectVote, + DelegationPool, } from '../src'; import { Config, ContractConfig, Contracts, UPGRADEBAL_CONTRACTS } from './contracts'; import { l1StandardBridge } from './L1StandardBridge'; @@ -81,7 +82,7 @@ function codeToHash(code: string) { return sha256(Buffer.from(code.replace(/^0x/, ''), 'hex')); } -async function getOverrides(): Promise { +async function getOverrides(wallet: Wallet): Promise { const price = await wallet.provider.getGasPrice(); // console.log(`gasprice: ${price.toString()}`) // price = price.add(15000000000); // add extra 15 gwei @@ -101,7 +102,7 @@ function loadDeployment(name: string) { return deployment; } -async function deployContract( +export async function deployContract( name: ContractName, target: 'root' | 'child', options?: { @@ -129,7 +130,7 @@ async function deployContract( if (proxyAdmin) { [contract, innerAddress] = await deployProxy(proxyAdmin, CONTRACT_FACTORY[name], wallet, confirms); } else { - const overrides = await getOverrides(); + const overrides = await getOverrides(wallet); contract = (await new CONTRACT_FACTORY[name](wallet).deploy(...deployConfig, overrides)) as T; logger?.info(`🔎 Tx hash: ${contract.deployTransaction.hash}`); await contract.deployTransaction.wait(confirms); @@ -141,7 +142,7 @@ async function deployContract( logger?.info('🤞 Init contract'); const defaultConfig = config[name] ?? []; const params = [...initConfig, ...defaultConfig]; - const overrides = await getOverrides(); + const overrides = await getOverrides(wallet); // @ts-expect-error type missing const tx = await contract.initialize(...params, overrides); @@ -161,7 +162,7 @@ export const deployProxy = async ( confirms: number ): Promise<[C, string]> => { const contractFactory = new ContractFactory(wallet); - const contractLogic = await contractFactory.deploy(await getOverrides()); + const contractLogic = await contractFactory.deploy(await getOverrides(wallet)); logger?.info(`🔎 Tx hash: contractLogic ${contractLogic.deployTransaction.hash}`); await contractLogic.deployTransaction.wait(confirms); @@ -171,7 +172,7 @@ export const deployProxy = async ( contractLogic.address, proxyAdmin.address, [], - await getOverrides() + await getOverrides(wallet) ); logger?.info(`🔎 Tx hash: contractProxy ${contractProxy.deployTransaction.hash}`); await contractProxy.deployTransaction.wait(confirms); @@ -538,6 +539,12 @@ export async function deployContracts( initConfig: [settingsAddress], }); + //deploy DelegationPool contract + const delegationPool = await deployContract('DelegationPool', 'child', { + proxyAdmin, + initConfig: [settingsAddress, 10000 /* 1% fee */], + }); + // Register addresses on settings contract logger?.info('🤞 Set settings addresses'); const txToken = await settings.setBatchAddress( @@ -618,6 +625,7 @@ export async function deployContracts( airdropper, stakingAllocation, l2Vesting, + delegationPool, }, ]; } catch (error) { @@ -636,7 +644,7 @@ export const upgradeContract = async ( ): Promise<[string, Contract]> => { wallet = _wallet; const contractFactory = new ContractFactory(wallet); - const contract = await contractFactory.deploy(await getOverrides()); + const contract = await contractFactory.deploy(await getOverrides(wallet)); await contract.deployTransaction.wait(confirms); if (!implementationOnly) { diff --git a/src/contracts.ts b/src/contracts.ts index 8fa57622..e16ed45f 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -38,6 +38,7 @@ import AirdropperLite from './artifacts/contracts/root/AirdropperLite.sol/Airdro import L2Vesting from './artifacts/contracts/l2/L2Vesting.sol/L2Vesting.json'; import UniswapPriceOracle from './artifacts/contracts/l2/UniswapPriceOracle.sol/UniswapPriceOracle.json'; import SubnetProjectVote from './artifacts/contracts/SubnetProjectVote.sol/SubnetProjectVote.json'; +import DelegationPool from './artifacts/contracts/DelegationPool.sol/DelegationPool.json'; export default { Settings, @@ -77,4 +78,5 @@ export default { L2Vesting, UniswapPriceOracle, SubnetProjectVote, + DelegationPool, }; diff --git a/src/sdk.ts b/src/sdk.ts index 1135a184..860abf9b 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -33,6 +33,7 @@ import { StakingAllocation, L2Vesting, SubnetProjectVote, + DelegationPool, } from './typechain'; import { CONTRACT_FACTORY, ContractDeploymentInner, ContractName, FactoryContstructor, SdkOptions } from './types'; import assert from 'assert'; @@ -75,6 +76,7 @@ export class ContractSDK { readonly stakingAllocation!: StakingAllocation; readonly l2Vesting!: L2Vesting; readonly subnetProjectVote!: SubnetProjectVote; + readonly delegationPool!: DelegationPool; constructor( // eslint-disable-next-line no-unused-vars diff --git a/src/types.ts b/src/types.ts index 189fd8c3..474842a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,7 @@ import { L2Vesting__factory, UniswapPriceOracle__factory, SubnetProjectVote__factory, + DelegationPool__factory, } from './typechain'; export type SubqueryNetwork = 'testnet' | 'testnet-mumbai' | 'mainnet' | 'local'; @@ -141,6 +142,7 @@ export const CONTRACT_FACTORY: Record = { L2Vesting: L2Vesting__factory, UniswapPriceOracle: UniswapPriceOracle__factory, SubnetProjectVote: SubnetProjectVote__factory, + DelegationPool: DelegationPool__factory, }; export enum SQContracts { @@ -165,6 +167,7 @@ export enum SQContracts { Treasury, RewardsBooster, StakingAllocation, + DelegationPool, } export enum ServiceStatus { diff --git a/test/DelegationPool.test.ts b/test/DelegationPool.test.ts new file mode 100644 index 00000000..cc7750d2 --- /dev/null +++ b/test/DelegationPool.test.ts @@ -0,0 +1,1413 @@ +// Copyright (C) 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { ethers, waffle } from 'hardhat'; + +import { SQContracts } from '../src/types'; +import { + EraManager, + IndexerRegistry, + ERC20, + Staking, + StakingManager, + DelegationPool, + Settings, + MockStakingManager, +} from '../src/typechain'; +import { etherParse, registerRunner, startNewEra, timeTravel, Wallet } from './helper'; +import { deployContracts } from './setup'; + +describe('DelegationPool Contract', () => { + let root: Wallet, + poolManager: Wallet, + user1: Wallet, + user2: Wallet, + user3: Wallet, + runner1: Wallet, + runner2: Wallet, + treasury: Wallet; + + let token: ERC20; + let staking: Staking; + let stakingManager: StakingManager; + let eraManager: EraManager; + let indexerRegistry: IndexerRegistry; + let settings: Settings; + let delegationPool: DelegationPool; + + const deployDelegationPool = async (feePerMill = 0) => { + const DelegationPoolFactory = await ethers.getContractFactory('DelegationPool', root); + const delegationPoolContract = await DelegationPoolFactory.deploy(); + await delegationPoolContract.initialize(settings.address, feePerMill); + return delegationPoolContract; + }; + + beforeEach(async () => { + [root, poolManager, user1, user2, user3, runner1, runner2, treasury] = waffle.provider.getWallets(); + + const contracts = await deployContracts(root, poolManager, treasury); + token = contracts.token; + staking = contracts.staking; + stakingManager = contracts.stakingManager; + eraManager = contracts.eraManager; + indexerRegistry = contracts.indexerRegistry; + settings = contracts.settings; + + // Deploy DelegationPool + delegationPool = await deployDelegationPool(); + + // Transfer ownership to poolManager + await delegationPool.transferOwnership(poolManager.address); + + // Setup runners + await registerRunner(token, indexerRegistry, staking, root, runner1, etherParse('2000')); + await registerRunner(token, indexerRegistry, staking, root, runner2, etherParse('2000')); + + // Give tokens to users + await token.transfer(user1.address, etherParse('10000')); + await token.transfer(user2.address, etherParse('10000')); + await token.transfer(user3.address, etherParse('10000')); + }); + + describe('Contract Initialization', () => { + it('should initialize with correct settings', async () => { + expect(await delegationPool.settings()).to.equal(settings.address); + expect(await delegationPool.owner()).to.equal(poolManager.address); + expect(await delegationPool.totalSupply()).to.equal(0); + expect(await delegationPool.availableAssets()).to.equal(0); + expect(await delegationPool.getFeeRate()).to.equal(0); + expect(await delegationPool.getAccumulatedFees()).to.equal(0); + }); + + it('should not allow initialization twice', async () => { + await expect(delegationPool.initialize(settings.address, 0)).to.be.revertedWith( + 'Initializable: contract is already initialized' + ); + }); + }); + + describe('User Delegation Functions', () => { + describe('delegate()', () => { + it('should allow users to delegate SQT and receive shares', async () => { + const delegateAmount = etherParse('1000'); + + // User1 approves and delegates + await token.connect(user1).approve(delegationPool.address, delegateAmount); + await expect(delegationPool.connect(user1).delegate(delegateAmount)) + .to.emit(delegationPool, 'Delegated') + .withArgs(user1.address, delegateAmount, delegateAmount); // 1:1 ratio for first deposit + + expect(await delegationPool.balanceOf(user1.address)).to.equal(delegateAmount); + expect(await delegationPool.totalSupply()).to.equal(delegateAmount); + expect(await delegationPool.availableAssets()).to.equal(delegateAmount); // Only delegated amount + expect(await delegationPool.getDelegationAmount(user1.address)).to.equal(delegateAmount); + }); + + it('should calculate shares proportionally for subsequent deposits', async () => { + const firstDeposit = etherParse('1000'); + const secondDeposit = etherParse('500'); + + // First deposit (1:1 ratio) + await token.connect(user1).approve(delegationPool.address, firstDeposit); + await delegationPool.connect(user1).delegate(firstDeposit); + + // Second deposit (proportional) + await token.connect(user2).approve(delegationPool.address, secondDeposit); + await delegationPool.connect(user2).delegate(secondDeposit); + + const expectedShares = secondDeposit.mul(firstDeposit).div(firstDeposit); // 500 * 1000 / 1000 = 500 + expect(await delegationPool.balanceOf(user2.address)).to.equal(expectedShares); + }); + + it('should reject zero amount delegation', async () => { + await expect(delegationPool.connect(user1).delegate(0)).to.be.revertedWith('DP001'); + }); + + it('should reject delegation without sufficient balance', async () => { + const delegateAmount = etherParse('20000'); // More than user1 has + await token.connect(user1).approve(delegationPool.address, delegateAmount); + await expect(delegationPool.connect(user1).delegate(delegateAmount)).to.be.revertedWith('DP002'); + }); + + it('should reject delegation without sufficient allowance', async () => { + const delegateAmount = etherParse('1000'); + // No approval + await expect(delegationPool.connect(user1).delegate(delegateAmount)).to.be.revertedWith( + 'ERC20: insufficient allowance' + ); + }); + }); + + describe('undelegate()', () => { + beforeEach(async () => { + // Setup: User1 delegates 1000 SQT + const delegateAmount = etherParse('1000'); + await token.connect(user1).approve(delegationPool.address, delegateAmount); + await delegationPool.connect(user1).delegate(delegateAmount); + }); + + it('should allow users to undelegate their shares', async () => { + const undelegateShares = etherParse('500'); + const expectedSQT = etherParse('500'); // 500 shares = 500 SQT (500/1000 * 1000) + + // Calculate expected net amount after fee + const unbondFeeRate = await staking.unbondFeeRate(); + const expectedFee = expectedSQT.mul(unbondFeeRate).div(1000000); + const expectedNet = expectedSQT.sub(expectedFee); + + await expect(delegationPool.connect(user1).undelegate(undelegateShares)) + .to.emit(delegationPool, 'UndelegationStarted') + .withArgs(user1.address, undelegateShares, expectedSQT); + + expect(await delegationPool.balanceOf(user1.address)).to.equal(etherParse('500')); + expect(await delegationPool.totalSupply()).to.equal(etherParse('500')); + + // Should create unbonding request for net amount (after fee) + const unbondRequests = await delegationPool.getPendingUnbonds(user1.address); + expect(unbondRequests.length).to.equal(1); + expect(unbondRequests[0].amount).to.equal(expectedNet); + expect(unbondRequests[0].completed).to.be.false; + }); + + it('should reject zero shares undelegation', async () => { + await expect(delegationPool.connect(user1).undelegate(0)).to.be.revertedWith('DP003'); + }); + + it('should reject undelegation of more shares than owned', async () => { + const excessiveShares = etherParse('1500'); + await expect(delegationPool.connect(user1).undelegate(excessiveShares)).to.be.revertedWith('DP004'); + }); + + it('should emit UndelegationRequired event when manager needs to undelegate from indexers', async () => { + // beforeEach already set up user1 with 1000 SQT delegation + // Manager delegates most of the pool to indexer, leaving only small amount available + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('900')); + + // User tries to undelegate more than available assets (100 available, 500 requested) + const undelegateShares = etherParse('500'); + const totalUndelegateAmount = etherParse('500'); // 500 shares = 500 SQT + const requiredFromIndexers = etherParse('400'); // 400 SQT needs to be undelegated from indexers + + await expect(delegationPool.connect(user1).undelegate(undelegateShares)) + .to.emit(delegationPool, 'UndelegationRequired') + .withArgs(user1.address, totalUndelegateAmount, requiredFromIndexers) + .and.to.emit(delegationPool, 'UndelegationStarted') + .withArgs(user1.address, undelegateShares, totalUndelegateAmount); + + // Verify pool state + expect(await delegationPool.availableAssets()).to.equal(0); // All available assets consumed + + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(requiredFromIndexers); + }); + + it('should not emit UndelegationRequired event when sufficient assets available', async () => { + // beforeEach already set up user1 with 1000 SQT delegation + // Manager delegates only part of the pool, leaving enough available + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('500')); + + // User undelegates less than available assets (500 available, 300 requested) + const undelegateShares = etherParse('300'); + const undelegateAmount = etherParse('300'); + + const tx = await delegationPool.connect(user1).undelegate(undelegateShares); + const receipt = await tx.wait(); + + // Should emit UndelegationStarted but NOT UndelegationRequired + expect(tx) + .to.emit(delegationPool, 'UndelegationStarted') + .withArgs(user1.address, undelegateShares, undelegateAmount); + + // Check that UndelegationRequired was NOT emitted + const undelegationRequiredEvents = + receipt.events?.filter((e) => e.event === 'UndelegationRequired') || []; + expect(undelegationRequiredEvents.length).to.equal(0); + + // Verify pool state + expect(await delegationPool.availableAssets()).to.equal(etherParse('200')); // 500 - 300 = 200 remaining + }); + + it('should allocate new deposits towards required undelegations', async () => { + // beforeEach already set up user1 with 1000 SQT delegation + // Manager delegates only part of the pool, leaving enough available + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('1000')); + + // Check that all pool assets are delegated + expect(await delegationPool.availableAssets()).to.equal(etherParse('0')); + + const undelegateShares = etherParse('1000'); + const delegateAmount = etherParse('500'); + const delegateAmount2 = etherParse('600'); + const undelegatedAmount = etherParse('999'); // Less fees + + await token.connect(user2).approve(delegationPool.address, delegateAmount.add(delegateAmount2)); + + // User1 undelegates 500 shares, requiring undelegation from indexers + await expect(delegationPool.connect(user1).undelegate(undelegateShares)) + .to.emit(delegationPool, 'UndelegationRequired') + .withArgs(user1.address, undelegatedAmount, undelegateShares); + + expect(await delegationPool.availableAssets()).to.equal(etherParse('0')); + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(undelegateShares); + + // User2's delegation should go towards the pending undelegation + await delegationPool.connect(user2).delegate(delegateAmount); + + expect(await delegationPool.availableAssets()).to.equal(etherParse('0')); + // The pending undelegation should be reduced by the new delegation + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(etherParse('500')); + + // User2's delegation should use up the remaining pending undelegation + await delegationPool.connect(user2).delegate(delegateAmount2); + // The amount available assets should now reflect the excess delegation + expect(await delegationPool.availableAssets()).to.equal(etherParse('100')); + // The pending undelegation should be reduced to zero + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(etherParse('0')); + }); + }); + + describe('withdraw()', () => { + beforeEach(async () => { + // Setup: User1 delegates and then undelegates + const delegateAmount = etherParse('1000'); + await token.connect(user1).approve(delegationPool.address, delegateAmount); + await delegationPool.connect(user1).delegate(delegateAmount); + await delegationPool.connect(user1).undelegate(etherParse('500')); + }); + + it('should reject withdrawal before lock period expires', async () => { + await expect(delegationPool.connect(user1).withdraw()).to.be.revertedWith('DP007'); + }); + + it('should allow withdrawal after lock period expires', async () => { + // Travel forward 28 days + await timeTravel(28 * 24 * 60 * 60); + + // Calculate expected net amount (after fee) + const unbondFeeRate = await staking.unbondFeeRate(); + const expectedFee = etherParse('500').mul(unbondFeeRate).div(1000000); + const expectedNet = etherParse('500').sub(expectedFee); + + const balanceBefore = await token.balanceOf(user1.address); + await expect(delegationPool.connect(user1).withdraw()) + .to.emit(delegationPool, 'Withdrawn') + .withArgs(user1.address, expectedNet); + + const balanceAfter = await token.balanceOf(user1.address); + expect(balanceAfter.sub(balanceBefore)).to.equal(expectedNet); + }); + + it('should reject withdrawal when no pending unbonds exist', async () => { + await expect(delegationPool.connect(user2).withdraw()).to.be.revertedWith('DP006'); + }); + }); + + describe('Unbond Fee Accounting', () => { + it('should track expected unbond fee when undelegating from indexers', async () => { + // Setup: delegate and have manager delegate to indexer + await token.connect(user1).approve(delegationPool.address, etherParse('10000')); + await delegationPool.connect(user1).delegate(etherParse('10000')); + + // Manager delegates all to indexer + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('10000')); + + // Verify available assets is 0 + expect(await delegationPool.availableAssets()).to.equal(0); + + // Get unbond fee rate + const unbondFeeRate = await staking.unbondFeeRate(); + + // User undelegates - must come from indexers since no available assets + const shares = await delegationPool.balanceOf(user1.address); + const sqtAmount = etherParse('5000'); + const sharesToUndelegate = shares.div(2); // Undelegate half + + // Calculate expected fee + const expectedFee = sqtAmount.mul(unbondFeeRate).div(1000000); + + // Undelegate should track the fee + await expect(delegationPool.connect(user1).undelegate(sharesToUndelegate)) + .to.emit(delegationPool, 'UnbondFeeTracked') + .withArgs(expectedFee, expectedFee); + + // Verify expectedUnbondFees was updated + expect(await delegationPool.expectedUnbondFees()).to.equal(expectedFee); + }); + + it('should collect unbond fee when undelegating from available assets', async () => { + // Setup: delegate but DON'T have manager delegate to indexers + await token.connect(user1).approve(delegationPool.address, etherParse('10000')); + await delegationPool.connect(user1).delegate(etherParse('10000')); + + // Verify all assets are available + expect(await delegationPool.availableAssets()).to.equal(etherParse('10000')); + + // User undelegates - should come from available assets + const shares = await delegationPool.balanceOf(user1.address); + const sharesToUndelegate = shares.div(2); // Undelegate half + + // Get unbond fee rate + const unbondFeeRate = await staking.unbondFeeRate(); + const expectedFee = etherParse('5000').mul(unbondFeeRate).div(1000000); + + await delegationPool.connect(user1).undelegate(sharesToUndelegate); + + // Verify accumulatedUnbondFees is tracked (not expectedUnbondFees) + expect(await delegationPool.accumulatedUnbondFees()).to.equal(expectedFee); + expect(await delegationPool.expectedUnbondFees()).to.equal(0); + }); + + it('should correctly account for expected fees in total assets', async () => { + // Setup: delegate and have manager delegate to indexer + await token.connect(user1).approve(delegationPool.address, etherParse('10000')); + await delegationPool.connect(user1).delegate(etherParse('10000')); + + // Manager delegates to indexer + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('8000')); + + // Total assets should be 10000 (8000 delegated + 2000 available) + let totalAssets = await delegationPool.getTotalAssets(); + expect(totalAssets).to.equal(etherParse('10000')); + + // User undelegates 5000 - 2000 from available, 3000 from indexers + const shares = await delegationPool.balanceOf(user1.address); + const sharesToUndelegate = shares.div(2); // Undelegate 5000 + + // Get unbond fee rate + const unbondFeeRate = await staking.unbondFeeRate(); + const totalFee = etherParse('5000').mul(unbondFeeRate).div(1000000); + const feeFromAvailable = etherParse('2000').mul(unbondFeeRate).div(1000000); + const feeFromIndexers = etherParse('3000').mul(unbondFeeRate).div(1000000); + + await delegationPool.connect(user1).undelegate(sharesToUndelegate); + + // Verify fees are tracked correctly + expect(await delegationPool.accumulatedUnbondFees()).to.equal(feeFromAvailable); + expect(await delegationPool.expectedUnbondFees()).to.equal(feeFromIndexers); + + // Total assets should now reflect the expected fee loss + totalAssets = await delegationPool.getTotalAssets(); + + // 5000 remaining, minus total fees + expect(totalAssets).to.equal(etherParse('5000').sub(totalFee)); + }); + + it('should accumulate expected fees across multiple undelegations', async () => { + // Setup: delegate and have manager delegate to indexer + await token.connect(user1).approve(delegationPool.address, etherParse('1000')); + await delegationPool.connect(user1).delegate(etherParse('1000')); + + await token.connect(user2).approve(delegationPool.address, etherParse('1000')); + await delegationPool.connect(user2).delegate(etherParse('1000')); + + // Manager delegates all to indexer + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('2000')); + + // Get unbond fee rate + const unbondFeeRate = await staking.unbondFeeRate(); + + // User1 undelegates 500 shares + const user1Shares = await delegationPool.balanceOf(user1.address); + await delegationPool.connect(user1).undelegate(user1Shares.div(2)); + + const user1Fee = etherParse('500').mul(unbondFeeRate).div(1000000); + expect(await delegationPool.expectedUnbondFees()).to.equal(user1Fee); + + // User2 undelegates 300 shares + const user2Shares = await delegationPool.balanceOf(user2.address); + const sharesToUndelegate = user2Shares.mul(3).div(10); + + await delegationPool.connect(user2).undelegate(sharesToUndelegate); + + // Calculate actual SQT amount for user2 undelegation based on current share price + // After user1 undelegated, remaining: 1500 total assets, 1500 total supply + // So share price is still 1:1, user2 undelegates 300 shares = 300 SQT + const user2Fee = etherParse('300').mul(unbondFeeRate).div(1000000); + + // Expected fees should be cumulative (allow small rounding error) + const totalExpectedFees = user1Fee.add(user2Fee); + const actualFees = await delegationPool.expectedUnbondFees(); + + // Allow 0.1% rounding error + expect(actualFees).to.be.closeTo(totalExpectedFees, totalExpectedFees.div(1000)); + }); + + it('should handle zero unbond fee rate', async () => { + // Set unbond fee to 0 + await staking.connect(root).setUnbondFeeRateBP(0); + + // Setup: delegate and undelegate from indexers + await token.connect(user1).approve(delegationPool.address, etherParse('1000')); + await delegationPool.connect(user1).delegate(etherParse('1000')); + + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('1000')); + + const shares = await delegationPool.balanceOf(user1.address); + await delegationPool.connect(user1).undelegate(shares); + + // No fee should be tracked + expect(await delegationPool.expectedUnbondFees()).to.equal(0); + + // Total assets should be 0 (all shares burned, no backing assets for shares) + expect(await delegationPool.getTotalAssets()).to.equal(0); + }); + }); + }); + + describe('Manager Functions', () => { + beforeEach(async () => { + // Setup: Pool has some assets + const delegateAmount = etherParse('5000'); + await token.connect(user1).approve(delegationPool.address, delegateAmount); + await delegationPool.connect(user1).delegate(delegateAmount); + }); + + describe('managerDelegate()', () => { + it('should allow manager to delegate pool funds to runner', async () => { + const delegateAmount = etherParse('2000'); + + await expect(delegationPool.connect(poolManager).managerDelegate(runner1.address, delegateAmount)) + .to.emit(delegationPool, 'ManagerDelegated') + .withArgs(runner1.address, delegateAmount); + + expect(await delegationPool.getDelegatedToIndexer(runner1.address)).to.equal(delegateAmount); + expect(await delegationPool.availableAssets()).to.equal(etherParse('3000')); // 5000 - 2000 + expect(await delegationPool.isActiveIndexer(runner1.address)).to.be.true; + expect(await delegationPool.getActiveIndexersCount()).to.equal(1); + + const activeIndexers = await delegationPool.getActiveIndexers(); + expect(activeIndexers[0]).to.equal(runner1.address); + }); + + it('should reject delegation from non-manager', async () => { + await expect( + delegationPool.connect(user1).managerDelegate(runner1.address, etherParse('1000')) + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should reject delegation with zero address', async () => { + await expect( + delegationPool.connect(poolManager).managerDelegate(constants.AddressZero, etherParse('1000')) + ).to.be.revertedWith('DP008'); + }); + + it('should reject delegation with zero amount', async () => { + await expect( + delegationPool.connect(poolManager).managerDelegate(runner1.address, 0) + ).to.be.revertedWith('DP001'); + }); + + it('should reject delegation exceeding available assets', async () => { + const excessiveAmount = etherParse('10000'); + await expect( + delegationPool.connect(poolManager).managerDelegate(runner1.address, excessiveAmount) + ).to.be.revertedWith('DP009'); + }); + }); + + describe.only('managerUndelegate()', () => { + beforeEach(async () => { + // Setup: Delegate to runner first + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('2000')); + }); + + it('should allow manager to undelegate from runner', async () => { + const undelegateAmount = etherParse('1000'); + + // Make sure all available assets are delegated + await delegationPool + .connect(poolManager) + .managerDelegate(runner2.address, await delegationPool.availableAssets()); + // User undelegates to make a pending undelegation + await delegationPool.connect(user1).undelegate(undelegateAmount); + + await expect(delegationPool.connect(poolManager).managerUndelegate(runner1.address, undelegateAmount)) + .to.emit(delegationPool, 'ManagerUndelegated') + .withArgs(runner1.address, undelegateAmount); + + expect(await delegationPool.getDelegatedToIndexer(runner1.address)).to.equal(etherParse('1000')); + }); + + it('should remove indexer from active list when delegation becomes zero', async () => { + const amount = etherParse('2000'); + await delegationPool + .connect(poolManager) + .managerDelegate(runner2.address, await delegationPool.availableAssets()); + await delegationPool.connect(user1).undelegate(amount); + await delegationPool.connect(poolManager).managerUndelegate(runner1.address, amount); + + expect(await delegationPool.getDelegatedToIndexer(runner1.address)).to.equal(0); + expect(await delegationPool.isActiveIndexer(runner1.address)).to.be.false; + expect(await delegationPool.getActiveIndexersCount()).to.equal(1); + }); + + it('should reject undelegation exceeding delegated amount', async () => { + const excessiveAmount = etherParse('3000'); + await expect( + delegationPool.connect(poolManager).managerUndelegate(runner1.address, excessiveAmount) + ).to.be.revertedWith('DP010'); + }); + + it('should reduce the pendingUndelegationsForUsers after manager undelegating', async () => { + const amount = etherParse('3000'); + await delegationPool.connect(poolManager).managerDelegate(runner1.address, amount); + + // All funds should be delegated + expect(await delegationPool.availableAssets()).to.equal(0); + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(0); + + await delegationPool.connect(user1).undelegate(amount); + + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(amount); + + await delegationPool.connect(poolManager).managerUndelegate(runner1.address, amount); + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(0); + }); + + // If manager undelegates before user undelegation, pendingUndelegationsForUsers should not increase + it('not allow manager to undelegate more than users have requested', async () => { + const amount = etherParse('3000'); + // Delegate all funds + await delegationPool.connect(poolManager).managerDelegate(runner1.address, amount); + + // All funds should be delegated + expect(await delegationPool.availableAssets()).to.equal(0); + expect(await delegationPool.pendingUndelegationsForUsers()).to.equal(0); + + await expect( + delegationPool.connect(poolManager).managerUndelegate(runner1.address, amount) + ).to.be.revertedWith('DP015'); + }); + }); + + describe('managerRedelegate()', () => { + beforeEach(async () => { + // Setup: Delegate to runner1 first + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('2000')); + }); + + it('should allow manager to redelegate between runners', async () => { + const redelegateAmount = etherParse('1000'); + + await expect( + delegationPool + .connect(poolManager) + .managerRedelegate(runner1.address, runner2.address, redelegateAmount) + ) + .to.emit(delegationPool, 'ManagerRedelegated') + .withArgs(runner1.address, runner2.address, redelegateAmount); + + expect(await delegationPool.getDelegatedToIndexer(runner1.address)).to.equal(etherParse('1000')); + expect(await delegationPool.getDelegatedToIndexer(runner2.address)).to.equal(redelegateAmount); + expect(await delegationPool.isActiveIndexer(runner2.address)).to.be.true; + expect(await delegationPool.getActiveIndexersCount()).to.equal(2); + }); + + it('should reject redelegation to same runner', async () => { + await expect( + delegationPool + .connect(poolManager) + .managerRedelegate(runner1.address, runner1.address, etherParse('1000')) + ).to.be.revertedWith('DP011'); + }); + + it('should reject redelegation with invalid addresses', async () => { + await expect( + delegationPool + .connect(poolManager) + .managerRedelegate(constants.AddressZero, runner2.address, etherParse('1000')) + ).to.be.revertedWith('DP008'); + }); + }); + }); + + describe('Auto Compound Functionality', () => { + beforeEach(async () => { + // Setup: Delegate to runner and have some delegations + const delegateAmount = etherParse('5000'); + await token.connect(user1).approve(delegationPool.address, delegateAmount); + await delegationPool.connect(user1).delegate(delegateAmount); + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('2000')); + }); + + it('should reject auto compound when no active delegations', async () => { + // Remove all delegations + await delegationPool.connect(poolManager).managerUndelegate(runner1.address, etherParse('2000')); + + await expect(delegationPool.autoCompound()).to.be.revertedWith('DP012'); + }); + + it('should handle auto compound when no rewards available', async () => { + // This should not revert but also not emit RewardsCompounded + const tx = await delegationPool.autoCompound(); + const receipt = await tx.wait(); + + // Should not have RewardsCompounded event + const rewardsCompoundedEvents = receipt.events?.filter((e) => e.event === 'RewardsCompounded') || []; + expect(rewardsCompoundedEvents.length).to.equal(0); + }); + }); + + describe('Auto Compound with MockStakingManager', () => { + let mockStakingManager: MockStakingManager; + let mockDelegationPool: DelegationPool; + + beforeEach(async () => { + // Deploy MockStakingManager + const MockStakingManagerFactory = await ethers.getContractFactory('MockStakingManager', root); + mockStakingManager = (await MockStakingManagerFactory.deploy(token.address)) as MockStakingManager; + + // Create a new Settings instance and register the mock + const SettingsFactory = await ethers.getContractFactory('Settings', root); + const mockSettings = (await SettingsFactory.deploy()) as Settings; + await mockSettings.setContractAddress(SQContracts.StakingManager, mockStakingManager.address); + await mockSettings.setContractAddress(SQContracts.SQToken, token.address); + await mockSettings.setContractAddress(SQContracts.Staking, staking.address); + await mockSettings.setContractAddress(SQContracts.EraManager, eraManager.address); + + // Deploy DelegationPool with mock settings + const DelegationPoolFactory = await ethers.getContractFactory('DelegationPool', root); + mockDelegationPool = (await DelegationPoolFactory.deploy()) as DelegationPool; + await mockDelegationPool.initialize(mockSettings.address, 10000); // 1% fee + await mockDelegationPool.transferOwnership(poolManager.address); + + // Setup initial delegation + await token.connect(user1).approve(mockDelegationPool.address, etherParse('10000')); + await mockDelegationPool.connect(user1).delegate(etherParse('10000')); + + // Manager delegates to runner + await mockDelegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('5000')); + }); + + it('should properly compound rewards and deduct fees', async () => { + // Set rewards for runner1 + const rewardAmount = etherParse('1000'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + + // Fund the mock staking manager with rewards + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + // Get initial state + const initialAvailableAssets = await mockDelegationPool.availableAssets(); + const initialTotalAssets = await mockDelegationPool.getTotalAssets(); + const initialFees = await mockDelegationPool.getAccumulatedFees(); + + // Execute auto compound + await expect(mockDelegationPool.autoCompound()) + .to.emit(mockDelegationPool, 'FeesDeducted') + .to.emit(mockDelegationPool, 'RewardsCompounded'); + + // Calculate expected values + const expectedFee = rewardAmount.mul(10000).div(1000000); // 1% of 1000 = 10 + const expectedCompoundAmount = rewardAmount.sub(expectedFee); // 1000 - 10 = 990 + + // Verify state changes + const finalAvailableAssets = await mockDelegationPool.availableAssets(); + const finalTotalAssets = await mockDelegationPool.getTotalAssets(); + const finalFees = await mockDelegationPool.getAccumulatedFees(); + + expect(finalAvailableAssets.sub(initialAvailableAssets)).to.equal(expectedCompoundAmount); + expect(finalFees.sub(initialFees)).to.equal(expectedFee); + expect(finalTotalAssets.sub(initialTotalAssets)).to.equal(expectedCompoundAmount); + }); + + it('should compound rewards from multiple runners', async () => { + // Setup delegation to runner2 + await mockDelegationPool.connect(poolManager).managerDelegate(runner2.address, etherParse('3000')); + + // Set rewards for both runners + const reward1 = etherParse('500'); + const reward2 = etherParse('300'); + await mockStakingManager.setRunnerRewards(runner1.address, reward1); + await mockStakingManager.setRunnerRewards(runner2.address, reward2); + + // Fund the mock staking manager with total rewards + const totalRewards = reward1.add(reward2); + await token.approve(mockStakingManager.address, totalRewards); + await mockStakingManager.fundRewards(totalRewards); + + // Get initial state + const initialAvailableAssets = await mockDelegationPool.availableAssets(); + const initialFees = await mockDelegationPool.getAccumulatedFees(); + + // Execute auto compound + await mockDelegationPool.autoCompound(); + + // Calculate expected values (1% fee on total 800) + const expectedFee = totalRewards.mul(10000).div(1000000); + const expectedCompoundAmount = totalRewards.sub(expectedFee); + + // Verify state changes + const finalAvailableAssets = await mockDelegationPool.availableAssets(); + const finalFees = await mockDelegationPool.getAccumulatedFees(); + + expect(finalAvailableAssets.sub(initialAvailableAssets)).to.equal(expectedCompoundAmount); + expect(finalFees.sub(initialFees)).to.equal(expectedFee); + }); + + it('should handle different fee rates correctly', async () => { + // Test with 5% fee + await mockDelegationPool.connect(poolManager).setFeeRate(50000); // 5% + + const rewardAmount = etherParse('1000'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + const initialFees = await mockDelegationPool.getAccumulatedFees(); + + await mockDelegationPool.autoCompound(); + + const expectedFee = rewardAmount.mul(50000).div(1000000); // 5% of 1000 = 50 + const finalFees = await mockDelegationPool.getAccumulatedFees(); + + expect(finalFees.sub(initialFees)).to.equal(expectedFee); + }); + + it('should not deduct fees when fee rate is 0%', async () => { + // Set fee rate to 0% + await mockDelegationPool.connect(poolManager).setFeeRate(0); + + const rewardAmount = etherParse('1000'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + const initialAvailableAssets = await mockDelegationPool.availableAssets(); + const initialFees = await mockDelegationPool.getAccumulatedFees(); + + const tx = await mockDelegationPool.autoCompound(); + const receipt = await tx.wait(); + + // Should not emit FeesDeducted event + const feesDeductedEvents = receipt.events?.filter((e) => e.event === 'FeesDeducted') || []; + expect(feesDeductedEvents.length).to.equal(0); + + // All rewards should go to available assets + const finalAvailableAssets = await mockDelegationPool.availableAssets(); + const finalFees = await mockDelegationPool.getAccumulatedFees(); + + expect(finalAvailableAssets.sub(initialAvailableAssets)).to.equal(rewardAmount); + expect(finalFees.sub(initialFees)).to.equal(0); + }); + + it('should allow fee collection after compounding', async () => { + // Compound with rewards + const rewardAmount = etherParse('1000'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + await mockDelegationPool.autoCompound(); + + const accumulatedFees = await mockDelegationPool.getAccumulatedFees(); + expect(accumulatedFees).to.be.gt(0); + + // Collect fees + const balanceBefore = await token.balanceOf(poolManager.address); + await expect(mockDelegationPool.connect(poolManager).collectAllFees()) + .to.emit(mockDelegationPool, 'FeesCollected') + .withArgs(poolManager.address, accumulatedFees); + + const balanceAfter = await token.balanceOf(poolManager.address); + expect(balanceAfter.sub(balanceBefore)).to.equal(accumulatedFees); + expect(await mockDelegationPool.getAccumulatedFees()).to.equal(0); + }); + + it('should increase share value after compounding', async () => { + // User2 deposits first + await token.connect(user2).approve(mockDelegationPool.address, etherParse('1000')); + await mockDelegationPool.connect(user2).delegate(etherParse('1000')); + + const user2SharesBefore = await mockDelegationPool.balanceOf(user2.address); + const user2ValueBefore = await mockDelegationPool.getDelegationAmount(user2.address); + + // Compound rewards + const rewardAmount = etherParse('1100'); // 11000 total assets, 1% fee = 11, compound 1089 + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + await mockDelegationPool.autoCompound(); + + // User2's shares should remain the same but value should increase + const user2SharesAfter = await mockDelegationPool.balanceOf(user2.address); + const user2ValueAfter = await mockDelegationPool.getDelegationAmount(user2.address); + + expect(user2SharesAfter).to.equal(user2SharesBefore); + expect(user2ValueAfter).to.be.gt(user2ValueBefore); + }); + + it('should distribute rewards proportionally among delegators', async () => { + // Setup: user1 has 10000, user2 adds 5000 + await token.connect(user2).approve(mockDelegationPool.address, etherParse('5000')); + await mockDelegationPool.connect(user2).delegate(etherParse('5000')); + + const user1SharesBefore = await mockDelegationPool.balanceOf(user1.address); + const user2SharesBefore = await mockDelegationPool.balanceOf(user2.address); + + // Compound rewards + const rewardAmount = etherParse('1500'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + await mockDelegationPool.autoCompound(); + + // Shares remain the same (no new shares minted) + expect(await mockDelegationPool.balanceOf(user1.address)).to.equal(user1SharesBefore); + expect(await mockDelegationPool.balanceOf(user2.address)).to.equal(user2SharesBefore); + + // But values should increase proportionally + const user1Value = await mockDelegationPool.getDelegationAmount(user1.address); + const user2Value = await mockDelegationPool.getDelegationAmount(user2.address); + + // User1 should have ~2/3 of total assets, user2 ~1/3 + const totalAssets = await mockDelegationPool.getTotalAssets(); + const user1Ratio = user1Value.mul(1000).div(totalAssets).toNumber(); + const user2Ratio = user2Value.mul(1000).div(totalAssets).toNumber(); + + // Check ratios are approximately 2:1 (666:333) + expect(user1Ratio).to.be.closeTo(666, 10); + expect(user2Ratio).to.be.closeTo(333, 10); + }); + + it('should update the share price correctly after compounding', async () => { + // Setup: user1 has 10000, user2 adds 5000 + await token.connect(user2).approve(mockDelegationPool.address, etherParse('5000')); + await token.connect(user3).approve(mockDelegationPool.address, etherParse('5000')); + + // Compound rewards + const rewardAmount = etherParse('1500'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + await startNewEra(eraManager); + + await mockDelegationPool.connect(user2).delegate(etherParse('5000')); + + await expect(mockDelegationPool.autoCompound()) + .to.emit(mockDelegationPool, 'RewardsCompounded') + .withArgs(rewardAmount); + // Compound rewards + // const rewardAmount = etherParse('1500'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + await startNewEra(eraManager); + + await expect(mockDelegationPool.autoCompound()) + .to.emit(mockDelegationPool, 'RewardsCompounded') + .withArgs(rewardAmount); + // TODO check shares, share price + + await mockDelegationPool.connect(user3).delegate(etherParse('5000')); + + expect(await mockDelegationPool.balanceOf(user2.address)).to.equal( + await mockDelegationPool.balanceOf(user2.address) + ); + }); + + describe('getPendingRewards()', () => { + it('should return 0 when RewardsDistributor is not configured', async () => { + // MockStakingManager tests don't have RewardsDistributor configured + const pendingRewards = await mockDelegationPool.getPendingRewards(); + expect(pendingRewards).to.equal(0); + }); + + it('should return 0 when no indexers are active', async () => { + // Undelegate all from runner1 + await mockDelegationPool.connect(poolManager).managerUndelegate(runner1.address, etherParse('5000')); + + // Check pending rewards + const pendingRewards = await mockDelegationPool.getPendingRewards(); + expect(pendingRewards).to.equal(0); + }); + }); + }); + + describe('View Functions', () => { + beforeEach(async () => { + // Setup pool with delegations + await token.connect(user1).approve(delegationPool.address, etherParse('1000')); + await delegationPool.connect(user1).delegate(etherParse('1000')); + await token.connect(user2).approve(delegationPool.address, etherParse('500')); + await delegationPool.connect(user2).delegate(etherParse('500')); + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('800')); + }); + + it('should return correct total assets', async () => { + const totalAssets = await delegationPool.getTotalAssets(); + expect(totalAssets).to.equal(etherParse('1500')); // 1000 + 500 delegated + }); + + it('should return correct user shares', async () => { + expect(await delegationPool.balanceOf(user1.address)).to.equal(etherParse('1000')); + expect(await delegationPool.balanceOf(user2.address)).to.equal(etherParse('500')); // 500 * 1000 / 1000 + }); + + it('should return correct delegation amounts', async () => { + expect(await delegationPool.getDelegationAmount(user1.address)).to.equal(etherParse('1000')); // 1000/1500 * 1500 + expect(await delegationPool.getDelegationAmount(user2.address)).to.equal(etherParse('500')); // 500/1500 * 1500 + }); + + it('should return correct indexer information', async () => { + expect(await delegationPool.getDelegatedToIndexer(runner1.address)).to.equal(etherParse('800')); + expect(await delegationPool.getActiveIndexersCount()).to.equal(1); + + const activeIndexers = await delegationPool.getActiveIndexers(); + expect(activeIndexers.length).to.equal(1); + expect(activeIndexers[0]).to.equal(runner1.address); + }); + + it('should return correct preview calculations', async () => { + const depositAmount = etherParse('500'); + const expectedShares = await delegationPool.previewDeposit(depositAmount); + expect(expectedShares).to.equal(etherParse('500')); // 500 * 1500 / 1500 + + const shareAmount = etherParse('100'); + const expectedAssets = await delegationPool.previewWithdraw(shareAmount); + expect(expectedAssets).to.equal(etherParse('100')); // 100 * 1500 / 1500 + }); + }); + + describe('Edge Cases and Error Conditions', () => { + it('should handle zero total shares correctly', async () => { + expect(await delegationPool.getDelegationAmount(user1.address)).to.equal(0); + expect(await delegationPool.previewWithdraw(etherParse('100'))).to.equal(0); + }); + + it('should handle settings update correctly', async () => { + const newSettings = await ethers.getContractFactory('Settings', root); + const newSettingsContract = await newSettings.deploy(); + + await delegationPool.connect(poolManager).updateSettings(newSettingsContract.address); + + expect(await delegationPool.settings()).to.equal(newSettingsContract.address); + }); + + it('should reject settings update from non-owner', async () => { + const newSettings = await ethers.getContractFactory('Settings', root); + const newSettingsContract = await newSettings.deploy(); + + await expect(delegationPool.connect(user1).updateSettings(newSettingsContract.address)).to.be.revertedWith( + 'Ownable: caller is not the owner' + ); + }); + + it('should handle empty active indexers array correctly', async () => { + expect(await delegationPool.getActiveIndexersCount()).to.equal(0); + const activeIndexers = await delegationPool.getActiveIndexers(); + expect(activeIndexers.length).to.equal(0); + }); + + it('should handle pending unbonds correctly for user with no unbonds', async () => { + const pendingUnbonds = await delegationPool.getPendingUnbonds(user1.address); + expect(pendingUnbonds.length).to.equal(0); + }); + }); + + describe('Integration with StakingManager', () => { + it('should properly integrate with StakingManager delegation flow', async () => { + // Setup: User delegates to pool, manager delegates to runner + await token.connect(user1).approve(delegationPool.address, etherParse('1000')); + await delegationPool.connect(user1).delegate(etherParse('1000')); + + // Check that pool can delegate to StakingManager + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('500')); + + // Start new era to finalize the delegation + await startNewEra(eraManager); + + // Verify delegation went through StakingManager + const delegationAmount = await stakingManager.getDelegationAmount(delegationPool.address, runner1.address); + expect(delegationAmount).to.equal(etherParse('500')); + }); + }); + + describe('Fee Management', () => { + it('should initialize with correct fee rate', async () => { + const feeRate = 10000; // 1% + const poolWithFee = await deployDelegationPool(feeRate); + expect(await poolWithFee.getFeeRate()).to.equal(feeRate); + expect(await poolWithFee.getAccumulatedFees()).to.equal(0); + }); + + it('should allow owner to set fee rate', async () => { + const newFeeRate = 25000; // 2.5% + await expect(delegationPool.connect(poolManager).setFeeRate(newFeeRate)) + .to.emit(delegationPool, 'FeeRateUpdated') + .withArgs(newFeeRate); + + expect(await delegationPool.getFeeRate()).to.equal(newFeeRate); + }); + + it('should reject fee rate changes from non-owner', async () => { + await expect(delegationPool.connect(user1).setFeeRate(10000)).to.be.revertedWith( + 'Ownable: caller is not the owner' + ); + }); + + it('should allow setting various fee rates including 0% and 100%', async () => { + // Test 0% + await delegationPool.connect(poolManager).setFeeRate(0); + expect(await delegationPool.getFeeRate()).to.equal(0); + + // Test 1% + await delegationPool.connect(poolManager).setFeeRate(10000); + expect(await delegationPool.getFeeRate()).to.equal(10000); + + // Test 10% + await delegationPool.connect(poolManager).setFeeRate(100000); + expect(await delegationPool.getFeeRate()).to.equal(100000); + + // Test 100% + await delegationPool.connect(poolManager).setFeeRate(1000000); + expect(await delegationPool.getFeeRate()).to.equal(1000000); + }); + + describe('Fee Collection', () => { + beforeEach(async () => { + // Set up pool with 1% fee + await delegationPool.connect(poolManager).setFeeRate(10000); // 1% + + // User delegates to pool + await token.connect(user1).approve(delegationPool.address, etherParse('1000')); + await delegationPool.connect(user1).delegate(etherParse('1000')); + + // Manager delegates to runner + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('500')); + }); + + it('should reject fee collection when no fees accumulated', async () => { + await expect(delegationPool.connect(poolManager).collectFees(etherParse('100'))).to.be.revertedWith( + 'DP014' + ); + + await expect(delegationPool.connect(poolManager).collectAllFees()).to.be.revertedWith('DP014'); + }); + + it('should reject fee collection from non-owner', async () => { + await expect(delegationPool.connect(user1).collectFees(etherParse('100'))).to.be.revertedWith( + 'Ownable: caller is not the owner' + ); + + await expect(delegationPool.connect(user1).collectAllFees()).to.be.revertedWith( + 'Ownable: caller is not the owner' + ); + }); + + it('should reject zero amount fee collection', async () => { + await expect(delegationPool.connect(poolManager).collectFees(0)).to.be.revertedWith('DP013'); + }); + + it('should reject collecting more fees than accumulated', async () => { + // Simulate some accumulated fees by manually setting them for testing + // In real scenario, fees would be accumulated through autoCompound + await expect(delegationPool.connect(poolManager).collectFees(etherParse('1'))).to.be.revertedWith( + 'DP014' + ); + }); + }); + + describe('Auto Compound with Fees', () => { + beforeEach(async () => { + // Set up pool with 5% fee + await delegationPool.connect(poolManager).setFeeRate(50000); // 5% + + // User delegates to pool + await token.connect(user1).approve(delegationPool.address, etherParse('1000')); + await delegationPool.connect(user1).delegate(etherParse('1000')); + + // Manager delegates to runner + await delegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('500')); + }); + + it('should handle auto compound with zero fee rate', async () => { + // Set fee rate to 0% + await delegationPool.connect(poolManager).setFeeRate(0); + + // Auto compound should work without fee deduction + const tx = await delegationPool.autoCompound(); + const receipt = await tx.wait(); + + // Should not have FeesDeducted event + const feesDeductedEvents = receipt.events?.filter((e) => e.event === 'FeesDeducted') || []; + expect(feesDeductedEvents.length).to.equal(0); + }); + + it('should handle auto compound when no rewards available', async () => { + // Auto compound without any rewards should not generate fees + const tx = await delegationPool.autoCompound(); + const receipt = await tx.wait(); + + // Should not have FeesDeducted event + const feesDeductedEvents = receipt.events?.filter((e) => e.event === 'FeesDeducted') || []; + expect(feesDeductedEvents.length).to.equal(0); + + expect(await delegationPool.getAccumulatedFees()).to.equal(0); + }); + + // Note: Testing actual fee deduction during autoCompound would require + // complex setup with actual reward distribution, which depends on the + // full ecosystem being active. The fee calculation logic is tested + // in the unit tests above. + }); + + describe('View Functions', () => { + it('should return correct fee information', async () => { + const feeRate = 25000; // 2.5% + await delegationPool.connect(poolManager).setFeeRate(feeRate); + + expect(await delegationPool.getFeeRate()).to.equal(feeRate); + expect(await delegationPool.getAccumulatedFees()).to.equal(0); + }); + }); + }); + + describe('Era-Based Share Pricing', () => { + let mockStakingManager: MockStakingManager; + let mockDelegationPool: DelegationPool; + + beforeEach(async () => { + // Deploy MockStakingManager + const MockStakingManagerFactory = await ethers.getContractFactory('MockStakingManager', root); + mockStakingManager = (await MockStakingManagerFactory.deploy(token.address)) as MockStakingManager; + + // Create a new Settings instance and register the mock + const SettingsFactory = await ethers.getContractFactory('Settings', root); + const mockSettings = (await SettingsFactory.deploy()) as Settings; + await mockSettings.setContractAddress(SQContracts.StakingManager, mockStakingManager.address); + await mockSettings.setContractAddress(SQContracts.SQToken, token.address); + await mockSettings.setContractAddress(SQContracts.Staking, staking.address); + await mockSettings.setContractAddress(SQContracts.EraManager, eraManager.address); + + // Deploy DelegationPool with mock settings + const DelegationPoolFactory = await ethers.getContractFactory('DelegationPool', root); + mockDelegationPool = (await DelegationPoolFactory.deploy()) as DelegationPool; + await mockDelegationPool.initialize(mockSettings.address, 0); // 0% fee for simpler math + await mockDelegationPool.transferOwnership(poolManager.address); + + // Setup initial delegation + await token.connect(user1).approve(mockDelegationPool.address, etherParse('10000')); + }); + + it('should initialize with correct share price', async () => { + const initialPrice = await mockDelegationPool.getCurrentSharePrice(); + expect(initialPrice).to.equal(etherParse('1')); // 1e18 = 1:1 ratio + expect(await mockDelegationPool.getLastPriceUpdateEra()).to.equal(0); + }); + + it('should maintain share price within same era', async () => { + // User1 deposits in era 1 + await mockDelegationPool.connect(user1).delegate(etherParse('1000')); + const priceAfterFirstDeposit = await mockDelegationPool.getCurrentSharePrice(); + const eraAfterFirstDeposit = await eraManager.eraNumber(); + + // User2 deposits in same era (no era transition) + await token.connect(user2).approve(mockDelegationPool.address, etherParse('500')); + await mockDelegationPool.connect(user2).delegate(etherParse('500')); + + // Price should be the same + expect(await mockDelegationPool.getCurrentSharePrice()).to.equal(priceAfterFirstDeposit); + // Era should still be the same + expect(await eraManager.eraNumber()).to.equal(eraAfterFirstDeposit); + + // Both users should get same price per share + const user1Shares = await mockDelegationPool.balanceOf(user1.address); + const user2Shares = await mockDelegationPool.balanceOf(user2.address); + expect(user1Shares).to.equal(etherParse('1000')); + expect(user2Shares).to.equal(etherParse('500')); + }); + + it('should update share price when era changes and rewards are compounded', async () => { + // Initial delegation - user1 deposits 10,000 SQT + await mockDelegationPool.connect(user1).delegate(etherParse('10000')); + await mockDelegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('5000')); + + const initialPrice = await mockDelegationPool.getCurrentSharePrice(); + const initialEra = await eraManager.eraNumber(); + const initialTotalAssets = await mockDelegationPool.getTotalAssets(); + const user1InitialShares = await mockDelegationPool.balanceOf(user1.address); + + // User1 should have 10,000 shares (1:1 ratio at initial price of 1e18) + expect(user1InitialShares).to.equal(etherParse('10000')); + + // Add rewards (10% return = 1,000 SQT) + const rewardAmount = etherParse('1000'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + // Move to next era + await startNewEra(eraManager); + + // Trigger price update by calling autoCompound + // This will: + // 1. Update share price to current era (locks it at old value of 1e18) + // 2. Claim and add rewards (1,000 SQT) + // 3. Total assets now = 11,000 SQT with 10,000 shares + await expect(mockDelegationPool.autoCompound()).to.emit(mockDelegationPool, 'SharePriceUpdated'); + + // Verify total assets increased by rewards + const newTotalAssets = await mockDelegationPool.getTotalAssets(); + expect(newTotalAssets.sub(initialTotalAssets)).to.equal(rewardAmount); + expect(newTotalAssets).to.equal(etherParse('11000')); + + // Now move to another era so the new price takes effect + await startNewEra(eraManager); + + // User2 deposits the SAME amount as user1 (10,000 SQT) + // But now the price has increased due to rewards + await token.connect(user2).approve(mockDelegationPool.address, etherParse('10000')); + await mockDelegationPool.connect(user2).delegate(etherParse('10000')); + + const user2Shares = await mockDelegationPool.balanceOf(user2.address); + + // Price should have increased from initial (was 1e18, now should be ~1.1e18) + const finalPrice = await mockDelegationPool.getCurrentSharePrice(); + expect(finalPrice).to.be.gt(initialPrice); + + // User2 should receive FEWER shares than user1 because price increased + // User1: 10,000 SQT → 10,000 shares (at price 1e18) + // User2: 10,000 SQT → ~9,090 shares (at price 1.1e18) + // Expected: 10,000 * 1e18 / 1.1e18 ≈ 9,090.909... + expect(user2Shares).to.be.lt(user1InitialShares); + + // More precisely: user2 should get approximately (10000 * 10000) / 11000 shares + // = 9090.909... shares + const expectedUser2Shares = etherParse('10000').mul(etherParse('10000')).div(etherParse('11000')); + expect(user2Shares).to.be.closeTo(expectedUser2Shares, etherParse('1')); + + // Verify both users' values + const user1Value = await mockDelegationPool.getDelegationAmount(user1.address); + const user2Value = await mockDelegationPool.getDelegationAmount(user2.address); + + // User1 should have more value because they earned rewards + // User1: 10,000 shares * (21,000 total assets / 19,090.909 total shares) ≈ 11,000 SQT + // User2: ~9,090.909 shares * (21,000 / 19,090.909) ≈ 10,000 SQT + expect(user1Value).to.be.gt(etherParse('10000')); + expect(user1Value).to.be.closeTo(etherParse('11000'), etherParse('10')); + expect(user2Value).to.be.closeTo(etherParse('10000'), etherParse('10')); + expect(user2Value).to.be.lt(user1Value); + + // Era should be updated + expect(await mockDelegationPool.getLastPriceUpdateEra()).to.equal(initialEra.add(2)); + }); + + it('should prevent reward front-running attack', async () => { + // Setup: user1 has 10,000 SQT staked in era 1 + await mockDelegationPool.connect(user1).delegate(etherParse('10000')); + await mockDelegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('5000')); + + const user1InitialShares = await mockDelegationPool.balanceOf(user1.address); + + // Set rewards that will be claimed (1000 SQT = 10% return on 10k) + const rewardAmount = etherParse('1000'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + // Move to era 2 + await startNewEra(eraManager); + + // Give user2 tokens and have them try to front-run + await token.transfer(user2.address, etherParse('10000')); + await token.connect(user2).approve(mockDelegationPool.address, etherParse('10000')); + + // User2 deposits same amount as user1 just before autoCompound + await mockDelegationPool.connect(user2).delegate(etherParse('10000')); + + const user2SharesAfterDeposit = await mockDelegationPool.balanceOf(user2.address); + + // CRITICAL: autoCompound() calls _updateSharePriceIfNeeded() FIRST + // This locks the price at the OLD (pre-reward) value for this era + // So user2's deposit used the old price + // Then rewards are added + await mockDelegationPool.autoCompound(); + + const totalAssetsAfter = await mockDelegationPool.getTotalAssets(); + + // User2 deposited the same amount as user1, but: + // - User1 got shares at 1:1 when pool value was 10,000 + // - User2 ALSO got shares at 1:1 (because era price was locked before rewards) + // - So they have equal shares + expect(user1InitialShares).to.equal(user2SharesAfterDeposit); + + // Both users now share the 1000 SQT reward equally (500 each) + // WITHOUT era-based pricing, user2 would have gotten far fewer shares + // (because they'd be buying at post-reward price) + // and thus would have benefited from rewards they didn't earn + + // The key is: both users' shares were minted at the SAME price + // User2 didn't get a discount by entering before compound + const user1Value = await mockDelegationPool.getDelegationAmount(user1.address); + const user2Value = await mockDelegationPool.getDelegationAmount(user2.address); + + // Both should have approximately equal value (they have equal shares and deposited equally) + // Each put in 10k and now has ~10.5k (their 10k + half of 1k reward) + expect(user1Value).to.be.closeTo(user2Value, etherParse('1')); // Within 1 SQT + + // This demonstrates the protection: + // Era-based pricing ensures new depositors pay the CURRENT era price, + // not a discounted pre-reward price, preventing them from "stealing" rewards + }); + + it('should calculate shares using era price not live price', async () => { + // Setup with initial delegation + await mockDelegationPool.connect(user1).delegate(etherParse('10000')); + await mockDelegationPool.connect(poolManager).managerDelegate(runner1.address, etherParse('5000')); + + // Lock in initial era price + const era1Price = await mockDelegationPool.getCurrentSharePrice(); + + // Add rewards but don't compound yet (live value increases but era price doesn't) + const rewardAmount = etherParse('5000'); + await mockStakingManager.setRunnerRewards(runner1.address, rewardAmount); + await token.approve(mockStakingManager.address, rewardAmount); + await mockStakingManager.fundRewards(rewardAmount); + + // User2 deposits - should use era1Price, not live price + await token.connect(user2).approve(mockDelegationPool.address, etherParse('10000')); + await mockDelegationPool.connect(user2).delegate(etherParse('10000')); + + // User2 should receive shares based on era1Price (1e18), not the increased live value + const user2Shares = await mockDelegationPool.balanceOf(user2.address); + expect(user2Shares).to.equal(etherParse('10000')); // 10000 * 1e18 / 1e18 + }); + + it('should handle first deposit in new era correctly', async () => { + // First era - initial deposit + await mockDelegationPool.connect(user1).delegate(etherParse('1000')); + + // Move to era 2 + await startNewEra(eraManager); + + // Second deposit triggers era update + await token.connect(user2).approve(mockDelegationPool.address, etherParse('1000')); + await mockDelegationPool.connect(user2).delegate(etherParse('1000')); + + // Both should have same shares (no rewards between deposits) + expect(await mockDelegationPool.balanceOf(user1.address)).to.equal( + await mockDelegationPool.balanceOf(user2.address) + ); + }); + + it('should emit SharePriceUpdated event on era transition', async () => { + await mockDelegationPool.connect(user1).delegate(etherParse('1000')); + + // Move to next era + await startNewEra(eraManager); + + const currentEra = await eraManager.eraNumber(); + + // Trigger update + await token.connect(user2).approve(mockDelegationPool.address, etherParse('500')); + await expect(mockDelegationPool.connect(user2).delegate(etherParse('500'))) + .to.emit(mockDelegationPool, 'SharePriceUpdated') + .withArgs(currentEra, await mockDelegationPool.getCurrentSharePrice()); + }); + }); +});