diff --git a/.gitignore b/.gitignore index b3e172a..6f96aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ CLAUDE.md node_modules/ artifacts/ +hyperion/artifacts/ ccagent webapp/node_modules @@ -14,3 +15,4 @@ keystore_password.txt # Foundry build artifacts cache/ out/ +findings/* diff --git a/README.md b/README.md index 3e99b3a..c2eb781 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,17 @@ Decentralized liquid staking protocol for QRL Zond. Deposit QRL, receive stQRL, ## Overview -QuantaPool enables QRL holders to participate in Proof-of-Stake validation without running their own validator nodes. Users deposit QRL and receive stQRL, a rebasing token whose balance automatically adjusts as validators earn rewards or experience slashing. +QuantaPool enables QRL holders to participate in Proof-of-Stake validation without running their own validator nodes. Users deposit QRL and receive stQRL, a fixed-balance token where `balanceOf()` returns stable shares and `getQRLValue()` returns the current QRL equivalent (which grows with rewards). ### Key Features - **Liquid Staking**: Receive stQRL tokens that can be transferred while underlying QRL earns rewards -- **Rebasing Token**: Balance increases automatically as validators earn rewards (Lido-style) -- **Slashing-Safe**: Rebasing design handles slashing events by proportionally reducing all holders' balances +- **Fixed-Balance Token**: Share balance stays constant (tax-friendly), QRL value grows with rewards +- **Slashing-Safe**: Fixed-balance design handles slashing by proportionally reducing all holders' QRL value - **Trustless Sync**: No oracle needed - rewards detected via EIP-4895 balance increases -- **Post-Quantum Secure**: Built on QRL's Dilithium signature scheme +- **Post-Quantum Secure**: Built on QRL's Dilithium ML-DSA-87 signature scheme +- **Production Infrastructure**: Terraform + Ansible for automated validator deployment +- **Monitoring Stack**: Prometheus, Grafana dashboards, and Alertmanager with Discord/Telegram alerts ## Architecture @@ -27,50 +29,118 @@ QuantaPool enables QRL holders to participate in Proof-of-Stake validation witho │ - Accepts deposits, mints stQRL shares │ │ - Queues and processes withdrawals │ │ - Trustless reward sync via balance checking │ -│ - Funds validators (MVP: stays in contract) │ +│ - Funds validators via beacon deposit contract │ └───────────────────────────┬─────────────────────────────────┘ │ mintShares() / burnShares() ▼ ┌─────────────────────────────────────────────────────────────┐ │ stQRL-v2.sol │ -│ - Rebasing ERC-20 token │ -│ - Shares-based accounting (Lido-style) │ -│ - balanceOf = shares × totalPooledQRL / totalShares │ +│ - Fixed-balance QRC-20 token │ +│ - Shares-based accounting (wstETH-style) │ +│ - balanceOf = shares, getQRLValue = QRL equivalent │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ ValidatorManager.sol │ │ - Tracks validator states (pending → active → exited) │ +│ - Stores Dilithium pubkeys (2,592 bytes) │ │ - MVP: single trusted operator model │ -└─────────────────────────────────────────────────────────────┘ +└───────────────────────────┬─────────────────────────────────┘ + │ + ┌─────────────┴─────────────┐ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────┐ +│ Infrastructure │ │ Monitoring │ +│ Terraform + Ansible │ │ Prometheus + Grafana │ +│ gzond, qrysm nodes │ │ Contract exporter + alerts │ +└──────────────────────┘ └──────────────────────────────┘ +``` + +## Project Structure + +``` +QuantaPool/ +├── contracts/solidity/ # Solidity smart contracts (source of truth) +│ ├── stQRL-v2.sol # Fixed-balance liquid staking token +│ ├── DepositPool-v2.sol # Deposits, withdrawals, reward sync +│ └── ValidatorManager.sol # Validator lifecycle tracking +├── hyperion/ # Hyperion language port (.hyp mirrors) +│ ├── contracts/ # Auto-synced from Solidity sources +│ └── test/ +├── test/ # Foundry test suite (178 tests) +│ ├── stQRL-v2.t.sol # 55 core token tests +│ ├── DepositPool-v2.t.sol # 68 deposit/withdrawal tests +│ └── ValidatorManager.t.sol# 55 validator lifecycle tests +├── infrastructure/ # Production validator deployment +│ ├── terraform/ # Hetzner Cloud provisioning +│ ├── ansible/ # Node configuration (gzond, qrysm) +│ ├── scripts/ # deploy.sh, failover.sh, health-check.sh +│ └── docs/ # Runbooks and deployment guides +├── monitoring/ # Observability stack +│ ├── prometheus/ # Scrape config + alert rules +│ ├── grafana/ # Dashboards (validator, contract, system) +│ ├── alertmanager/ # Discord/Telegram routing by severity +│ └── contract-exporter/ # Custom Node.js exporter for on-chain metrics +├── key-management/ # Validator key lifecycle scripts +├── scripts/ # Build & deployment automation +├── config/ # Network deployment configs +└── docs/ # Architecture docs ``` ## Contracts -| Contract | Purpose | -|----------|---------| -| `stQRL-v2.sol` | Rebasing liquid staking token | -| `DepositPool-v2.sol` | User entry point, deposits/withdrawals, reward sync | -| `ValidatorManager.sol` | Validator lifecycle tracking | +| Contract | LOC | Purpose | +|----------|-----|---------| +| `stQRL-v2.sol` | 496 | Fixed-balance liquid staking token (shares-based) | +| `DepositPool-v2.sol` | 773 | User entry point, deposits/withdrawals, trustless reward sync | +| `ValidatorManager.sol` | 349 | Validator lifecycle: Pending → Active → Exiting → Exited | -## How Rebasing Works +Solidity sources are maintained under `contracts/solidity/`. Hyperion mirrors live separately under `hyperion/contracts/` so the `.hyp` port does not get mixed into the Foundry tree. + +## How Fixed-Balance Model Works 1. User deposits 100 QRL when pool has 1000 QRL and 1000 shares -2. User receives 100 shares, balance shows 100 QRL +2. User receives 100 shares, `balanceOf()` = 100 shares 3. Validators earn 50 QRL rewards (pool now has 1050 QRL) -4. User's balance = 100 × 1050 / 1000 = **105 QRL** -5. User's shares unchanged, but balance "rebased" upward +4. User's `balanceOf()` still = **100 shares** (unchanged, tax-friendly) +5. User's `getQRLValue()` = 100 × 1050 / 1000 = **105 QRL** If slashing occurs (pool drops to 950 QRL): -- User's balance = 100 × 950 / 1000 = **95 QRL** +- User's `balanceOf()` still = **100 shares** +- User's `getQRLValue()` = 100 × 950 / 1000 = **95 QRL** - Loss distributed proportionally to all holders +## Infrastructure + +Production-ready validator infrastructure using Terraform and Ansible. + +**Components provisioned:** +- **Primary validator node** — gzond (execution) + qrysm-beacon + qrysm-validator +- **Backup validator node** — hot standby with failover script +- **Monitoring server** — Prometheus, Grafana, Alertmanager + +**Key management scripts** handle the full Dilithium key lifecycle: generation, encryption, backup, restore, and import to the validator client. + +See `infrastructure/docs/DEPLOYMENT.md` for the step-by-step deployment guide and `infrastructure/docs/runbooks/` for operational procedures. + +## Monitoring + +Docker Compose stack providing full observability: + +- **Prometheus**: Scrapes metrics from gzond, qrysm-beacon, qrysm-validator, and the custom contract exporter +- **Grafana**: Three dashboards — Validator Overview, Contract State, System Resources +- **Alertmanager**: Routes alerts by severity (Critical/Warning/Info) to Discord and Telegram +- **Contract Exporter**: Custom Node.js service exposing on-chain metrics (stQRL exchange rate, TVL, deposit queue, validator count) + +See `monitoring/README.md` for setup and configuration. + ## Development ### Prerequisites - [Foundry](https://book.getfoundry.sh/getting-started/installation) +- `hypc` for Hyperion compilation/deployment ### Build @@ -90,28 +160,54 @@ forge test forge test -vvv ``` +### Hyperion workflow + +```bash +npm run sync:hyperion +npm run compile:hyperion +npm run deploy:hyperion +``` + +See `hyperion/README.md` for the dedicated Hyperion layout and deploy config. + +### CI + +GitHub Actions runs `forge fmt --check`, `forge build --sizes`, and `forge test -vvv` on every push and pull request. + ## Test Coverage -- **46 tests passing** (stQRL-v2 + DepositPool-v2) -- Rebasing math, multi-user rewards, slashing scenarios -- Withdrawal flow with delay enforcement -- Access control and pause functionality +- **178 tests passing** (55 stQRL-v2 + 68 DepositPool-v2 + 55 ValidatorManager) +- Share/QRL conversion math, multi-user rewards, slashing scenarios +- Withdrawal flow with 128-block delay enforcement +- Validator lifecycle (registration, activation, exit, slashing) +- Virtual shares to prevent first-depositor attacks +- Access control, pause functionality, and reentrancy protection - Fuzz testing for edge cases ## Status -**v2 contracts ready** - awaiting Zond testnet deployment. +**v2 contracts ready** — infrastructure and monitoring built, awaiting Zond testnet deployment. ### Roadmap +- [x] v2 fixed-balance contracts with audit remediations +- [x] Validator infrastructure (Terraform + Ansible) +- [x] Monitoring and alerting stack +- [x] Key management tooling - [ ] Deploy v2 contracts to Zond testnet - [ ] Integrate staking UI into [qrlwallet.com](https://qrlwallet.com) -- [ ] Add wstQRL wrapper (non-rebasing, for DeFi compatibility) ## Security - Slither static analysis completed (0 critical/high findings) -- See `slither-report.txt` for full audit results +- Virtual shares (1e3) to prevent first-depositor/inflation attacks +- See `slither-report.txt` for full analysis results + +## Acknowledgments + +- [Lido](https://lido.fi/) and [Rocket Pool](https://rocketpool.net/) for pioneering liquid staking designs +- [The QRL Core Team](https://www.theqrl.org/) for building post-quantum secure blockchain infrastructure +- [Robyer](https://github.com/robyer) for community feedback on the fixed-balance token model (tax implications of rebasing) ## License diff --git a/config/testnet-hyperion.json b/config/testnet-hyperion.json new file mode 100644 index 0000000..cb08fec --- /dev/null +++ b/config/testnet-hyperion.json @@ -0,0 +1,10 @@ +{ + "provider": "https://qrlwallet.com/api/zond-rpc/testnet", + "chainId": 32382, + "txConfirmations": 12, + "contracts": { + "stQRLV2": "", + "depositPoolV2": "", + "validatorManager": "" + } +} diff --git a/contracts/DepositPool-v2.sol b/contracts/solidity/DepositPool-v2.sol similarity index 67% rename from contracts/DepositPool-v2.sol rename to contracts/solidity/DepositPool-v2.sol index 40f7fa1..efe4df4 100644 --- a/contracts/DepositPool-v2.sol +++ b/contracts/solidity/DepositPool-v2.sol @@ -38,9 +38,12 @@ interface IstQRL { function mintShares(address to, uint256 qrlAmount) external returns (uint256); function burnShares(address from, uint256 sharesAmount) external returns (uint256); function updateTotalPooledQRL(uint256 newAmount) external; + function lockShares(address account, uint256 sharesAmount) external; + function unlockShares(address account, uint256 sharesAmount) external; function totalPooledQRL() external view returns (uint256); function totalShares() external view returns (uint256); function sharesOf(address account) external view returns (uint256); + function lockedSharesOf(address account) external view returns (uint256); function getSharesByPooledQRL(uint256 qrlAmount) external view returns (uint256); function getPooledQRLByShares(uint256 sharesAmount) external view returns (uint256); } @@ -60,8 +63,8 @@ contract DepositPoolV2 { // CONSTANTS // ============================================================= - /// @notice Minimum stake for a Zond validator - uint256 public constant VALIDATOR_STAKE = 10_000 ether; + /// @notice Minimum stake for a Zond validator (MaxEffectiveBalance from Zond config) + uint256 public constant VALIDATOR_STAKE = 40_000 ether; /// @notice Zond beacon chain deposit contract address public constant DEPOSIT_CONTRACT = 0x4242424242424242424242424242424242424242; @@ -78,6 +81,9 @@ contract DepositPoolV2 { /// @notice Minimum blocks to wait before claiming withdrawal uint256 public constant WITHDRAWAL_DELAY = 128; // ~2 hours on Zond + /// @notice Absolute minimum for minDepositFloor (dust prevention, ~1e15 wei) + uint256 public constant ABSOLUTE_MIN_DEPOSIT = 0.001 ether; + // ============================================================= // STORAGE // ============================================================= @@ -97,6 +103,9 @@ contract DepositPoolV2 { /// @notice Minimum deposit amount uint256 public minDeposit; + /// @notice Adjustable floor for minDeposit (owner can lower after deployment) + uint256 public minDepositFloor = 100 ether; + /// @notice Paused state bool public paused; @@ -115,8 +124,11 @@ contract DepositPoolV2 { bool claimed; // Whether claimed } - /// @notice Withdrawal requests by user - mapping(address => WithdrawalRequest) public withdrawalRequests; + /// @notice Withdrawal requests by user (supports multiple requests via array) + mapping(address => WithdrawalRequest[]) public withdrawalRequests; + + /// @notice Next withdrawal request ID to process for each user + mapping(address => uint256) public nextWithdrawalIndex; /// @notice Total shares locked in withdrawal queue uint256 public totalWithdrawalShares; @@ -154,10 +166,14 @@ contract DepositPoolV2 { event ValidatorFunded(uint256 indexed validatorId, bytes pubkey, uint256 amount); event WithdrawalReserveFunded(uint256 amount); + event WithdrawalCancelled(address indexed user, uint256 indexed requestId, uint256 shares); event MinDepositUpdated(uint256 newMinDeposit); + event MinDepositFloorUpdated(uint256 newFloor); event Paused(address account); event Unpaused(address account); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event StQRLSet(address indexed stQRL); + event EmergencyWithdrawal(address indexed to, uint256 amount); // ============================================================= // ERRORS @@ -169,8 +185,9 @@ contract DepositPoolV2 { error ZeroAddress(); error ZeroAmount(); error BelowMinDeposit(); + error BelowMinDepositFloor(); + error BelowAbsoluteMin(); error InsufficientShares(); - error WithdrawalPending(); error NoWithdrawalPending(); error WithdrawalNotReady(); error InsufficientReserve(); @@ -181,6 +198,9 @@ contract DepositPoolV2 { error InvalidWithdrawalCredentials(); error TransferFailed(); error StQRLNotSet(); + error StQRLAlreadySet(); + error InvalidWithdrawalIndex(); + error ExceedsRecoverableAmount(); // ============================================================= // MODIFIERS @@ -209,7 +229,7 @@ contract DepositPoolV2 { constructor() { owner = msg.sender; - minDeposit = 0.1 ether; // 0.1 QRL minimum + minDeposit = 100 ether; // 100 QRL minimum lastSyncBlock = block.number; } @@ -265,14 +285,20 @@ contract DepositPoolV2 { /** * @notice Request withdrawal of stQRL - * @dev Locks shares until claim is processed + * @dev Users can have multiple pending withdrawal requests * @param shares Amount of shares to withdraw + * @return requestId The ID of this withdrawal request * @return qrlAmount Current QRL value of shares (may change before claim) */ - function requestWithdrawal(uint256 shares) external nonReentrant whenNotPaused returns (uint256 qrlAmount) { + function requestWithdrawal(uint256 shares) + external + nonReentrant + whenNotPaused + returns (uint256 requestId, uint256 qrlAmount) + { if (shares == 0) revert ZeroAmount(); - if (stQRL.sharesOf(msg.sender) < shares) revert InsufficientShares(); - if (withdrawalRequests[msg.sender].shares > 0) revert WithdrawalPending(); + uint256 unlockedShares = stQRL.sharesOf(msg.sender) - stQRL.lockedSharesOf(msg.sender); + if (unlockedShares < shares) revert InsufficientShares(); // Sync rewards first _syncRewards(); @@ -280,57 +306,84 @@ contract DepositPoolV2 { // Calculate current QRL value qrlAmount = stQRL.getPooledQRLByShares(shares); - // Create withdrawal request - withdrawalRequests[msg.sender] = - WithdrawalRequest({shares: shares, qrlAmount: qrlAmount, requestBlock: block.number, claimed: false}); + // Lock shares so they cannot be transferred + stQRL.lockShares(msg.sender, shares); + + // Create withdrawal request (push to array for multiple requests support) + requestId = withdrawalRequests[msg.sender].length; + withdrawalRequests[msg.sender].push( + WithdrawalRequest({shares: shares, qrlAmount: qrlAmount, requestBlock: block.number, claimed: false}) + ); totalWithdrawalShares += shares; emit WithdrawalRequested(msg.sender, shares, qrlAmount, block.number); - return qrlAmount; + return (requestId, qrlAmount); } /** - * @notice Claim a pending withdrawal - * @dev Burns shares and transfers QRL to user - * Follows CEI pattern: Checks → Effects → Interactions + * @notice Claim the next pending withdrawal (FIFO order) + * @dev Burns shares and transfers QRL to user. + * Skips cancelled requests (claimed=true, shares=0) automatically. + * Uses actual burned QRL value for all accounting to prevent discrepancies. * @return qrlAmount Amount of QRL received */ function claimWithdrawal() external nonReentrant returns (uint256 qrlAmount) { - WithdrawalRequest storage request = withdrawalRequests[msg.sender]; + uint256 requestIndex = nextWithdrawalIndex[msg.sender]; + uint256 totalRequests = withdrawalRequests[msg.sender].length; + + // Skip cancelled requests (shares=0 && claimed=true) + // Bounded: user can only create cancellations via their own txs, + // so the practical depth is small. Tested up to 500 in suite. + while (requestIndex < totalRequests && withdrawalRequests[msg.sender][requestIndex].shares == 0) { + requestIndex++; + } + if (requestIndex >= totalRequests) revert NoWithdrawalPending(); + + // Update index to account for skipped cancelled requests + nextWithdrawalIndex[msg.sender] = requestIndex; + + WithdrawalRequest storage request = withdrawalRequests[msg.sender][requestIndex]; // === CHECKS === - if (request.shares == 0) revert NoWithdrawalPending(); if (request.claimed) revert NoWithdrawalPending(); if (block.number < request.requestBlock + WITHDRAWAL_DELAY) revert WithdrawalNotReady(); // Sync rewards first (external call, but to trusted stQRL contract) _syncRewards(); - // Cache values before state changes + // Cache shares before state changes uint256 sharesToBurn = request.shares; - // Recalculate QRL amount (may have changed due to rewards/slashing) - qrlAmount = stQRL.getPooledQRLByShares(sharesToBurn); + // Use the QRL amount captured at request time. + // After fundWithdrawalReserve() reclassified pooled QRL into the reserve, + // totalPooledQRL is reduced, which distorts the share→QRL conversion. + // The request's qrlAmount was calculated BEFORE reclassification, so it + // reflects the true value of the shares at the time they were locked. + qrlAmount = request.qrlAmount; + + // Unlock shares before burning + stQRL.unlockShares(msg.sender, sharesToBurn); + + // Burn shares (return value ignored — see comment above) + stQRL.burnShares(msg.sender, sharesToBurn); // Check if we have enough in reserve if (withdrawalReserve < qrlAmount) revert InsufficientReserve(); - // === EFFECTS (all state changes before external calls) === - // Mark as claimed and clean up request - delete withdrawalRequests[msg.sender]; + // === EFFECTS (state changes using actual burned amount) === + request.claimed = true; + nextWithdrawalIndex[msg.sender] = requestIndex + 1; totalWithdrawalShares -= sharesToBurn; withdrawalReserve -= qrlAmount; - // === INTERACTIONS === - // Burn shares (uses cached sharesToBurn, returns actual QRL value) - uint256 burnedQRL = stQRL.burnShares(msg.sender, sharesToBurn); - - // Update total pooled QRL using the actual burned amount - uint256 newTotalPooled = stQRL.totalPooledQRL() - burnedQRL; - stQRL.updateTotalPooledQRL(newTotalPooled); + // NOTE: We do NOT decrement totalPooledQRL here. + // The QRL being claimed comes from withdrawalReserve, which is already + // outside totalPooledQRL. The totalPooledQRL was decremented when the + // reserve was funded (see fundWithdrawalReserve). Decrementing here + // would double-count and cause _syncRewards() to detect phantom rewards. - // Transfer QRL to user (last external call) + // === INTERACTION (ETH transfer last) === (bool success,) = msg.sender.call{value: qrlAmount}(""); if (!success) revert TransferFailed(); @@ -339,32 +392,56 @@ contract DepositPoolV2 { } /** - * @notice Cancel a pending withdrawal request - * @dev Returns shares to normal circulating state + * @notice Cancel a specific pending withdrawal request + * @dev Returns shares to normal circulating state. Only unclaimed requests can be cancelled. + * @param requestId The index of the withdrawal request to cancel */ - function cancelWithdrawal() external nonReentrant { - WithdrawalRequest storage request = withdrawalRequests[msg.sender]; + function cancelWithdrawal(uint256 requestId) external nonReentrant { + if (requestId >= withdrawalRequests[msg.sender].length) revert InvalidWithdrawalIndex(); + if (requestId < nextWithdrawalIndex[msg.sender]) revert InvalidWithdrawalIndex(); // Already processed + + WithdrawalRequest storage request = withdrawalRequests[msg.sender][requestId]; if (request.shares == 0) revert NoWithdrawalPending(); if (request.claimed) revert NoWithdrawalPending(); - totalWithdrawalShares -= request.shares; - delete withdrawalRequests[msg.sender]; + uint256 shares = request.shares; + totalWithdrawalShares -= shares; + request.shares = 0; + request.claimed = true; // Mark as processed + + // Unlock shares so they can be transferred again + stQRL.unlockShares(msg.sender, shares); + + emit WithdrawalCancelled(msg.sender, requestId, shares); } /** - * @notice Get withdrawal request details + * @notice Get withdrawal request details by index * @param user Address to query + * @param requestId Index of the withdrawal request */ - function getWithdrawalRequest(address user) + function getWithdrawalRequest(address user, uint256 requestId) external view - returns (uint256 shares, uint256 currentQRLValue, uint256 requestBlock, bool canClaim, uint256 blocksRemaining) + returns ( + uint256 shares, + uint256 currentQRLValue, + uint256 requestBlock, + bool canClaim, + uint256 blocksRemaining, + bool claimed + ) { - WithdrawalRequest storage request = withdrawalRequests[user]; + if (requestId >= withdrawalRequests[user].length) { + return (0, 0, 0, false, 0, false); + } + + WithdrawalRequest storage request = withdrawalRequests[user][requestId]; shares = request.shares; currentQRLValue = stQRL.getPooledQRLByShares(shares); requestBlock = request.requestBlock; + claimed = request.claimed; uint256 unlockBlock = request.requestBlock + WITHDRAWAL_DELAY; canClaim = !request.claimed && request.shares > 0 && block.number >= unlockBlock @@ -373,6 +450,18 @@ contract DepositPoolV2 { blocksRemaining = block.number >= unlockBlock ? 0 : unlockBlock - block.number; } + /** + * @notice Get the number of withdrawal requests for a user + * @param user Address to query + * @return total Total number of requests + * @return pending Number of pending (unprocessed) requests + */ + function getWithdrawalRequestCount(address user) external view returns (uint256 total, uint256 pending) { + total = withdrawalRequests[user].length; + uint256 nextIndex = nextWithdrawalIndex[user]; + pending = total > nextIndex ? total - nextIndex : 0; + } + // ============================================================= // REWARD SYNC FUNCTIONS // ============================================================= @@ -448,7 +537,7 @@ contract DepositPoolV2 { * @notice Fund a validator with beacon chain deposit * @dev Only owner can call. Sends VALIDATOR_STAKE to beacon deposit contract. * @param pubkey Dilithium public key (2592 bytes) - * @param withdrawal_credentials Must point to this contract (0x01 prefix) + * @param withdrawal_credentials Must point to this contract (0x01 + 11 zero bytes + address) * @param signature Dilithium signature (4595 bytes) * @param deposit_data_root SSZ hash of deposit data * @return validatorId The new validator's ID @@ -464,9 +553,15 @@ contract DepositPoolV2 { if (signature.length != SIGNATURE_LENGTH) revert InvalidSignatureLength(); if (withdrawal_credentials.length != CREDENTIALS_LENGTH) revert InvalidCredentialsLength(); - // Verify withdrawal credentials point to this contract (0x01 prefix) - // First byte should be 0x01, remaining 31 bytes should be this contract's address - if (withdrawal_credentials[0] != 0x01) revert InvalidWithdrawalCredentials(); + // Verify withdrawal credentials point to this contract + // Format: 0x01 (1 byte) + 11 zero bytes + contract address (20 bytes) = 32 bytes + // This ensures validator withdrawals come to this contract + bytes32 expectedCredentials = bytes32(abi.encodePacked(bytes1(0x01), bytes11(0), address(this))); + bytes32 actualCredentials; + assembly { + actualCredentials := calldataload(withdrawal_credentials.offset) + } + if (actualCredentials != expectedCredentials) revert InvalidWithdrawalCredentials(); bufferedQRL -= VALIDATOR_STAKE; validatorId = validatorCount++; @@ -497,12 +592,29 @@ contract DepositPoolV2 { } /** - * @notice Add QRL to withdrawal reserve (from validator exits) - * @dev Called when validators exit and funds return to contract + * @notice Move QRL from pooled accounting to withdrawal reserve + * @dev Called by owner to earmark pooled QRL for pending withdrawals. + * This does NOT accept ETH - it reclassifies existing contract balance + * from totalPooledQRL to withdrawalReserve. + * + * Invariant maintained: address(this).balance = totalPooledQRL + withdrawalReserve + * + * For MVP: pooled QRL is in the contract, so we just reclassify. + * For production: call this after validator exit proceeds arrive and + * _syncRewards() has already attributed them to totalPooledQRL. + * + * @param amount Amount to move from pooled to withdrawal reserve */ - function fundWithdrawalReserve() external payable { - withdrawalReserve += msg.value; - emit WithdrawalReserveFunded(msg.value); + function fundWithdrawalReserve(uint256 amount) external onlyOwner { + if (amount == 0) revert ZeroAmount(); + + uint256 currentPooled = stQRL.totalPooledQRL(); + if (amount > currentPooled) revert InsufficientBuffer(); + + withdrawalReserve += amount; + stQRL.updateTotalPooledQRL(currentPooled - amount); + + emit WithdrawalReserveFunded(amount); } // ============================================================= @@ -561,12 +673,14 @@ contract DepositPoolV2 { // ============================================================= /** - * @notice Set the stQRL token contract + * @notice Set the stQRL token contract (one-time only) * @param _stQRL Address of stQRL contract */ function setStQRL(address _stQRL) external onlyOwner { if (_stQRL == address(0)) revert ZeroAddress(); + if (address(stQRL) != address(0)) revert StQRLAlreadySet(); stQRL = IstQRL(_stQRL); + emit StQRLSet(_stQRL); } /** @@ -574,10 +688,22 @@ contract DepositPoolV2 { * @param _minDeposit New minimum deposit */ function setMinDeposit(uint256 _minDeposit) external onlyOwner { + if (_minDeposit < minDepositFloor) revert BelowMinDepositFloor(); minDeposit = _minDeposit; emit MinDepositUpdated(_minDeposit); } + /** + * @notice Set the adjustable floor for minDeposit + * @dev Allows owner to lower the floor post-deployment (e.g., if QRL appreciates) + * @param _floor New floor value (must be >= ABSOLUTE_MIN_DEPOSIT) + */ + function setMinDepositFloor(uint256 _floor) external onlyOwner { + if (_floor < ABSOLUTE_MIN_DEPOSIT) revert BelowAbsoluteMin(); + minDepositFloor = _floor; + emit MinDepositFloorUpdated(_floor); + } + /** * @notice Pause the contract */ @@ -606,14 +732,26 @@ contract DepositPoolV2 { /** * @notice Emergency withdrawal of stuck funds - * @dev Only for recovery of accidentally sent tokens, not pool funds + * @dev Only for recovery of accidentally sent tokens, not pool funds. + * Can only withdraw excess balance that's not part of pooled QRL or withdrawal reserve. * @param to Recipient address * @param amount Amount to withdraw */ function emergencyWithdraw(address to, uint256 amount) external onlyOwner { if (to == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + + // Calculate recoverable amount: balance - pooled funds - withdrawal reserve + uint256 totalProtocolFunds = (address(stQRL) != address(0) ? stQRL.totalPooledQRL() : 0) + withdrawalReserve; + uint256 currentBalance = address(this).balance; + uint256 recoverableAmount = currentBalance > totalProtocolFunds ? currentBalance - totalProtocolFunds : 0; + + if (amount > recoverableAmount) revert ExceedsRecoverableAmount(); + (bool success,) = to.call{value: amount}(""); if (!success) revert TransferFailed(); + + emit EmergencyWithdrawal(to, amount); } // ============================================================= @@ -623,13 +761,15 @@ contract DepositPoolV2 { /** * @notice Receive QRL (from validator exits, rewards, or direct sends) * @dev Rewards arrive via EIP-4895 WITHOUT triggering this function. - * This is only triggered by explicit transfers. We add to reserve - * assuming these are validator exit proceeds. + * This is only triggered by explicit transfers (e.g. validator exit + * proceeds via a regular transaction). + * + * Incoming ETH is NOT auto-classified. It increases address(this).balance, + * and the next _syncRewards() call will detect it as a balance increase + * and attribute it to totalPooledQRL. The owner can then call + * fundWithdrawalReserve() to reclassify it for pending withdrawals. */ receive() external payable { - // Funds received here are assumed to be from validator exits - // They go to withdrawal reserve - withdrawalReserve += msg.value; - emit WithdrawalReserveFunded(msg.value); + // No automatic accounting - _syncRewards() will detect the balance change } } diff --git a/contracts/ValidatorManager.sol b/contracts/solidity/ValidatorManager.sol similarity index 96% rename from contracts/ValidatorManager.sol rename to contracts/solidity/ValidatorManager.sol index 0669e55..1f03731 100644 --- a/contracts/ValidatorManager.sol +++ b/contracts/solidity/ValidatorManager.sol @@ -20,8 +20,8 @@ contract ValidatorManager { // CONSTANTS // ============================================================= - /// @notice Zond validator stake amount - uint256 public constant VALIDATOR_STAKE = 10_000 ether; + /// @notice Zond validator stake amount (MaxEffectiveBalance from Zond config) + uint256 public constant VALIDATOR_STAKE = 40_000 ether; /// @notice Dilithium pubkey length uint256 private constant PUBKEY_LENGTH = 2592; @@ -219,16 +219,17 @@ contract ValidatorManager { */ function markValidatorSlashed(uint256 validatorId) external onlyOwner { Validator storage v = validators[validatorId]; - if (v.status != ValidatorStatus.Active && v.status != ValidatorStatus.Exiting) { + ValidatorStatus previousStatus = v.status; + + if (previousStatus != ValidatorStatus.Active && previousStatus != ValidatorStatus.Exiting) { revert InvalidStatusTransition(); } v.status = ValidatorStatus.Slashed; v.exitedBlock = block.number; - if (v.status == ValidatorStatus.Active) { - activeValidatorCount--; - } + // Decrement counter - both Active and Exiting validators count toward activeValidatorCount + activeValidatorCount--; emit ValidatorSlashed(validatorId, block.number); } diff --git a/contracts/stQRL-v2.sol b/contracts/solidity/stQRL-v2.sol similarity index 65% rename from contracts/stQRL-v2.sol rename to contracts/solidity/stQRL-v2.sol index 04cf211..6a4250f 100644 --- a/contracts/stQRL-v2.sol +++ b/contracts/solidity/stQRL-v2.sol @@ -2,28 +2,29 @@ pragma solidity ^0.8.24; /** - * @title stQRL v2 - Rebasing Staked QRL Token + * @title stQRL v2 - Fixed-Balance Staked QRL Token * @author QuantaPool - * @notice Liquid staking token for QRL Zond. Balance automatically adjusts - * as validators earn rewards or experience slashing. + * @notice Liquid staking token for QRL Zond. Balance represents shares (fixed), + * use getQRLValue() to see current QRL equivalent. * * @dev Key concepts: - * - Internally tracks "shares" (fixed after deposit) - * - Externally exposes "balance" in QRL (changes with rewards/slashing) - * - balanceOf(user) = shares[user] * totalPooledQRL / totalShares + * - balanceOf() returns raw shares (stable, tax-friendly) + * - getQRLValue() returns QRL equivalent (changes with rewards/slashing) + * - Exchange rate: totalPooledQRL / totalShares * - * This is similar to Lido's stETH rebasing model. + * This is a fixed-balance model (like wstETH) rather than rebasing (like stETH). + * Chosen for cleaner tax implications - balance only changes on deposit/withdraw. * * Example: * 1. User deposits 100 QRL when pool has 1000 QRL and 1000 shares - * 2. User receives 100 shares + * 2. User receives 100 shares, balanceOf() = 100 * 3. Validators earn 50 QRL rewards (pool now has 1050 QRL) - * 4. User's balance = 100 * 1050 / 1000 = 105 QRL - * 5. User's shares unchanged, but balance "rebased" upward + * 4. User's balanceOf() still = 100 shares (unchanged) + * 5. User's getQRLValue() = 100 * 1050 / 1000 = 105 QRL * * If slashing occurs (pool drops to 950 QRL): - * - User's balance = 100 * 950 / 1000 = 95 QRL - * - Loss distributed proportionally to all holders + * - User's balanceOf() still = 100 shares + * - User's getQRLValue() = 100 * 950 / 1000 = 95 QRL */ contract stQRLv2 { // ============================================================= @@ -37,6 +38,13 @@ contract stQRLv2 { /// @notice Initial shares per QRL (1:1 at launch) uint256 private constant INITIAL_SHARES_PER_QRL = 1; + /// @notice Virtual shares offset to prevent first depositor attack (donation attack) + /// @dev Adding virtual shares/assets creates a floor that makes share inflation attacks + /// economically unviable. With 1e3 virtual offset, an attacker would need to + /// donate ~1000x more than they could steal. See OpenZeppelin ERC4626 for details. + uint256 private constant VIRTUAL_SHARES = 1e3; + uint256 private constant VIRTUAL_ASSETS = 1e3; + // ============================================================= // SHARE STORAGE // ============================================================= @@ -47,11 +55,13 @@ contract stQRLv2 { /// @notice Shares held by each account mapping(address => uint256) private _shares; - /// @notice Allowances for transferFrom (in shares, not QRL) - /// @dev We store allowances in shares for consistency, but approve/allowance - /// functions work in QRL amounts for ERC-20 compatibility + /// @notice Allowances for transferFrom (in shares) + /// @dev All amounts in this contract are shares, not QRL mapping(address => mapping(address => uint256)) private _allowances; + /// @notice Shares locked for pending withdrawals (cannot be transferred) + mapping(address => uint256) private _lockedShares; + // ============================================================= // POOL STORAGE // ============================================================= @@ -77,13 +87,10 @@ contract stQRLv2 { // EVENTS // ============================================================= - // ERC-20 standard events + // QRC-20 standard events (values are in shares) event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); - // Share-specific events (for off-chain tracking) - event TransferShares(address indexed from, address indexed to, uint256 sharesValue); - // Pool events event TotalPooledQRLUpdated(uint256 previousAmount, uint256 newAmount); event SharesMinted(address indexed to, uint256 sharesAmount, uint256 qrlAmount); @@ -107,6 +114,7 @@ contract stQRLv2 { error InsufficientBalance(); error InsufficientAllowance(); error DepositPoolAlreadySet(); + error InsufficientUnlockedShares(); // ============================================================= // MODIFIERS @@ -136,49 +144,47 @@ contract stQRLv2 { } // ============================================================= - // ERC-20 VIEW FUNCTIONS + // QRC-20 VIEW FUNCTIONS // ============================================================= /** - * @notice Returns the total supply of stQRL tokens - * @dev This equals totalPooledQRL (the QRL value, not shares) - * @return Total stQRL in circulation (in QRL terms) + * @notice Returns the total supply of stQRL tokens (in shares) + * @dev Use totalPooledQRL() for the QRL value + * @return Total stQRL shares in circulation */ function totalSupply() external view returns (uint256) { - return _totalPooledQRL; + return _totalShares; } /** - * @notice Returns the stQRL balance of an account - * @dev Calculated as: shares * totalPooledQRL / totalShares - * This value changes as rewards accrue or slashing occurs + * @notice Returns the stQRL balance of an account (in shares) + * @dev Returns raw shares - stable value that only changes on deposit/withdraw + * Use getQRLValue() for the current QRL equivalent * @param account The address to query - * @return The account's stQRL balance in QRL terms + * @return The account's share balance */ function balanceOf(address account) public view returns (uint256) { - return getPooledQRLByShares(_shares[account]); + return _shares[account]; } /** - * @notice Returns the allowance for a spender - * @dev Stored internally as shares, converted to QRL for compatibility + * @notice Returns the allowance for a spender (in shares) * @param _owner The token owner * @param spender The approved spender - * @return The allowance in QRL terms + * @return The allowance in shares */ function allowance(address _owner, address spender) public view returns (uint256) { - return getPooledQRLByShares(_allowances[_owner][spender]); + return _allowances[_owner][spender]; } // ============================================================= - // ERC-20 WRITE FUNCTIONS + // QRC-20 WRITE FUNCTIONS // ============================================================= /** - * @notice Transfer stQRL to another address - * @dev Transfers the equivalent shares, not a fixed QRL amount + * @notice Transfer stQRL shares to another address * @param to Recipient address - * @param amount Amount of stQRL (in QRL terms) to transfer + * @param amount Amount of shares to transfer * @return success True if transfer succeeded */ function transfer(address to, uint256 amount) external whenNotPaused returns (bool) { @@ -187,9 +193,9 @@ contract stQRLv2 { } /** - * @notice Approve a spender to transfer stQRL on your behalf + * @notice Approve a spender to transfer stQRL shares on your behalf * @param spender The address to approve - * @param amount The amount of stQRL (in QRL terms) to approve + * @param amount The amount of shares to approve * @return success True if approval succeeded */ function approve(address spender, uint256 amount) external returns (bool) { @@ -198,23 +204,22 @@ contract stQRLv2 { } /** - * @notice Transfer stQRL from one address to another (with approval) + * @notice Transfer stQRL shares from one address to another (with approval) * @param from Source address * @param to Destination address - * @param amount Amount of stQRL (in QRL terms) to transfer + * @param amount Amount of shares to transfer * @return success True if transfer succeeded */ function transferFrom(address from, address to, uint256 amount) external whenNotPaused returns (bool) { - uint256 sharesToTransfer = getSharesByPooledQRL(amount); - if (sharesToTransfer == 0) revert ZeroAmount(); + if (amount == 0) revert ZeroAmount(); - uint256 currentAllowanceShares = _allowances[from][msg.sender]; - if (currentAllowanceShares < sharesToTransfer) revert InsufficientAllowance(); + uint256 currentAllowance = _allowances[from][msg.sender]; + if (currentAllowance < amount) revert InsufficientAllowance(); // Decrease allowance (unless unlimited) - if (currentAllowanceShares != type(uint256).max) { - _allowances[from][msg.sender] = currentAllowanceShares - sharesToTransfer; - emit Approval(from, msg.sender, getPooledQRLByShares(_allowances[from][msg.sender])); + if (currentAllowance != type(uint256).max) { + _allowances[from][msg.sender] = currentAllowance - amount; + emit Approval(from, msg.sender, _allowances[from][msg.sender]); } _transfer(from, to, amount); @@ -227,7 +232,7 @@ contract stQRLv2 { /** * @notice Returns the total shares in existence - * @dev Shares are the internal accounting unit, not exposed as balanceOf + * @dev Same as totalSupply() in fixed-balance model * @return Total shares */ function totalShares() external view returns (uint256) { @@ -236,7 +241,7 @@ contract stQRLv2 { /** * @notice Returns the shares held by an account - * @dev Shares don't change with rewards - only balanceOf does + * @dev Same as balanceOf() in fixed-balance model * @param account The address to query * @return The account's share balance */ @@ -244,35 +249,41 @@ contract stQRLv2 { return _shares[account]; } + /** + * @notice Returns the current QRL value of an account's shares + * @dev This is what would have been balanceOf() in a rebasing model + * Value changes as rewards accrue or slashing occurs + * @param account The address to query + * @return The account's stQRL value in QRL terms + */ + function getQRLValue(address account) public view returns (uint256) { + return getPooledQRLByShares(_shares[account]); + } + /** * @notice Convert a QRL amount to shares - * @dev shares = qrlAmount * totalShares / totalPooledQRL + * @dev shares = qrlAmount * (totalShares + VIRTUAL_SHARES) / (totalPooledQRL + VIRTUAL_ASSETS) + * Virtual offsets prevent first depositor inflation attacks. * @param qrlAmount The QRL amount to convert * @return The equivalent number of shares */ function getSharesByPooledQRL(uint256 qrlAmount) public view returns (uint256) { - // If no shares exist yet, 1:1 ratio - if (_totalShares == 0) { - return qrlAmount * INITIAL_SHARES_PER_QRL; - } - // If no pooled QRL (shouldn't happen with shares > 0, but be safe) - if (_totalPooledQRL == 0) { - return qrlAmount * INITIAL_SHARES_PER_QRL; - } - return (qrlAmount * _totalShares) / _totalPooledQRL; + // Use virtual shares/assets to prevent donation attacks + // Even with 0 real shares/assets, the virtual offset ensures fair pricing + return (qrlAmount * (_totalShares + VIRTUAL_SHARES)) / (_totalPooledQRL + VIRTUAL_ASSETS); } /** * @notice Convert shares to QRL amount - * @dev qrlAmount = shares * totalPooledQRL / totalShares + * @dev qrlAmount = shares * (totalPooledQRL + VIRTUAL_ASSETS) / (totalShares + VIRTUAL_SHARES) + * Virtual offsets prevent first depositor inflation attacks. * @param sharesAmount The shares to convert * @return The equivalent QRL amount */ function getPooledQRLByShares(uint256 sharesAmount) public view returns (uint256) { - if (_totalShares == 0) { - return 0; - } - return (sharesAmount * _totalPooledQRL) / _totalShares; + // Use virtual shares/assets to prevent donation attacks + // This ensures consistent pricing with getSharesByPooledQRL + return (sharesAmount * (_totalPooledQRL + VIRTUAL_ASSETS)) / (_totalShares + VIRTUAL_SHARES); } /** @@ -286,14 +297,12 @@ contract stQRLv2 { /** * @notice Returns the current exchange rate (QRL per share, scaled by 1e18) - * @dev Useful for UI display and calculations + * @dev Useful for UI display and calculations. Uses virtual offsets for consistency. * @return Exchange rate (1e18 = 1:1) */ function getExchangeRate() external view returns (uint256) { - if (_totalShares == 0) { - return 1e18; - } - return (_totalPooledQRL * 1e18) / _totalShares; + // Use virtual offsets for consistency with share conversion functions + return ((_totalPooledQRL + VIRTUAL_ASSETS) * 1e18) / (_totalShares + VIRTUAL_SHARES); } // ============================================================= @@ -321,8 +330,7 @@ contract stQRLv2 { // This allows DepositPool to batch updates emit SharesMinted(to, shares, qrlAmount); - emit Transfer(address(0), to, qrlAmount); - emit TransferShares(address(0), to, shares); + emit Transfer(address(0), to, shares); return shares; } @@ -352,8 +360,7 @@ contract stQRLv2 { // Note: totalPooledQRL is updated separately via updateTotalPooledQRL emit SharesBurned(from, sharesAmount, qrlAmount); - emit Transfer(from, address(0), qrlAmount); - emit TransferShares(from, address(0), sharesAmount); + emit Transfer(from, address(0), sharesAmount); return qrlAmount; } @@ -361,7 +368,7 @@ contract stQRLv2 { /** * @notice Update the total pooled QRL * @dev Called by DepositPool after syncing rewards/slashing - * This is what causes balanceOf to change for all holders + * This changes the exchange rate (affects getQRLValue, not balanceOf) * @param newTotalPooledQRL The new total pooled QRL amount */ function updateTotalPooledQRL(uint256 newTotalPooledQRL) external onlyDepositPool { @@ -370,42 +377,67 @@ contract stQRLv2 { emit TotalPooledQRLUpdated(previousAmount, newTotalPooledQRL); } + // ============================================================= + // SHARE LOCKING FUNCTIONS + // ============================================================= + + /** + * @notice Lock shares for a pending withdrawal + * @dev Only callable by DepositPool. Locked shares cannot be transferred. + * @param account The account whose shares to lock + * @param sharesAmount Number of shares to lock + */ + function lockShares(address account, uint256 sharesAmount) external onlyDepositPool { + _lockedShares[account] += sharesAmount; + } + + /** + * @notice Unlock shares after withdrawal claim or cancellation + * @dev Only callable by DepositPool + * @param account The account whose shares to unlock + * @param sharesAmount Number of shares to unlock + */ + function unlockShares(address account, uint256 sharesAmount) external onlyDepositPool { + _lockedShares[account] -= sharesAmount; + } + + /** + * @notice Returns the locked shares for an account + * @param account The address to query + * @return The number of locked shares + */ + function lockedSharesOf(address account) external view returns (uint256) { + return _lockedShares[account]; + } + // ============================================================= // INTERNAL FUNCTIONS // ============================================================= /** - * @dev Internal transfer logic + * @dev Internal transfer logic - amount is in shares */ function _transfer(address from, address to, uint256 amount) internal { if (from == address(0)) revert ZeroAddress(); if (to == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); + if (_shares[from] < amount) revert InsufficientBalance(); + if (_shares[from] - _lockedShares[from] < amount) revert InsufficientUnlockedShares(); - uint256 sharesToTransfer = getSharesByPooledQRL(amount); - if (_shares[from] < sharesToTransfer) revert InsufficientBalance(); - - _shares[from] -= sharesToTransfer; - _shares[to] += sharesToTransfer; + _shares[from] -= amount; + _shares[to] += amount; emit Transfer(from, to, amount); - emit TransferShares(from, to, sharesToTransfer); } /** - * @dev Internal approve logic + * @dev Internal approve logic - amount is in shares */ function _approve(address _owner, address spender, uint256 amount) internal { if (_owner == address(0)) revert ZeroAddress(); if (spender == address(0)) revert ZeroAddress(); - uint256 sharesToApprove = getSharesByPooledQRL(amount); - // Handle max approval - if (amount == type(uint256).max) { - sharesToApprove = type(uint256).max; - } - - _allowances[_owner][spender] = sharesToApprove; + _allowances[_owner][spender] = amount; emit Approval(_owner, spender, amount); } diff --git a/contracts/v1-deprecated/DepositPool.sol b/contracts/solidity/v1-deprecated/DepositPool.sol similarity index 100% rename from contracts/v1-deprecated/DepositPool.sol rename to contracts/solidity/v1-deprecated/DepositPool.sol diff --git a/contracts/v1-deprecated/OperatorRegistry.sol b/contracts/solidity/v1-deprecated/OperatorRegistry.sol similarity index 100% rename from contracts/v1-deprecated/OperatorRegistry.sol rename to contracts/solidity/v1-deprecated/OperatorRegistry.sol diff --git a/contracts/v1-deprecated/RewardsOracle.sol b/contracts/solidity/v1-deprecated/RewardsOracle.sol similarity index 100% rename from contracts/v1-deprecated/RewardsOracle.sol rename to contracts/solidity/v1-deprecated/RewardsOracle.sol diff --git a/contracts/TestToken.sol b/contracts/solidity/v1-deprecated/TestToken.sol similarity index 100% rename from contracts/TestToken.sol rename to contracts/solidity/v1-deprecated/TestToken.sol diff --git a/contracts/v1-deprecated/stQRL.sol b/contracts/solidity/v1-deprecated/stQRL.sol similarity index 100% rename from contracts/v1-deprecated/stQRL.sol rename to contracts/solidity/v1-deprecated/stQRL.sol diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9075924 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,184 @@ +# QuantaPool v2 Architecture + +## Overview + +QuantaPool is a decentralized liquid staking protocol for QRL Zond. Users deposit QRL and receive stQRL tokens representing their stake. The protocol uses a **fixed-balance token model** (like Lido's wstETH) where share balances remain constant and QRL value grows with rewards. + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User │ +└───────────────────────────┬─────────────────────────────────┘ + │ deposit() / requestWithdrawal() + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DepositPool-v2.sol │ +│ - Accepts QRL deposits, mints stQRL shares │ +│ - Manages withdrawal queue (128-block delay) │ +│ - Trustless reward sync via balance checking │ +│ - Funds validators via beacon deposit contract │ +└───────────────────────────┬─────────────────────────────────┘ + │ mintShares() / burnShares() + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ stQRL-v2.sol │ +│ - Fixed-balance QRC-20 token (shares-based) │ +│ - balanceOf() = shares (stable, tax-friendly) │ +│ - getQRLValue() = QRL equivalent (grows with rewards) │ +│ - Virtual shares prevent first-depositor attacks │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ValidatorManager.sol │ +│ - Tracks validator lifecycle (Pending → Active → Exited) │ +│ - Stores Dilithium pubkeys (2592 bytes) │ +│ - MVP: single trusted operator model │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Zond Beacon Deposit Contract │ +│ - 40,000 QRL per validator │ +│ - Withdrawal credentials → DepositPool │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Core Contracts + +### stQRL-v2.sol - Liquid Staking Token + +**Fixed-balance model** where `balanceOf()` returns shares (stable) and `getQRLValue()` returns QRL equivalent (fluctuates with rewards/slashing). + +| Function | Returns | Changes When | +|----------|---------|--------------| +| `balanceOf(user)` | Shares | Only on deposit/withdraw/transfer | +| `getQRLValue(user)` | QRL equivalent | Rewards accrue or slashing occurs | +| `getExchangeRate()` | QRL per share (1e18 scaled) | Rewards/slashing | + +**Key Features:** +- Virtual shares/assets (1e3) prevent first-depositor inflation attacks +- All QRC-20 operations work with shares, not QRL amounts +- Tax-friendly: balance only changes on explicit user actions + +**Example:** +``` +1. User deposits 100 QRL when pool has 1000 QRL / 1000 shares +2. User receives 100 shares, balanceOf() = 100 +3. Validators earn 50 QRL rewards (pool now 1050 QRL) +4. User's balanceOf() still = 100 shares (unchanged) +5. User's getQRLValue() = 100 × 1050 / 1000 = 105 QRL +``` + +### DepositPool-v2.sol - User Entry Point + +Handles deposits, withdrawals, and reward synchronization. + +**Deposit Flow:** +1. User calls `deposit()` with QRL +2. Contract syncs rewards via `_syncRewards()` (trustless balance check) +3. Shares calculated at current exchange rate +4. `stQRL.mintShares()` called, shares minted to user +5. `totalPooledQRL` updated + +**Withdrawal Flow:** +1. User calls `requestWithdrawal(shares)` +2. Shares locked (cannot be transferred), QRL amount snapshot taken +3. Request queued with 128-block delay (~2 hours) +4. User calls `claimWithdrawal()` after delay +5. Shares burned, QRL transferred from withdrawal reserve + +**Trustless Reward Sync:** +- No oracle needed for reward detection +- `_syncRewards()` compares contract balance to expected +- Balance increase = rewards, decrease = slashing +- EIP-4895 withdrawals automatically credit the contract + +**Key Parameters:** +- `WITHDRAWAL_DELAY`: 128 blocks (~2 hours) +- `MIN_DEPOSIT`: 1 ether (configurable) +- `VALIDATOR_STAKE`: 40,000 ether + +### ValidatorManager.sol - Validator Lifecycle + +Tracks validators through their lifecycle: + +``` +None → Pending → Active → Exiting → Exited + ↓ + Slashed +``` + +**State Transitions:** +- `registerValidator(pubkey)` → Pending +- `activateValidator(id)` → Active (confirmed on beacon chain) +- `requestValidatorExit(id)` → Exiting +- `markValidatorExited(id)` → Exited +- `markValidatorSlashed(id)` → Slashed (from Active or Exiting) + +**Access Control:** +- Owner can perform all operations (trusted operator MVP) +- DepositPool can register validators + +## Security Model + +### Access Control + +| Contract | Role | Capabilities | +|----------|------|--------------| +| stQRL | Owner | Set depositPool (once), pause/unpause | +| stQRL | DepositPool | Mint/burn shares, update totalPooledQRL | +| DepositPool | Owner | Pause, set parameters, emergency withdraw excess | +| ValidatorManager | Owner | All validator state transitions | + +### Attack Mitigations + +| Attack | Mitigation | +|--------|------------| +| First depositor inflation | Virtual shares/assets (1e3 offset) | +| Reentrancy | CEI pattern, no external calls before state changes | +| Withdrawal front-running | 128-block delay, FIFO queue | +| Emergency fund drain | emergencyWithdraw limited to excess balance only | + +### Slashing Protection + +When slashing occurs: +1. `_syncRewards()` detects balance decrease +2. `totalPooledQRL` reduced proportionally +3. All stQRL holders share the loss via reduced `getQRLValue()` +4. Share balances unchanged (loss is implicit) + +## Zond-Specific Adaptations + +| Parameter | Ethereum | QRL Zond | +|-----------|----------|----------| +| Validator stake | 32 ETH | 40,000 QRL | +| Block time | ~12s | ~60s | +| Signature scheme | ECDSA | Dilithium (ML-DSA-87) | +| Pubkey size | 48 bytes | 2,592 bytes | +| Signature size | 96 bytes | 4,595 bytes | + +## Test Coverage + +- **178 tests** across 3 test suites +- stQRL-v2: 55 tests (shares, conversions, rewards, slashing) +- DepositPool-v2: 68 tests (deposits, withdrawals, sync, access control) +- ValidatorManager: 55 tests (lifecycle, slashing, batch operations) + +## Deployment Checklist + +1. Deploy stQRL-v2 +2. Deploy ValidatorManager +3. Deploy DepositPool-v2 +4. Call `stQRL.setDepositPool(depositPool)` (one-time) +5. Call `depositPool.setStQRL(stQRL)` (one-time) +6. Call `validatorManager.setDepositPool(depositPool)` +7. Transfer ownership to multisig (optional for mainnet) + +## Future Improvements + +- [ ] Multi-operator support (permissionless registration) +- [ ] Two-step ownership transfer pattern +- [ ] Pagination for `getValidatorsByStatus()` +- [ ] On-chain integration between DepositPool and ValidatorManager diff --git a/docs/minipool-economics.md b/docs/v1-deprecated/minipool-economics.md similarity index 100% rename from docs/minipool-economics.md rename to docs/v1-deprecated/minipool-economics.md diff --git a/docs/quantapool-research.md b/docs/v1-deprecated/quantapool-research.md similarity index 100% rename from docs/quantapool-research.md rename to docs/v1-deprecated/quantapool-research.md diff --git a/docs/rocketpool-reading-guide.md b/docs/v1-deprecated/rocketpool-reading-guide.md similarity index 100% rename from docs/rocketpool-reading-guide.md rename to docs/v1-deprecated/rocketpool-reading-guide.md diff --git a/hyperion/README.md b/hyperion/README.md new file mode 100644 index 0000000..2433c08 --- /dev/null +++ b/hyperion/README.md @@ -0,0 +1,33 @@ +# Hyperion Sources + +This directory keeps the Hyperion port separate from the Solidity + Foundry workspace. + +- `hyperion/contracts/` contains generated `.hyp` mirrors of the live Solidity contracts in `contracts/solidity/`. +- `hyperion/test/` contains generated Hyperion copies of the primary v2 tests. +- `hyperion/artifacts/` contains `hypc` output and is ignored by git. +- `config/testnet-hyperion.json` stores deployment targets for the Hyperion deployment path. + +## Workflow + +1. Sync the generated Hyperion sources: + +```bash +node scripts/sync-hyperion.js +``` + +2. Compile with the Hyperion compiler: + +```bash +HYPERION_COMPILER=/path/to/hypc node scripts/compile-hyperion.js +``` + +3. Deploy the v2 contracts to Zond: + +```bash +TESTNET_SEED="..." node scripts/deploy-hyperion.js +``` + +## Notes + +- The Solidity sources in `contracts/solidity/` remain the canonical editing target. +- The Foundry tests in `test/` remain the canonical test suite; `hyperion/test/` is a mirrored compatibility layer. diff --git a/hyperion/contracts/DepositPool-v2.hyp b/hyperion/contracts/DepositPool-v2.hyp new file mode 100644 index 0000000..7b33d53 --- /dev/null +++ b/hyperion/contracts/DepositPool-v2.hyp @@ -0,0 +1,777 @@ +// SPDX-License-Identifier: GPL-3.0 +// Generated from ../contracts/solidity/DepositPool-v2.sol by scripts/sync-hyperion.js. +// Edit the Solidity source first, then re-run this script. +pragma hyperion ^0.8.24; + +/** + * @title DepositPool v2 - User Entry Point for QuantaPool + * @author QuantaPool + * @notice Accepts QRL deposits, manages withdrawals, and syncs validator rewards + * + * @dev Key responsibilities: + * 1. Accept user deposits → mint stQRL shares + * 2. Queue and process withdrawals → burn shares, return QRL + * 3. Trustless reward sync → detect balance changes from validators + * 4. Fund validators → send QRL to beacon deposit contract + * + * Reward Sync (Oracle-Free): + * Validator rewards arrive via EIP-4895 as balance increases WITHOUT + * triggering contract code. This contract periodically checks its balance + * and updates stQRL's totalPooledQRL accordingly. + * + * syncRewards() can be called by anyone - it's trustless. The contract + * simply compares its actual balance to expected balance and attributes + * the difference to rewards (positive) or slashing (negative). + * + * Balance Accounting: + * contractBalance = totalPooledQRL + withdrawalReserve + * + * - totalPooledQRL: All QRL under pool management (buffered + rewards) + * This is what stQRL token tracks. Includes buffered deposits waiting + * to fund validators, plus any rewards that arrive via EIP-4895. + * - withdrawalReserve: QRL earmarked for pending withdrawals (not pooled) + * + * For MVP (testnet), funded validators keep QRL in this contract. + * For production, QRL goes to beacon deposit contract and returns + * when validators exit. + */ + +interface IstQRL { + function mintShares(address to, uint256 qrlAmount) external returns (uint256); + function burnShares(address from, uint256 sharesAmount) external returns (uint256); + function updateTotalPooledQRL(uint256 newAmount) external; + function lockShares(address account, uint256 sharesAmount) external; + function unlockShares(address account, uint256 sharesAmount) external; + function totalPooledQRL() external view returns (uint256); + function totalShares() external view returns (uint256); + function sharesOf(address account) external view returns (uint256); + function lockedSharesOf(address account) external view returns (uint256); + function getSharesByPooledQRL(uint256 qrlAmount) external view returns (uint256); + function getPooledQRLByShares(uint256 sharesAmount) external view returns (uint256); +} + +/// @notice Zond beacon chain deposit contract interface +interface IDepositContract { + function deposit( + bytes calldata pubkey, + bytes calldata withdrawal_credentials, + bytes calldata signature, + bytes32 deposit_data_root + ) external payable; +} + +contract DepositPoolV2 { + // ============================================================= + // CONSTANTS + // ============================================================= + + /// @notice Minimum stake for a Zond validator (MaxEffectiveBalance from Zond config) + uint256 public constant VALIDATOR_STAKE = 40_000 ether; + + /// @notice Zond beacon chain deposit contract + address public constant DEPOSIT_CONTRACT = 0x4242424242424242424242424242424242424242; + + /// @notice Dilithium pubkey length (bytes) + uint256 private constant PUBKEY_LENGTH = 2592; + + /// @notice Dilithium signature length (bytes) + uint256 private constant SIGNATURE_LENGTH = 4595; + + /// @notice Withdrawal credentials length + uint256 private constant CREDENTIALS_LENGTH = 32; + + /// @notice Minimum blocks to wait before claiming withdrawal + uint256 public constant WITHDRAWAL_DELAY = 128; // ~2 hours on Zond + + /// @notice Absolute minimum for minDepositFloor (dust prevention, ~1e15 wei) + uint256 public constant ABSOLUTE_MIN_DEPOSIT = 0.001 ether; + + // ============================================================= + // STORAGE + // ============================================================= + + /// @notice stQRL token contract + IstQRL public stQRL; + + /// @notice Contract owner + address public owner; + + /// @notice QRL buffered for next validator (not yet staked) + uint256 public bufferedQRL; + + /// @notice Number of active validators + uint256 public validatorCount; + + /// @notice Minimum deposit amount + uint256 public minDeposit; + + /// @notice Adjustable floor for minDeposit (owner can lower after deployment) + uint256 public minDepositFloor = 100 ether; + + /// @notice Paused state + bool public paused; + + /// @notice Reentrancy guard + uint256 private _locked; + + // ============================================================= + // WITHDRAWAL STORAGE + // ============================================================= + + /// @notice Withdrawal request data + struct WithdrawalRequest { + uint256 shares; // Shares to burn + uint256 qrlAmount; // QRL amount at request time (may change with rebase) + uint256 requestBlock; // Block when requested + bool claimed; // Whether claimed + } + + /// @notice Withdrawal requests by user (supports multiple requests via array) + mapping(address => WithdrawalRequest[]) public withdrawalRequests; + + /// @notice Next withdrawal request ID to process for each user + mapping(address => uint256) public nextWithdrawalIndex; + + /// @notice Total shares locked in withdrawal queue + uint256 public totalWithdrawalShares; + + /// @notice QRL reserved for pending withdrawals + uint256 public withdrawalReserve; + + // ============================================================= + // SYNC STORAGE + // ============================================================= + + /// @notice Last block when rewards were synced + uint256 public lastSyncBlock; + + /// @notice Total rewards received (cumulative, for stats) + uint256 public totalRewardsReceived; + + /// @notice Total slashing losses (cumulative, for stats) + uint256 public totalSlashingLosses; + + // ============================================================= + // EVENTS + // ============================================================= + + event Deposited(address indexed user, uint256 qrlAmount, uint256 sharesReceived); + + event WithdrawalRequested(address indexed user, uint256 shares, uint256 qrlAmount, uint256 requestBlock); + + event WithdrawalClaimed(address indexed user, uint256 shares, uint256 qrlAmount); + + event RewardsSynced(uint256 rewardsAmount, uint256 newTotalPooled, uint256 blockNumber); + + event SlashingDetected(uint256 lossAmount, uint256 newTotalPooled, uint256 blockNumber); + + event ValidatorFunded(uint256 indexed validatorId, bytes pubkey, uint256 amount); + + event WithdrawalReserveFunded(uint256 amount); + event WithdrawalCancelled(address indexed user, uint256 indexed requestId, uint256 shares); + event MinDepositUpdated(uint256 newMinDeposit); + event MinDepositFloorUpdated(uint256 newFloor); + event Paused(address account); + event Unpaused(address account); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event StQRLSet(address indexed stQRL); + event EmergencyWithdrawal(address indexed to, uint256 amount); + + // ============================================================= + // ERRORS + // ============================================================= + + error NotOwner(); + error ContractPaused(); + error ReentrancyGuard(); + error ZeroAddress(); + error ZeroAmount(); + error BelowMinDeposit(); + error BelowMinDepositFloor(); + error BelowAbsoluteMin(); + error InsufficientShares(); + error NoWithdrawalPending(); + error WithdrawalNotReady(); + error InsufficientReserve(); + error InsufficientBuffer(); + error InvalidPubkeyLength(); + error InvalidSignatureLength(); + error InvalidCredentialsLength(); + error InvalidWithdrawalCredentials(); + error TransferFailed(); + error StQRLNotSet(); + error StQRLAlreadySet(); + error InvalidWithdrawalIndex(); + error ExceedsRecoverableAmount(); + + // ============================================================= + // MODIFIERS + // ============================================================= + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + modifier whenNotPaused() { + if (paused) revert ContractPaused(); + _; + } + + modifier nonReentrant() { + if (_locked == 1) revert ReentrancyGuard(); + _locked = 1; + _; + _locked = 0; + } + + // ============================================================= + // CONSTRUCTOR + // ============================================================= + + constructor() { + owner = msg.sender; + minDeposit = 100 ether; // 100 QRL minimum + lastSyncBlock = block.number; + } + + // ============================================================= + // DEPOSIT FUNCTIONS + // ============================================================= + + /** + * @notice Deposit QRL and receive stQRL + * @dev Mints shares based on current exchange rate, adds deposit to buffer + * + * Note: Does NOT call syncRewards() because msg.value is already in + * address(this).balance when function executes, which would incorrectly + * be detected as "rewards". Users wanting the latest rate should call + * syncRewards() before depositing. + * + * @return shares Amount of stQRL shares minted + */ + function deposit() external payable nonReentrant whenNotPaused returns (uint256 shares) { + if (address(stQRL) == address(0)) revert StQRLNotSet(); + if (msg.value < minDeposit) revert BelowMinDeposit(); + + // Mint shares FIRST - this calculates shares at current rate + // mintShares internally calls getSharesByPooledQRL(qrlAmount) + // This must happen BEFORE updating totalPooledQRL to ensure fair pricing + shares = stQRL.mintShares(msg.sender, msg.value); + + // Add to buffer (deposited QRL waiting to fund validators) + bufferedQRL += msg.value; + + // Update total pooled QRL (deposit is now under protocol management) + // This must happen AFTER minting to not affect the share calculation + uint256 newTotalPooled = stQRL.totalPooledQRL() + msg.value; + stQRL.updateTotalPooledQRL(newTotalPooled); + + emit Deposited(msg.sender, msg.value, shares); + return shares; + } + + /** + * @notice Preview deposit - get expected shares for QRL amount + * @param qrlAmount Amount of QRL to deposit + * @return shares Expected shares to receive + */ + function previewDeposit(uint256 qrlAmount) external view returns (uint256 shares) { + if (address(stQRL) == address(0)) return qrlAmount; + return stQRL.getSharesByPooledQRL(qrlAmount); + } + + // ============================================================= + // WITHDRAWAL FUNCTIONS + // ============================================================= + + /** + * @notice Request withdrawal of stQRL + * @dev Users can have multiple pending withdrawal requests + * @param shares Amount of shares to withdraw + * @return requestId The ID of this withdrawal request + * @return qrlAmount Current QRL value of shares (may change before claim) + */ + function requestWithdrawal(uint256 shares) + external + nonReentrant + whenNotPaused + returns (uint256 requestId, uint256 qrlAmount) + { + if (shares == 0) revert ZeroAmount(); + uint256 unlockedShares = stQRL.sharesOf(msg.sender) - stQRL.lockedSharesOf(msg.sender); + if (unlockedShares < shares) revert InsufficientShares(); + + // Sync rewards first + _syncRewards(); + + // Calculate current QRL value + qrlAmount = stQRL.getPooledQRLByShares(shares); + + // Lock shares so they cannot be transferred + stQRL.lockShares(msg.sender, shares); + + // Create withdrawal request (push to array for multiple requests support) + requestId = withdrawalRequests[msg.sender].length; + withdrawalRequests[msg.sender].push( + WithdrawalRequest({shares: shares, qrlAmount: qrlAmount, requestBlock: block.number, claimed: false}) + ); + + totalWithdrawalShares += shares; + + emit WithdrawalRequested(msg.sender, shares, qrlAmount, block.number); + return (requestId, qrlAmount); + } + + /** + * @notice Claim the next pending withdrawal (FIFO order) + * @dev Burns shares and transfers QRL to user. + * Skips cancelled requests (claimed=true, shares=0) automatically. + * Uses actual burned QRL value for all accounting to prevent discrepancies. + * @return qrlAmount Amount of QRL received + */ + function claimWithdrawal() external nonReentrant returns (uint256 qrlAmount) { + uint256 requestIndex = nextWithdrawalIndex[msg.sender]; + uint256 totalRequests = withdrawalRequests[msg.sender].length; + + // Skip cancelled requests (shares=0 && claimed=true) + // Bounded: user can only create cancellations via their own txs, + // so the practical depth is small. Tested up to 500 in suite. + while (requestIndex < totalRequests && withdrawalRequests[msg.sender][requestIndex].shares == 0) { + requestIndex++; + } + if (requestIndex >= totalRequests) revert NoWithdrawalPending(); + + // Update index to account for skipped cancelled requests + nextWithdrawalIndex[msg.sender] = requestIndex; + + WithdrawalRequest storage request = withdrawalRequests[msg.sender][requestIndex]; + + // === CHECKS === + if (request.claimed) revert NoWithdrawalPending(); + if (block.number < request.requestBlock + WITHDRAWAL_DELAY) revert WithdrawalNotReady(); + + // Sync rewards first (external call, but to trusted stQRL contract) + _syncRewards(); + + // Cache shares before state changes + uint256 sharesToBurn = request.shares; + + // Use the QRL amount captured at request time. + // After fundWithdrawalReserve() reclassified pooled QRL into the reserve, + // totalPooledQRL is reduced, which distorts the share→QRL conversion. + // The request's qrlAmount was calculated BEFORE reclassification, so it + // reflects the true value of the shares at the time they were locked. + qrlAmount = request.qrlAmount; + + // Unlock shares before burning + stQRL.unlockShares(msg.sender, sharesToBurn); + + // Burn shares (return value ignored — see comment above) + stQRL.burnShares(msg.sender, sharesToBurn); + + // Check if we have enough in reserve + if (withdrawalReserve < qrlAmount) revert InsufficientReserve(); + + // === EFFECTS (state changes using actual burned amount) === + request.claimed = true; + nextWithdrawalIndex[msg.sender] = requestIndex + 1; + totalWithdrawalShares -= sharesToBurn; + withdrawalReserve -= qrlAmount; + + // NOTE: We do NOT decrement totalPooledQRL here. + // The QRL being claimed comes from withdrawalReserve, which is already + // outside totalPooledQRL. The totalPooledQRL was decremented when the + // reserve was funded (see fundWithdrawalReserve). Decrementing here + // would double-count and cause _syncRewards() to detect phantom rewards. + + // === INTERACTION (ETH transfer last) === + (bool success,) = msg.sender.call{value: qrlAmount}(""); + if (!success) revert TransferFailed(); + + emit WithdrawalClaimed(msg.sender, sharesToBurn, qrlAmount); + return qrlAmount; + } + + /** + * @notice Cancel a specific pending withdrawal request + * @dev Returns shares to normal circulating state. Only unclaimed requests can be cancelled. + * @param requestId The index of the withdrawal request to cancel + */ + function cancelWithdrawal(uint256 requestId) external nonReentrant { + if (requestId >= withdrawalRequests[msg.sender].length) revert InvalidWithdrawalIndex(); + if (requestId < nextWithdrawalIndex[msg.sender]) revert InvalidWithdrawalIndex(); // Already processed + + WithdrawalRequest storage request = withdrawalRequests[msg.sender][requestId]; + + if (request.shares == 0) revert NoWithdrawalPending(); + if (request.claimed) revert NoWithdrawalPending(); + + uint256 shares = request.shares; + totalWithdrawalShares -= shares; + request.shares = 0; + request.claimed = true; // Mark as processed + + // Unlock shares so they can be transferred again + stQRL.unlockShares(msg.sender, shares); + + emit WithdrawalCancelled(msg.sender, requestId, shares); + } + + /** + * @notice Get withdrawal request details by index + * @param user Address to query + * @param requestId Index of the withdrawal request + */ + function getWithdrawalRequest(address user, uint256 requestId) + external + view + returns ( + uint256 shares, + uint256 currentQRLValue, + uint256 requestBlock, + bool canClaim, + uint256 blocksRemaining, + bool claimed + ) + { + if (requestId >= withdrawalRequests[user].length) { + return (0, 0, 0, false, 0, false); + } + + WithdrawalRequest storage request = withdrawalRequests[user][requestId]; + shares = request.shares; + currentQRLValue = stQRL.getPooledQRLByShares(shares); + requestBlock = request.requestBlock; + claimed = request.claimed; + + uint256 unlockBlock = request.requestBlock + WITHDRAWAL_DELAY; + canClaim = !request.claimed && request.shares > 0 && block.number >= unlockBlock + && withdrawalReserve >= currentQRLValue; + + blocksRemaining = block.number >= unlockBlock ? 0 : unlockBlock - block.number; + } + + /** + * @notice Get the number of withdrawal requests for a user + * @param user Address to query + * @return total Total number of requests + * @return pending Number of pending (unprocessed) requests + */ + function getWithdrawalRequestCount(address user) external view returns (uint256 total, uint256 pending) { + total = withdrawalRequests[user].length; + uint256 nextIndex = nextWithdrawalIndex[user]; + pending = total > nextIndex ? total - nextIndex : 0; + } + + // ============================================================= + // REWARD SYNC FUNCTIONS + // ============================================================= + + /** + * @notice Sync rewards from validator balance changes + * @dev Anyone can call this. It's trustless - just compares balances. + * Called automatically on deposit/withdraw, but can be called + * manually to update balances more frequently. + */ + function syncRewards() external nonReentrant { + _syncRewards(); + } + + /** + * @dev Internal reward sync logic + * + * Balance accounting: + * The contract holds: bufferedQRL + rewards/staked QRL + withdrawalReserve + * withdrawalReserve is earmarked for pending withdrawals (not pooled) + * actualTotalPooled = balance - withdrawalReserve + * + * If actualTotalPooled > previousPooled → rewards arrived + * If actualTotalPooled < previousPooled → slashing occurred + * + * Note: For MVP (fundValidatorMVP), staked QRL stays in contract. + * For production (fundValidator), staked QRL goes to beacon deposit contract + * and returns via EIP-4895 withdrawals when validators exit. + */ + function _syncRewards() internal { + if (address(stQRL) == address(0)) return; + + uint256 currentBalance = address(this).balance; + + // Total pooled = everything except withdrawal reserve + // This includes: bufferedQRL + any rewards that arrived via EIP-4895 + uint256 actualTotalPooled; + if (currentBalance > withdrawalReserve) { + actualTotalPooled = currentBalance - withdrawalReserve; + } else { + actualTotalPooled = 0; + } + + // What we previously tracked as pooled + uint256 previousPooled = stQRL.totalPooledQRL(); + + // Compare and attribute difference + if (actualTotalPooled > previousPooled) { + // Rewards arrived (via EIP-4895 or direct transfer) + uint256 rewards = actualTotalPooled - previousPooled; + totalRewardsReceived += rewards; + stQRL.updateTotalPooledQRL(actualTotalPooled); + lastSyncBlock = block.number; + + emit RewardsSynced(rewards, actualTotalPooled, block.number); + } else if (actualTotalPooled < previousPooled) { + // Slashing detected (or funds removed somehow) + uint256 loss = previousPooled - actualTotalPooled; + totalSlashingLosses += loss; + stQRL.updateTotalPooledQRL(actualTotalPooled); + lastSyncBlock = block.number; + + emit SlashingDetected(loss, actualTotalPooled, block.number); + } + // If equal, no change needed + } + + // ============================================================= + // VALIDATOR FUNCTIONS + // ============================================================= + + /** + * @notice Fund a validator with beacon chain deposit + * @dev Only owner can call. Sends VALIDATOR_STAKE to beacon deposit contract. + * @param pubkey Dilithium public key (2592 bytes) + * @param withdrawal_credentials Must point to this contract (0x01 + 11 zero bytes + address) + * @param signature Dilithium signature (4595 bytes) + * @param deposit_data_root SSZ hash of deposit data + * @return validatorId The new validator's ID + */ + function fundValidator( + bytes calldata pubkey, + bytes calldata withdrawal_credentials, + bytes calldata signature, + bytes32 deposit_data_root + ) external onlyOwner nonReentrant returns (uint256 validatorId) { + if (bufferedQRL < VALIDATOR_STAKE) revert InsufficientBuffer(); + if (pubkey.length != PUBKEY_LENGTH) revert InvalidPubkeyLength(); + if (signature.length != SIGNATURE_LENGTH) revert InvalidSignatureLength(); + if (withdrawal_credentials.length != CREDENTIALS_LENGTH) revert InvalidCredentialsLength(); + + // Verify withdrawal credentials point to this contract + // Format: 0x01 (1 byte) + 11 zero bytes + contract address (20 bytes) = 32 bytes + // This ensures validator withdrawals come to this contract + bytes32 expectedCredentials = bytes32(abi.encodePacked(bytes1(0x01), bytes11(0), address(this))); + bytes32 actualCredentials; + assembly { + actualCredentials := calldataload(withdrawal_credentials.offset) + } + if (actualCredentials != expectedCredentials) revert InvalidWithdrawalCredentials(); + + bufferedQRL -= VALIDATOR_STAKE; + validatorId = validatorCount++; + + // Call beacon deposit contract + IDepositContract(DEPOSIT_CONTRACT).deposit{value: VALIDATOR_STAKE}( + pubkey, withdrawal_credentials, signature, deposit_data_root + ); + + emit ValidatorFunded(validatorId, pubkey, VALIDATOR_STAKE); + return validatorId; + } + + /** + * @notice Fund a validator (MVP testing - no actual beacon deposit) + * @dev Moves QRL from buffer to simulated stake. For testnet only. + * @return validatorId The new validator's ID + */ + function fundValidatorMVP() external onlyOwner nonReentrant returns (uint256 validatorId) { + if (bufferedQRL < VALIDATOR_STAKE) revert InsufficientBuffer(); + + bufferedQRL -= VALIDATOR_STAKE; + validatorId = validatorCount++; + + // QRL stays in contract, simulating staked funds + emit ValidatorFunded(validatorId, "", VALIDATOR_STAKE); + return validatorId; + } + + /** + * @notice Move QRL from pooled accounting to withdrawal reserve + * @dev Called by owner to earmark pooled QRL for pending withdrawals. + * This does NOT accept ETH - it reclassifies existing contract balance + * from totalPooledQRL to withdrawalReserve. + * + * Invariant maintained: address(this).balance = totalPooledQRL + withdrawalReserve + * + * For MVP: pooled QRL is in the contract, so we just reclassify. + * For production: call this after validator exit proceeds arrive and + * _syncRewards() has already attributed them to totalPooledQRL. + * + * @param amount Amount to move from pooled to withdrawal reserve + */ + function fundWithdrawalReserve(uint256 amount) external onlyOwner { + if (amount == 0) revert ZeroAmount(); + + uint256 currentPooled = stQRL.totalPooledQRL(); + if (amount > currentPooled) revert InsufficientBuffer(); + + withdrawalReserve += amount; + stQRL.updateTotalPooledQRL(currentPooled - amount); + + emit WithdrawalReserveFunded(amount); + } + + // ============================================================= + // VIEW FUNCTIONS + // ============================================================= + + /** + * @notice Get pool status + */ + function getPoolStatus() + external + view + returns ( + uint256 totalPooled, + uint256 totalShares, + uint256 buffered, + uint256 validators, + uint256 pendingWithdrawalShares, + uint256 reserveBalance, + uint256 exchangeRate + ) + { + totalPooled = address(stQRL) != address(0) ? stQRL.totalPooledQRL() : 0; + totalShares = address(stQRL) != address(0) ? stQRL.totalShares() : 0; + buffered = bufferedQRL; + validators = validatorCount; + pendingWithdrawalShares = totalWithdrawalShares; + reserveBalance = withdrawalReserve; + exchangeRate = totalShares > 0 ? (totalPooled * 1e18) / totalShares : 1e18; + } + + /** + * @notice Get reward/slashing stats + */ + function getRewardStats() + external + view + returns (uint256 totalRewards, uint256 totalSlashing, uint256 netRewards, uint256 lastSync) + { + totalRewards = totalRewardsReceived; + totalSlashing = totalSlashingLosses; + netRewards = totalRewardsReceived > totalSlashingLosses ? totalRewardsReceived - totalSlashingLosses : 0; + lastSync = lastSyncBlock; + } + + /** + * @notice Check if validator funding is possible + */ + function canFundValidator() external view returns (bool possible, uint256 bufferedAmount) { + possible = bufferedQRL >= VALIDATOR_STAKE; + bufferedAmount = bufferedQRL; + } + + // ============================================================= + // ADMIN FUNCTIONS + // ============================================================= + + /** + * @notice Set the stQRL token contract (one-time only) + * @param _stQRL Address of stQRL contract + */ + function setStQRL(address _stQRL) external onlyOwner { + if (_stQRL == address(0)) revert ZeroAddress(); + if (address(stQRL) != address(0)) revert StQRLAlreadySet(); + stQRL = IstQRL(_stQRL); + emit StQRLSet(_stQRL); + } + + /** + * @notice Set minimum deposit amount + * @param _minDeposit New minimum deposit + */ + function setMinDeposit(uint256 _minDeposit) external onlyOwner { + if (_minDeposit < minDepositFloor) revert BelowMinDepositFloor(); + minDeposit = _minDeposit; + emit MinDepositUpdated(_minDeposit); + } + + /** + * @notice Set the adjustable floor for minDeposit + * @dev Allows owner to lower the floor post-deployment (e.g., if QRL appreciates) + * @param _floor New floor value (must be >= ABSOLUTE_MIN_DEPOSIT) + */ + function setMinDepositFloor(uint256 _floor) external onlyOwner { + if (_floor < ABSOLUTE_MIN_DEPOSIT) revert BelowAbsoluteMin(); + minDepositFloor = _floor; + emit MinDepositFloorUpdated(_floor); + } + + /** + * @notice Pause the contract + */ + function pause() external onlyOwner { + paused = true; + emit Paused(msg.sender); + } + + /** + * @notice Unpause the contract + */ + function unpause() external onlyOwner { + paused = false; + emit Unpaused(msg.sender); + } + + /** + * @notice Transfer ownership + * @param newOwner New owner address + */ + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + + /** + * @notice Emergency withdrawal of stuck funds + * @dev Only for recovery of accidentally sent tokens, not pool funds. + * Can only withdraw excess balance that's not part of pooled QRL or withdrawal reserve. + * @param to Recipient address + * @param amount Amount to withdraw + */ + function emergencyWithdraw(address to, uint256 amount) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + + // Calculate recoverable amount: balance - pooled funds - withdrawal reserve + uint256 totalProtocolFunds = (address(stQRL) != address(0) ? stQRL.totalPooledQRL() : 0) + withdrawalReserve; + uint256 currentBalance = address(this).balance; + uint256 recoverableAmount = currentBalance > totalProtocolFunds ? currentBalance - totalProtocolFunds : 0; + + if (amount > recoverableAmount) revert ExceedsRecoverableAmount(); + + (bool success,) = to.call{value: amount}(""); + if (!success) revert TransferFailed(); + + emit EmergencyWithdrawal(to, amount); + } + + // ============================================================= + // RECEIVE FUNCTION + // ============================================================= + + /** + * @notice Receive QRL (from validator exits, rewards, or direct sends) + * @dev Rewards arrive via EIP-4895 WITHOUT triggering this function. + * This is only triggered by explicit transfers (e.g. validator exit + * proceeds via a regular transaction). + * + * Incoming ETH is NOT auto-classified. It increases address(this).balance, + * and the next _syncRewards() call will detect it as a balance increase + * and attribute it to totalPooledQRL. The owner can then call + * fundWithdrawalReserve() to reclassify it for pending withdrawals. + */ + receive() external payable { + // No automatic accounting - _syncRewards() will detect the balance change + } +} diff --git a/hyperion/contracts/ValidatorManager.hyp b/hyperion/contracts/ValidatorManager.hyp new file mode 100644 index 0000000..bb4d396 --- /dev/null +++ b/hyperion/contracts/ValidatorManager.hyp @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: GPL-3.0 +// Generated from ../contracts/solidity/ValidatorManager.sol by scripts/sync-hyperion.js. +// Edit the Solidity source first, then re-run this script. +pragma hyperion ^0.8.24; + +/** + * @title ValidatorManager - Simplified Validator Tracking for QuantaPool + * @author QuantaPool + * @notice Tracks validator pubkeys and status for the liquid staking pool + * + * @dev MVP Design: + * - Single trusted operator (owner) + * - No bonds, collateral, or complex economics + * - Simple validator state machine: Pending → Active → Exiting → Exited + * - Future: Permissionless operator registration + * + * This contract is intentionally minimal. Complex operator economics + * can be added in v3 after the core staking mechanism is proven. + */ +contract ValidatorManager { + // ============================================================= + // CONSTANTS + // ============================================================= + + /// @notice Zond validator stake amount (MaxEffectiveBalance from Zond config) + uint256 public constant VALIDATOR_STAKE = 40_000 ether; + + /// @notice Dilithium pubkey length + uint256 private constant PUBKEY_LENGTH = 2592; + + // ============================================================= + // ENUMS + // ============================================================= + + /// @notice Validator lifecycle states + enum ValidatorStatus { + None, // Not registered + Pending, // Registered, awaiting activation + Active, // Currently validating + Exiting, // Exit requested + Exited, // Fully exited, funds returned + Slashed // Slashed (for record keeping) + } + + // ============================================================= + // STRUCTS + // ============================================================= + + /// @notice Validator data + struct Validator { + bytes pubkey; // Dilithium public key (2592 bytes) + ValidatorStatus status; // Current status + uint256 activatedBlock; // Block when activated + uint256 exitedBlock; // Block when exited (0 if not exited) + } + + // ============================================================= + // STORAGE + // ============================================================= + + /// @notice Contract owner (operator for MVP) + address public owner; + + /// @notice DepositPool contract (authorized to register validators) + address public depositPool; + + /// @notice Validator data by index + mapping(uint256 => Validator) public validators; + + /// @notice Pubkey hash to validator index + mapping(bytes32 => uint256) public pubkeyToIndex; + + /// @notice Total validators ever registered + uint256 public totalValidators; + + /// @notice Count of active validators + uint256 public activeValidatorCount; + + /// @notice Count of pending validators + uint256 public pendingValidatorCount; + + // ============================================================= + // EVENTS + // ============================================================= + + event ValidatorRegistered(uint256 indexed validatorId, bytes pubkey, ValidatorStatus status); + + event ValidatorActivated(uint256 indexed validatorId, uint256 activatedBlock); + + event ValidatorExitRequested(uint256 indexed validatorId, uint256 requestBlock); + + event ValidatorExited(uint256 indexed validatorId, uint256 exitedBlock); + + event ValidatorSlashed(uint256 indexed validatorId, uint256 slashedBlock); + + event DepositPoolSet(address indexed depositPool); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + // ============================================================= + // ERRORS + // ============================================================= + + error NotOwner(); + error NotDepositPool(); + error NotAuthorized(); + error ZeroAddress(); + error InvalidPubkeyLength(); + error ValidatorAlreadyExists(); + error ValidatorNotFound(); + error InvalidStatusTransition(); + + // ============================================================= + // MODIFIERS + // ============================================================= + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + modifier onlyDepositPool() { + if (msg.sender != depositPool) revert NotDepositPool(); + _; + } + + modifier onlyAuthorized() { + if (msg.sender != owner && msg.sender != depositPool) revert NotAuthorized(); + _; + } + + // ============================================================= + // CONSTRUCTOR + // ============================================================= + + constructor() { + owner = msg.sender; + } + + // ============================================================= + // VALIDATOR REGISTRATION + // ============================================================= + + /** + * @notice Register a new validator + * @dev Called by DepositPool when funding a validator + * @param pubkey Dilithium public key (2592 bytes) + * @return validatorId The new validator's index + */ + function registerValidator(bytes calldata pubkey) external onlyAuthorized returns (uint256 validatorId) { + if (pubkey.length != PUBKEY_LENGTH) revert InvalidPubkeyLength(); + + bytes32 pubkeyHash = keccak256(pubkey); + if (pubkeyToIndex[pubkeyHash] != 0) revert ValidatorAlreadyExists(); + + // Validator IDs start at 1 (0 means not found) + validatorId = ++totalValidators; + + validators[validatorId] = + Validator({pubkey: pubkey, status: ValidatorStatus.Pending, activatedBlock: 0, exitedBlock: 0}); + + pubkeyToIndex[pubkeyHash] = validatorId; + pendingValidatorCount++; + + emit ValidatorRegistered(validatorId, pubkey, ValidatorStatus.Pending); + return validatorId; + } + + // ============================================================= + // STATUS TRANSITIONS + // ============================================================= + + /** + * @notice Mark validator as active (confirmed on beacon chain) + * @param validatorId The validator to activate + */ + function activateValidator(uint256 validatorId) external onlyOwner { + Validator storage v = validators[validatorId]; + if (v.status != ValidatorStatus.Pending) revert InvalidStatusTransition(); + + v.status = ValidatorStatus.Active; + v.activatedBlock = block.number; + + pendingValidatorCount--; + activeValidatorCount++; + + emit ValidatorActivated(validatorId, block.number); + } + + /** + * @notice Mark validator as exiting + * @param validatorId The validator requesting exit + */ + function requestValidatorExit(uint256 validatorId) external onlyOwner { + Validator storage v = validators[validatorId]; + if (v.status != ValidatorStatus.Active) revert InvalidStatusTransition(); + + v.status = ValidatorStatus.Exiting; + + emit ValidatorExitRequested(validatorId, block.number); + } + + /** + * @notice Mark validator as fully exited + * @param validatorId The validator that has exited + */ + function markValidatorExited(uint256 validatorId) external onlyOwner { + Validator storage v = validators[validatorId]; + if (v.status != ValidatorStatus.Exiting) revert InvalidStatusTransition(); + + v.status = ValidatorStatus.Exited; + v.exitedBlock = block.number; + + activeValidatorCount--; + + emit ValidatorExited(validatorId, block.number); + } + + /** + * @notice Mark validator as slashed + * @param validatorId The slashed validator + */ + function markValidatorSlashed(uint256 validatorId) external onlyOwner { + Validator storage v = validators[validatorId]; + ValidatorStatus previousStatus = v.status; + + if (previousStatus != ValidatorStatus.Active && previousStatus != ValidatorStatus.Exiting) { + revert InvalidStatusTransition(); + } + + v.status = ValidatorStatus.Slashed; + v.exitedBlock = block.number; + + // Decrement counter - both Active and Exiting validators count toward activeValidatorCount + activeValidatorCount--; + + emit ValidatorSlashed(validatorId, block.number); + } + + /** + * @notice Batch activate multiple validators + * @param validatorIds Array of validator IDs to activate + */ + function batchActivateValidators(uint256[] calldata validatorIds) external onlyOwner { + for (uint256 i = 0; i < validatorIds.length; i++) { + Validator storage v = validators[validatorIds[i]]; + if (v.status == ValidatorStatus.Pending) { + v.status = ValidatorStatus.Active; + v.activatedBlock = block.number; + pendingValidatorCount--; + activeValidatorCount++; + emit ValidatorActivated(validatorIds[i], block.number); + } + } + } + + // ============================================================= + // VIEW FUNCTIONS + // ============================================================= + + /** + * @notice Get validator details + * @param validatorId The validator to query + */ + function getValidator(uint256 validatorId) + external + view + returns (bytes memory pubkey, ValidatorStatus status, uint256 activatedBlock, uint256 exitedBlock) + { + Validator storage v = validators[validatorId]; + return (v.pubkey, v.status, v.activatedBlock, v.exitedBlock); + } + + /** + * @notice Get validator ID by pubkey + * @param pubkey The pubkey to look up + * @return validatorId (0 if not found) + */ + function getValidatorIdByPubkey(bytes calldata pubkey) external view returns (uint256) { + return pubkeyToIndex[keccak256(pubkey)]; + } + + /** + * @notice Get validator status by pubkey + * @param pubkey The pubkey to look up + */ + function getValidatorStatus(bytes calldata pubkey) external view returns (ValidatorStatus) { + uint256 validatorId = pubkeyToIndex[keccak256(pubkey)]; + if (validatorId == 0) return ValidatorStatus.None; + return validators[validatorId].status; + } + + /** + * @notice Get summary statistics + */ + function getStats() external view returns (uint256 total, uint256 pending, uint256 active, uint256 totalStaked) { + total = totalValidators; + pending = pendingValidatorCount; + active = activeValidatorCount; + totalStaked = activeValidatorCount * VALIDATOR_STAKE; + } + + /** + * @notice Get all validators in a specific status + * @param status The status to filter by + * @return validatorIds Array of matching validator IDs + */ + function getValidatorsByStatus(ValidatorStatus status) external view returns (uint256[] memory validatorIds) { + // First pass: count matches + uint256 count = 0; + for (uint256 i = 1; i <= totalValidators; i++) { + if (validators[i].status == status) { + count++; + } + } + + // Second pass: collect IDs + validatorIds = new uint256[](count); + uint256 index = 0; + for (uint256 i = 1; i <= totalValidators; i++) { + if (validators[i].status == status) { + validatorIds[index++] = i; + } + } + + return validatorIds; + } + + // ============================================================= + // ADMIN FUNCTIONS + // ============================================================= + + /** + * @notice Set the DepositPool contract + * @param _depositPool Address of DepositPool + */ + function setDepositPool(address _depositPool) external onlyOwner { + if (_depositPool == address(0)) revert ZeroAddress(); + depositPool = _depositPool; + emit DepositPoolSet(_depositPool); + } + + /** + * @notice Transfer ownership + * @param newOwner New owner address + */ + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } +} diff --git a/hyperion/contracts/stQRL-v2.hyp b/hyperion/contracts/stQRL-v2.hyp new file mode 100644 index 0000000..a419b0c --- /dev/null +++ b/hyperion/contracts/stQRL-v2.hyp @@ -0,0 +1,498 @@ +// SPDX-License-Identifier: GPL-3.0 +// Generated from ../contracts/solidity/stQRL-v2.sol by scripts/sync-hyperion.js. +// Edit the Solidity source first, then re-run this script. +pragma hyperion ^0.8.24; + +/** + * @title stQRL v2 - Fixed-Balance Staked QRL Token + * @author QuantaPool + * @notice Liquid staking token for QRL Zond. Balance represents shares (fixed), + * use getQRLValue() to see current QRL equivalent. + * + * @dev Key concepts: + * - balanceOf() returns raw shares (stable, tax-friendly) + * - getQRLValue() returns QRL equivalent (changes with rewards/slashing) + * - Exchange rate: totalPooledQRL / totalShares + * + * This is a fixed-balance model (like wstETH) rather than rebasing (like stETH). + * Chosen for cleaner tax implications - balance only changes on deposit/withdraw. + * + * Example: + * 1. User deposits 100 QRL when pool has 1000 QRL and 1000 shares + * 2. User receives 100 shares, balanceOf() = 100 + * 3. Validators earn 50 QRL rewards (pool now has 1050 QRL) + * 4. User's balanceOf() still = 100 shares (unchanged) + * 5. User's getQRLValue() = 100 * 1050 / 1000 = 105 QRL + * + * If slashing occurs (pool drops to 950 QRL): + * - User's balanceOf() still = 100 shares + * - User's getQRLValue() = 100 * 950 / 1000 = 95 QRL + */ +contract stQRLv2 { + // ============================================================= + // CONSTANTS + // ============================================================= + + string public constant name = "Staked QRL"; + string public constant symbol = "stQRL"; + uint8 public constant decimals = 18; + + /// @notice Initial shares per QRL (1:1 at launch) + uint256 private constant INITIAL_SHARES_PER_QRL = 1; + + /// @notice Virtual shares offset to prevent first depositor attack (donation attack) + /// @dev Adding virtual shares/assets creates a floor that makes share inflation attacks + /// economically unviable. With 1e3 virtual offset, an attacker would need to + /// donate ~1000x more than they could steal. See OpenZeppelin ERC4626 for details. + uint256 private constant VIRTUAL_SHARES = 1e3; + uint256 private constant VIRTUAL_ASSETS = 1e3; + + // ============================================================= + // SHARE STORAGE + // ============================================================= + + /// @notice Total shares in existence + uint256 private _totalShares; + + /// @notice Shares held by each account + mapping(address => uint256) private _shares; + + /// @notice Allowances for transferFrom (in shares) + /// @dev All amounts in this contract are shares, not QRL + mapping(address => mapping(address => uint256)) private _allowances; + + /// @notice Shares locked for pending withdrawals (cannot be transferred) + mapping(address => uint256) private _lockedShares; + + // ============================================================= + // POOL STORAGE + // ============================================================= + + /// @notice Total QRL controlled by the protocol (staked + rewards - slashing) + /// @dev Updated by DepositPool via updateTotalPooledQRL() + uint256 private _totalPooledQRL; + + // ============================================================= + // ACCESS CONTROL + // ============================================================= + + /// @notice Contract owner (for initial setup) + address public owner; + + /// @notice DepositPool contract (only address that can mint/burn/update) + address public depositPool; + + /// @notice Pause state for emergencies + bool public paused; + + // ============================================================= + // EVENTS + // ============================================================= + + // QRC-20 standard events (values are in shares) + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + // Pool events + event TotalPooledQRLUpdated(uint256 previousAmount, uint256 newAmount); + event SharesMinted(address indexed to, uint256 sharesAmount, uint256 qrlAmount); + event SharesBurned(address indexed from, uint256 sharesAmount, uint256 qrlAmount); + + // Admin events + event DepositPoolSet(address indexed previousPool, address indexed newPool); + event Paused(address account); + event Unpaused(address account); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + // ============================================================= + // ERRORS + // ============================================================= + + error NotOwner(); + error NotDepositPool(); + error ContractPaused(); + error ZeroAddress(); + error ZeroAmount(); + error InsufficientBalance(); + error InsufficientAllowance(); + error DepositPoolAlreadySet(); + error InsufficientUnlockedShares(); + + // ============================================================= + // MODIFIERS + // ============================================================= + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + modifier onlyDepositPool() { + if (msg.sender != depositPool) revert NotDepositPool(); + _; + } + + modifier whenNotPaused() { + if (paused) revert ContractPaused(); + _; + } + + // ============================================================= + // CONSTRUCTOR + // ============================================================= + + constructor() { + owner = msg.sender; + } + + // ============================================================= + // QRC-20 VIEW FUNCTIONS + // ============================================================= + + /** + * @notice Returns the total supply of stQRL tokens (in shares) + * @dev Use totalPooledQRL() for the QRL value + * @return Total stQRL shares in circulation + */ + function totalSupply() external view returns (uint256) { + return _totalShares; + } + + /** + * @notice Returns the stQRL balance of an account (in shares) + * @dev Returns raw shares - stable value that only changes on deposit/withdraw + * Use getQRLValue() for the current QRL equivalent + * @param account The address to query + * @return The account's share balance + */ + function balanceOf(address account) public view returns (uint256) { + return _shares[account]; + } + + /** + * @notice Returns the allowance for a spender (in shares) + * @param _owner The token owner + * @param spender The approved spender + * @return The allowance in shares + */ + function allowance(address _owner, address spender) public view returns (uint256) { + return _allowances[_owner][spender]; + } + + // ============================================================= + // QRC-20 WRITE FUNCTIONS + // ============================================================= + + /** + * @notice Transfer stQRL shares to another address + * @param to Recipient address + * @param amount Amount of shares to transfer + * @return success True if transfer succeeded + */ + function transfer(address to, uint256 amount) external whenNotPaused returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + /** + * @notice Approve a spender to transfer stQRL shares on your behalf + * @param spender The address to approve + * @param amount The amount of shares to approve + * @return success True if approval succeeded + */ + function approve(address spender, uint256 amount) external returns (bool) { + _approve(msg.sender, spender, amount); + return true; + } + + /** + * @notice Transfer stQRL shares from one address to another (with approval) + * @param from Source address + * @param to Destination address + * @param amount Amount of shares to transfer + * @return success True if transfer succeeded + */ + function transferFrom(address from, address to, uint256 amount) external whenNotPaused returns (bool) { + if (amount == 0) revert ZeroAmount(); + + uint256 currentAllowance = _allowances[from][msg.sender]; + if (currentAllowance < amount) revert InsufficientAllowance(); + + // Decrease allowance (unless unlimited) + if (currentAllowance != type(uint256).max) { + _allowances[from][msg.sender] = currentAllowance - amount; + emit Approval(from, msg.sender, _allowances[from][msg.sender]); + } + + _transfer(from, to, amount); + return true; + } + + // ============================================================= + // SHARE VIEW FUNCTIONS + // ============================================================= + + /** + * @notice Returns the total shares in existence + * @dev Same as totalSupply() in fixed-balance model + * @return Total shares + */ + function totalShares() external view returns (uint256) { + return _totalShares; + } + + /** + * @notice Returns the shares held by an account + * @dev Same as balanceOf() in fixed-balance model + * @param account The address to query + * @return The account's share balance + */ + function sharesOf(address account) external view returns (uint256) { + return _shares[account]; + } + + /** + * @notice Returns the current QRL value of an account's shares + * @dev This is what would have been balanceOf() in a rebasing model + * Value changes as rewards accrue or slashing occurs + * @param account The address to query + * @return The account's stQRL value in QRL terms + */ + function getQRLValue(address account) public view returns (uint256) { + return getPooledQRLByShares(_shares[account]); + } + + /** + * @notice Convert a QRL amount to shares + * @dev shares = qrlAmount * (totalShares + VIRTUAL_SHARES) / (totalPooledQRL + VIRTUAL_ASSETS) + * Virtual offsets prevent first depositor inflation attacks. + * @param qrlAmount The QRL amount to convert + * @return The equivalent number of shares + */ + function getSharesByPooledQRL(uint256 qrlAmount) public view returns (uint256) { + // Use virtual shares/assets to prevent donation attacks + // Even with 0 real shares/assets, the virtual offset ensures fair pricing + return (qrlAmount * (_totalShares + VIRTUAL_SHARES)) / (_totalPooledQRL + VIRTUAL_ASSETS); + } + + /** + * @notice Convert shares to QRL amount + * @dev qrlAmount = shares * (totalPooledQRL + VIRTUAL_ASSETS) / (totalShares + VIRTUAL_SHARES) + * Virtual offsets prevent first depositor inflation attacks. + * @param sharesAmount The shares to convert + * @return The equivalent QRL amount + */ + function getPooledQRLByShares(uint256 sharesAmount) public view returns (uint256) { + // Use virtual shares/assets to prevent donation attacks + // This ensures consistent pricing with getSharesByPooledQRL + return (sharesAmount * (_totalPooledQRL + VIRTUAL_ASSETS)) / (_totalShares + VIRTUAL_SHARES); + } + + /** + * @notice Returns the total QRL controlled by the protocol + * @dev This is the sum of all staked QRL plus rewards minus slashing + * @return Total pooled QRL + */ + function totalPooledQRL() external view returns (uint256) { + return _totalPooledQRL; + } + + /** + * @notice Returns the current exchange rate (QRL per share, scaled by 1e18) + * @dev Useful for UI display and calculations. Uses virtual offsets for consistency. + * @return Exchange rate (1e18 = 1:1) + */ + function getExchangeRate() external view returns (uint256) { + // Use virtual offsets for consistency with share conversion functions + return ((_totalPooledQRL + VIRTUAL_ASSETS) * 1e18) / (_totalShares + VIRTUAL_SHARES); + } + + // ============================================================= + // DEPOSIT POOL FUNCTIONS + // ============================================================= + + /** + * @notice Mint new shares to a recipient + * @dev Only callable by DepositPool when user deposits QRL + * @param to Recipient of the new shares + * @param qrlAmount Amount of QRL being deposited + * @return shares Number of shares minted + */ + function mintShares(address to, uint256 qrlAmount) external onlyDepositPool whenNotPaused returns (uint256 shares) { + if (to == address(0)) revert ZeroAddress(); + if (qrlAmount == 0) revert ZeroAmount(); + + shares = getSharesByPooledQRL(qrlAmount); + if (shares == 0) revert ZeroAmount(); + + _totalShares += shares; + _shares[to] += shares; + + // Note: totalPooledQRL is updated separately via updateTotalPooledQRL + // This allows DepositPool to batch updates + + emit SharesMinted(to, shares, qrlAmount); + emit Transfer(address(0), to, shares); + + return shares; + } + + /** + * @notice Burn shares from an account + * @dev Only callable by DepositPool when user withdraws QRL + * @param from Account to burn shares from + * @param sharesAmount Number of shares to burn + * @return qrlAmount Amount of QRL the burned shares were worth + */ + function burnShares(address from, uint256 sharesAmount) + external + onlyDepositPool + whenNotPaused + returns (uint256 qrlAmount) + { + if (from == address(0)) revert ZeroAddress(); + if (sharesAmount == 0) revert ZeroAmount(); + if (_shares[from] < sharesAmount) revert InsufficientBalance(); + + qrlAmount = getPooledQRLByShares(sharesAmount); + + _shares[from] -= sharesAmount; + _totalShares -= sharesAmount; + + // Note: totalPooledQRL is updated separately via updateTotalPooledQRL + + emit SharesBurned(from, sharesAmount, qrlAmount); + emit Transfer(from, address(0), sharesAmount); + + return qrlAmount; + } + + /** + * @notice Update the total pooled QRL + * @dev Called by DepositPool after syncing rewards/slashing + * This changes the exchange rate (affects getQRLValue, not balanceOf) + * @param newTotalPooledQRL The new total pooled QRL amount + */ + function updateTotalPooledQRL(uint256 newTotalPooledQRL) external onlyDepositPool { + uint256 previousAmount = _totalPooledQRL; + _totalPooledQRL = newTotalPooledQRL; + emit TotalPooledQRLUpdated(previousAmount, newTotalPooledQRL); + } + + // ============================================================= + // SHARE LOCKING FUNCTIONS + // ============================================================= + + /** + * @notice Lock shares for a pending withdrawal + * @dev Only callable by DepositPool. Locked shares cannot be transferred. + * @param account The account whose shares to lock + * @param sharesAmount Number of shares to lock + */ + function lockShares(address account, uint256 sharesAmount) external onlyDepositPool { + _lockedShares[account] += sharesAmount; + } + + /** + * @notice Unlock shares after withdrawal claim or cancellation + * @dev Only callable by DepositPool + * @param account The account whose shares to unlock + * @param sharesAmount Number of shares to unlock + */ + function unlockShares(address account, uint256 sharesAmount) external onlyDepositPool { + _lockedShares[account] -= sharesAmount; + } + + /** + * @notice Returns the locked shares for an account + * @param account The address to query + * @return The number of locked shares + */ + function lockedSharesOf(address account) external view returns (uint256) { + return _lockedShares[account]; + } + + // ============================================================= + // INTERNAL FUNCTIONS + // ============================================================= + + /** + * @dev Internal transfer logic - amount is in shares + */ + function _transfer(address from, address to, uint256 amount) internal { + if (from == address(0)) revert ZeroAddress(); + if (to == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + if (_shares[from] < amount) revert InsufficientBalance(); + if (_shares[from] - _lockedShares[from] < amount) revert InsufficientUnlockedShares(); + + _shares[from] -= amount; + _shares[to] += amount; + + emit Transfer(from, to, amount); + } + + /** + * @dev Internal approve logic - amount is in shares + */ + function _approve(address _owner, address spender, uint256 amount) internal { + if (_owner == address(0)) revert ZeroAddress(); + if (spender == address(0)) revert ZeroAddress(); + + _allowances[_owner][spender] = amount; + emit Approval(_owner, spender, amount); + } + + // ============================================================= + // ADMIN FUNCTIONS + // ============================================================= + + /** + * @notice Set the DepositPool contract address + * @dev Can only be called once by owner + * @param _depositPool The DepositPool contract address + */ + function setDepositPool(address _depositPool) external onlyOwner { + if (_depositPool == address(0)) revert ZeroAddress(); + if (depositPool != address(0)) revert DepositPoolAlreadySet(); + + emit DepositPoolSet(depositPool, _depositPool); + depositPool = _depositPool; + } + + /** + * @notice Pause the contract + * @dev Blocks transfers, minting, and burning + */ + function pause() external onlyOwner { + paused = true; + emit Paused(msg.sender); + } + + /** + * @notice Unpause the contract + */ + function unpause() external onlyOwner { + paused = false; + emit Unpaused(msg.sender); + } + + /** + * @notice Transfer ownership + * @param newOwner The new owner address + */ + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + + /** + * @notice Renounce ownership (irreversible) + * @dev Use after DepositPool is set and system is stable + */ + function renounceOwnership() external onlyOwner { + emit OwnershipTransferred(owner, address(0)); + owner = address(0); + } +} diff --git a/hyperion/test/DepositPool-v2.t.hyp b/hyperion/test/DepositPool-v2.t.hyp new file mode 100644 index 0000000..fbea357 --- /dev/null +++ b/hyperion/test/DepositPool-v2.t.hyp @@ -0,0 +1,1006 @@ +// SPDX-License-Identifier: GPL-3.0 +// Generated from ../test/DepositPool-v2.t.sol by scripts/sync-hyperion.js. +// Edit the Solidity source first, then re-run this script. +pragma hyperion ^0.8.24; + +import "forge-std/Test.hyp"; +import "../contracts/stQRL-v2.hyp"; +import "../contracts/DepositPool-v2.hyp"; + +/** + * @title DepositPool v2 Integration Tests + * @notice Tests for deposit, withdrawal, and reward sync flows + */ +contract DepositPoolV2Test is Test { + stQRLv2 public token; + DepositPoolV2 public pool; + + address public owner; + address public user1; + address public user2; + + event Deposited(address indexed user, uint256 qrlAmount, uint256 sharesReceived); + event WithdrawalRequested(address indexed user, uint256 shares, uint256 qrlAmount, uint256 requestBlock); + event WithdrawalClaimed(address indexed user, uint256 shares, uint256 qrlAmount); + event RewardsSynced(uint256 rewardsAmount, uint256 newTotalPooled, uint256 blockNumber); + event SlashingDetected(uint256 lossAmount, uint256 newTotalPooled, uint256 blockNumber); + + function setUp() public { + owner = address(this); + user1 = address(0x1); + user2 = address(0x2); + + // Deploy contracts + token = new stQRLv2(); + pool = new DepositPoolV2(); + + // Link contracts + pool.setStQRL(address(token)); + token.setDepositPool(address(pool)); + + // Fund test users + vm.deal(user1, 1000 ether); + vm.deal(user2, 1000 ether); + } + + // ========================================================================= + // DEPOSIT TESTS + // ========================================================================= + + function test_Deposit() public { + vm.prank(user1); + uint256 shares = pool.deposit{value: 100 ether}(); + + assertEq(shares, 100 ether); + assertEq(token.balanceOf(user1), 100 ether); + assertEq(pool.bufferedQRL(), 100 ether); + } + + function test_Deposit_MinimumEnforced() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.BelowMinDeposit.selector); + pool.deposit{value: 0.01 ether}(); // Below 0.1 minimum + } + + function test_MultipleDeposits() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user2); + pool.deposit{value: 200 ether}(); + + assertEq(token.balanceOf(user1), 100 ether); + assertEq(token.balanceOf(user2), 200 ether); + assertEq(pool.bufferedQRL(), 300 ether); + assertEq(token.totalSupply(), 300 ether); + } + + function test_DepositAfterRewards() public { + // User1 deposits 100 QRL + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Simulate rewards by sending ETH directly and syncing + vm.deal(address(pool), 150 ether); // 50 QRL rewards + pool.syncRewards(); + + // User1's shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // But QRL value increased (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 150 ether, 1e14); + + // User2 deposits 150 QRL (should get ~100 shares at new rate) + vm.prank(user2); + uint256 shares = pool.deposit{value: 150 ether}(); + + // User2 gets shares based on current rate + // Rate: 150 QRL / 100 shares = 1.5 QRL per share + // For 150 QRL: 150 / 1.5 ≈ 100 shares (approx due to virtual shares) + assertApproxEqRel(shares, 100 ether, 1e14); + assertApproxEqRel(token.sharesOf(user2), 100 ether, 1e14); + } + + // ========================================================================= + // REWARD SYNC TESTS + // ========================================================================= + + function test_SyncRewards_DetectsRewards() public { + // User deposits + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Initial state + assertEq(token.totalPooledQRL(), 100 ether); + assertEq(pool.totalRewardsReceived(), 0); + + // Simulate validator rewards by adding ETH to contract + vm.deal(address(pool), 110 ether); // 10 QRL rewards + + // Sync should detect rewards + vm.expectEmit(true, true, true, true); + emit RewardsSynced(10 ether, 110 ether, block.number); + pool.syncRewards(); + + assertEq(token.totalPooledQRL(), 110 ether); + assertEq(pool.totalRewardsReceived(), 10 ether); + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // QRL value reflects rewards (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); + } + + function test_SyncRewards_DetectsSlashing() public { + // This test demonstrates slashing detection + // Slashing math is verified in stQRL tests (balance decrease) + // Here we just verify the sync doesn't break with no change + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Sync should work without changes + pool.syncRewards(); + assertEq(token.totalPooledQRL(), 100 ether); + } + + function test_SyncRewards_NoChangeWhenBalanceMatch() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + uint256 rewardsBefore = pool.totalRewardsReceived(); + pool.syncRewards(); + uint256 rewardsAfter = pool.totalRewardsReceived(); + + // No change in rewards + assertEq(rewardsBefore, rewardsAfter); + } + + // ========================================================================= + // WITHDRAWAL TESTS + // ========================================================================= + + function test_RequestWithdrawal() public { + // Deposit + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Request withdrawal + vm.prank(user1); + (uint256 requestId, uint256 qrlAmount) = pool.requestWithdrawal(50 ether); + + assertEq(requestId, 0); + assertEq(qrlAmount, 50 ether); + + (uint256 shares, uint256 qrl, uint256 requestBlock, bool canClaim,, bool claimed) = + pool.getWithdrawalRequest(user1, 0); + + assertEq(shares, 50 ether); + assertEq(qrl, 50 ether); + assertEq(requestBlock, block.number); + assertFalse(canClaim); // Not enough time passed + assertFalse(claimed); + } + + function test_ClaimWithdrawal() public { + // Deposit + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Request withdrawal FIRST (captures QRL value at current 1:1 rate) + vm.prank(user1); + pool.requestWithdrawal(50 ether); + + // Fund withdrawal reserve AFTER request (reclassify pooled QRL for the claim) + pool.fundWithdrawalReserve(50 ether); + + // Wait for withdrawal delay + vm.roll(block.number + 129); // > 128 blocks + + // Claim + uint256 balanceBefore = user1.balance; + vm.prank(user1); + uint256 claimed = pool.claimWithdrawal(); + + assertEq(claimed, 50 ether); + assertEq(user1.balance - balanceBefore, 50 ether); + assertEq(token.balanceOf(user1), 50 ether); + } + + function test_ClaimWithdrawal_TooEarly() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + pool.requestWithdrawal(50 ether); + + pool.fundWithdrawalReserve(50 ether); + + // Try to claim immediately (should fail) + vm.prank(user1); + vm.expectRevert(DepositPoolV2.WithdrawalNotReady.selector); + pool.claimWithdrawal(); + } + + function test_ClaimWithdrawal_InsufficientReserve() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // No withdrawal reserve funded + + vm.prank(user1); + pool.requestWithdrawal(50 ether); + + vm.roll(block.number + 129); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.InsufficientReserve.selector); + pool.claimWithdrawal(); + } + + function test_CancelWithdrawal() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + (uint256 requestId,) = pool.requestWithdrawal(50 ether); + + assertEq(pool.totalWithdrawalShares(), 50 ether); + + vm.prank(user1); + pool.cancelWithdrawal(requestId); + + assertEq(pool.totalWithdrawalShares(), 0); + } + + function test_WithdrawalAfterRewards() public { + // Deposit 100 QRL + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add 10% rewards + vm.deal(address(pool), 110 ether); + pool.syncRewards(); + + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // User's shares now worth 110 QRL (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); + + // Request withdrawal of all shares BEFORE funding reserve + // (so shares are valued at current rate: ~110 QRL for 100 shares) + vm.prank(user1); + (, uint256 qrlAmount) = pool.requestWithdrawal(100 ether); + + // Approx due to virtual shares + assertApproxEqRel(qrlAmount, 110 ether, 1e14); + + // Fund withdrawal reserve (reclassify from totalPooledQRL to cover the claim) + pool.fundWithdrawalReserve(token.totalPooledQRL()); + + vm.roll(block.number + 129); + + uint256 balanceBefore = user1.balance; + vm.prank(user1); + uint256 claimed = pool.claimWithdrawal(); + + // Should receive ~110 QRL (original + rewards) + assertApproxEqRel(user1.balance - balanceBefore, 110 ether, 1e14); + assertEq(user1.balance - balanceBefore, claimed); + } + + // ========================================================================= + // SLASHING SIMULATION + // ========================================================================= + + function test_SlashingReducesWithdrawalAmount() public { + // Deposit 100 QRL + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // User's shares are worth 100 QRL initially (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); + + // Simulate slashing by directly reducing the contract balance + // In real scenarios, this happens through validator slashing on the beacon chain + vm.deal(address(pool), 90 ether); // Was 100, now 90 + + // Sync to detect the "slashing" + pool.syncRewards(); + + // User's shares now worth less (90 QRL instead of 100) (approx) + assertApproxEqRel(token.getQRLValue(user1), 90 ether, 1e14); + + // Request withdrawal of all shares FIRST (captures slashed QRL value ~90) + vm.prank(user1); + (, uint256 qrlAmount) = pool.requestWithdrawal(100 ether); + + // Should only get ~90 QRL (slashed amount) (approx due to virtual shares) + assertApproxEqRel(qrlAmount, 90 ether, 1e14); + + // Fund withdrawal reserve AFTER request (reclassify pooled QRL for the claim) + pool.fundWithdrawalReserve(token.totalPooledQRL()); + } + + function test_SlashingDetected_EmitsEvent() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Simulate slashing by directly reducing the contract balance + vm.deal(address(pool), 90 ether); // Was 100, now 90 + + vm.expectEmit(true, true, true, true); + emit SlashingDetected(10 ether, 90 ether, block.number); + pool.syncRewards(); + } + + // ========================================================================= + // VALIDATOR FUNDING TESTS + // ========================================================================= + + function test_CanFundValidator() public { + // Fund users with enough ETH for this test + vm.deal(user1, 20000 ether); + vm.deal(user2, 20000 ether); + + // Deposit less than threshold + vm.prank(user1); + pool.deposit{value: 20000 ether}(); + + (bool possible, uint256 buffered) = pool.canFundValidator(); + assertFalse(possible); + assertEq(buffered, 20000 ether); + + // Deposit more to reach threshold + vm.prank(user2); + pool.deposit{value: 20000 ether}(); + + (possible, buffered) = pool.canFundValidator(); + assertTrue(possible); + assertEq(buffered, 40000 ether); + } + + function test_FundValidatorMVP() public { + // Deposit enough for validator (40,000 QRL per Zond mainnet config) + vm.deal(user1, 40000 ether); + vm.prank(user1); + pool.deposit{value: 40000 ether}(); + + uint256 validatorId = pool.fundValidatorMVP(); + + assertEq(validatorId, 0); + assertEq(pool.validatorCount(), 1); + assertEq(pool.bufferedQRL(), 0); + } + + // ========================================================================= + // VIEW FUNCTION TESTS + // ========================================================================= + + function test_GetPoolStatus() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + ( + uint256 totalPooled, + uint256 totalShares, + uint256 buffered, + uint256 validators, + uint256 pendingShares, + uint256 reserve, + uint256 rate + ) = pool.getPoolStatus(); + + assertEq(totalPooled, 100 ether); + assertEq(totalShares, 100 ether); + assertEq(buffered, 100 ether); + assertEq(validators, 0); + assertEq(pendingShares, 0); + assertEq(reserve, 0); + assertEq(rate, 1e18); // 1:1 exchange rate + } + + function test_GetRewardStats() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add rewards + vm.deal(address(pool), 110 ether); + pool.syncRewards(); + + (uint256 totalRewards, uint256 totalSlashing, uint256 netRewards, uint256 lastSync) = pool.getRewardStats(); + + assertEq(totalRewards, 10 ether); + assertEq(totalSlashing, 0); + assertEq(netRewards, 10 ether); + assertEq(lastSync, block.number); + } + + // ========================================================================= + // ACCESS CONTROL TESTS + // ========================================================================= + + function test_OnlyOwnerCanFundValidator() public { + vm.deal(user1, 40000 ether); + vm.prank(user1); + pool.deposit{value: 40000 ether}(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.fundValidatorMVP(); + } + + function test_OnlyOwnerCanPause() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.pause(); + } + + function test_PauseBlocksDeposits() public { + pool.pause(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.ContractPaused.selector); + pool.deposit{value: 100 ether}(); + } + + // ========================================================================= + // FUZZ TESTS + // ========================================================================= + + function testFuzz_DepositAndWithdraw(uint256 amount) public { + amount = bound(amount, 100 ether, 10000 ether); + + vm.deal(user1, amount * 2); + + vm.prank(user1); + pool.deposit{value: amount}(); + + assertEq(token.balanceOf(user1), amount); + + // Request withdrawal FIRST (captures QRL value at current rate) + uint256 shares = token.sharesOf(user1); + vm.prank(user1); + pool.requestWithdrawal(shares); + + // Fund reserve AFTER request (reclassify deposited QRL for the claim) + pool.fundWithdrawalReserve(amount); + + vm.roll(block.number + 129); + + uint256 balanceBefore = user1.balance; + vm.prank(user1); + pool.claimWithdrawal(); + + // Should get back approximately the same amount (minus any rounding) + assertApproxEqRel(user1.balance - balanceBefore, amount, 1e15); + } + + // ========================================================================= + // DEPOSIT ERROR TESTS + // ========================================================================= + + function test_Deposit_StQRLNotSet_Reverts() public { + // Deploy fresh pool without stQRL set + DepositPoolV2 freshPool = new DepositPoolV2(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.StQRLNotSet.selector); + freshPool.deposit{value: 1 ether}(); + } + + function test_Deposit_ZeroAmount_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.BelowMinDeposit.selector); + pool.deposit{value: 0}(); + } + + function test_Deposit_EmitsEvent() public { + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit Deposited(user1, 100 ether, 100 ether); + pool.deposit{value: 100 ether}(); + } + + // ========================================================================= + // WITHDRAWAL ERROR TESTS + // ========================================================================= + + function test_RequestWithdrawal_ZeroShares_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.ZeroAmount.selector); + pool.requestWithdrawal(0); + } + + function test_RequestWithdrawal_InsufficientShares_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.InsufficientShares.selector); + pool.requestWithdrawal(150 ether); + } + + function test_MultipleWithdrawalRequests() public { + // Multiple withdrawal requests are now allowed + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + (uint256 requestId1,) = pool.requestWithdrawal(50 ether); + + vm.prank(user1); + (uint256 requestId2,) = pool.requestWithdrawal(25 ether); + + assertEq(requestId1, 0); + assertEq(requestId2, 1); + assertEq(pool.totalWithdrawalShares(), 75 ether); + + // Verify both requests exist + (uint256 total, uint256 pending) = pool.getWithdrawalRequestCount(user1); + assertEq(total, 2); + assertEq(pending, 2); + } + + function test_RequestWithdrawal_WhenPaused_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + pool.pause(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.ContractPaused.selector); + pool.requestWithdrawal(50 ether); + } + + function test_RequestWithdrawal_EmitsEvent() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit WithdrawalRequested(user1, 50 ether, 50 ether, block.number); + pool.requestWithdrawal(50 ether); + } + + function test_ClaimWithdrawal_NoRequest_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NoWithdrawalPending.selector); + pool.claimWithdrawal(); + } + + function test_ClaimWithdrawal_EmitsEvent() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Request FIRST (captures QRL value), then fund reserve + vm.prank(user1); + pool.requestWithdrawal(50 ether); + + pool.fundWithdrawalReserve(50 ether); + + vm.roll(block.number + 129); + + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit WithdrawalClaimed(user1, 50 ether, 50 ether); + pool.claimWithdrawal(); + } + + function test_CancelWithdrawal_NoRequest_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.InvalidWithdrawalIndex.selector); + pool.cancelWithdrawal(0); + } + + // ========================================================================= + // VALIDATOR FUNDING ERROR TESTS + // ========================================================================= + + function test_FundValidatorMVP_InsufficientBuffer_Reverts() public { + // Deposit less than validator stake + vm.deal(user1, 5000 ether); + vm.prank(user1); + pool.deposit{value: 5000 ether}(); + + vm.expectRevert(DepositPoolV2.InsufficientBuffer.selector); + pool.fundValidatorMVP(); + } + + function test_FundValidatorMVP_EmitsEvent() public { + vm.deal(user1, 40000 ether); + vm.prank(user1); + pool.deposit{value: 40000 ether}(); + + vm.expectEmit(true, false, false, true); + emit ValidatorFunded(0, "", 40000 ether); + pool.fundValidatorMVP(); + } + + // ========================================================================= + // ADMIN FUNCTION TESTS + // ========================================================================= + + function test_SetStQRL() public { + DepositPoolV2 freshPool = new DepositPoolV2(); + address newStQRL = address(0x123); + + freshPool.setStQRL(newStQRL); + + assertEq(address(freshPool.stQRL()), newStQRL); + } + + function test_SetStQRL_ZeroAddress_Reverts() public { + DepositPoolV2 freshPool = new DepositPoolV2(); + + vm.expectRevert(DepositPoolV2.ZeroAddress.selector); + freshPool.setStQRL(address(0)); + } + + function test_SetStQRL_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.setStQRL(address(0x123)); + } + + function test_SetStQRL_AlreadySet_Reverts() public { + // stQRL is already set in setUp() + vm.expectRevert(DepositPoolV2.StQRLAlreadySet.selector); + pool.setStQRL(address(0x123)); + } + + function test_SetMinDeposit() public { + pool.setMinDeposit(200 ether); + assertEq(pool.minDeposit(), 200 ether); + + // Cannot set below the current floor (100 ether by default) + vm.expectRevert(DepositPoolV2.BelowMinDepositFloor.selector); + pool.setMinDeposit(50 ether); + } + + function test_SetMinDeposit_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.setMinDeposit(200 ether); + } + + function test_SetMinDeposit_EmitsEvent() public { + vm.expectEmit(false, false, false, true); + emit MinDepositUpdated(200 ether); + pool.setMinDeposit(200 ether); + } + + function test_Unpause() public { + pool.pause(); + assertTrue(pool.paused()); + + pool.unpause(); + assertFalse(pool.paused()); + } + + function test_Unpause_NotOwner_Reverts() public { + pool.pause(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.unpause(); + } + + function test_TransferOwnership() public { + address newOwner = address(0x999); + + pool.transferOwnership(newOwner); + + assertEq(pool.owner(), newOwner); + } + + function test_TransferOwnership_ZeroAddress_Reverts() public { + vm.expectRevert(DepositPoolV2.ZeroAddress.selector); + pool.transferOwnership(address(0)); + } + + function test_TransferOwnership_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.transferOwnership(user1); + } + + function test_TransferOwnership_EmitsEvent() public { + address newOwner = address(0x999); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, newOwner); + pool.transferOwnership(newOwner); + } + + function test_EmergencyWithdraw() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Send some excess funds to the contract (stuck tokens) + vm.deal(address(pool), 110 ether); // 100 pooled + 10 excess + + address recipient = address(0x999); + uint256 balanceBefore = recipient.balance; + + // Can only withdraw excess (10 ether) + pool.emergencyWithdraw(recipient, 10 ether); + + assertEq(recipient.balance - balanceBefore, 10 ether); + } + + function test_EmergencyWithdraw_ExceedsRecoverable_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // No excess funds - balance equals pooled QRL + // Try to withdraw pool funds + vm.expectRevert(DepositPoolV2.ExceedsRecoverableAmount.selector); + pool.emergencyWithdraw(address(0x999), 10 ether); + } + + function test_EmergencyWithdraw_ZeroAddress_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add excess funds + vm.deal(address(pool), 110 ether); + + vm.expectRevert(DepositPoolV2.ZeroAddress.selector); + pool.emergencyWithdraw(address(0), 10 ether); + } + + function test_EmergencyWithdraw_ZeroAmount_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.expectRevert(DepositPoolV2.ZeroAmount.selector); + pool.emergencyWithdraw(address(0x999), 0); + } + + function test_EmergencyWithdraw_NotOwner_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add excess funds + vm.deal(address(pool), 110 ether); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.emergencyWithdraw(user1, 10 ether); + } + + // ========================================================================= + // VIEW FUNCTION TESTS + // ========================================================================= + + function test_PreviewDeposit() public view { + // Before any deposits, 1:1 ratio + uint256 shares = pool.previewDeposit(100 ether); + assertEq(shares, 100 ether); + } + + function test_PreviewDeposit_AfterRewards() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add 50% rewards + vm.deal(address(pool), 150 ether); + pool.syncRewards(); + + // 100 QRL should now get fewer shares + uint256 shares = pool.previewDeposit(100 ether); + // At 1.5 QRL/share rate, 100 QRL = 66.67 shares + assertApproxEqRel(shares, 66.67 ether, 1e16); + } + + function test_PreviewDeposit_StQRLNotSet() public { + DepositPoolV2 freshPool = new DepositPoolV2(); + + // Should return 1:1 if stQRL not set + uint256 shares = freshPool.previewDeposit(100 ether); + assertEq(shares, 100 ether); + } + + // ========================================================================= + // RECEIVE FUNCTION TESTS + // ========================================================================= + + function test_Receive_IsNoOp() public { + // receive() is a no-op — incoming ETH does NOT auto-add to withdrawalReserve. + // _syncRewards() will later detect it as a balance increase (rewards). + uint256 reserveBefore = pool.withdrawalReserve(); + + // Send ETH directly to contract + (bool success,) = address(pool).call{value: 50 ether}(""); + assertTrue(success); + + // withdrawalReserve unchanged (receive is no-op) + assertEq(pool.withdrawalReserve(), reserveBefore); + + // syncRewards picks it up as rewards + pool.syncRewards(); + assertEq(pool.totalRewardsReceived(), 50 ether); + } + + function test_Receive_DetectedAsRewardsBySyncRewards() public { + // Deposit first so there's an existing totalPooledQRL baseline + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Send ETH directly — receive() is a no-op, no event emitted + (bool success,) = address(pool).call{value: 50 ether}(""); + assertTrue(success); + + // syncRewards detects the 50 ether increase as rewards + vm.expectEmit(true, true, true, true); + emit RewardsSynced(50 ether, 150 ether, block.number); + pool.syncRewards(); + } + + function test_FundWithdrawalReserve() public { + // Need deposits first so there's totalPooledQRL to reclassify + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + uint256 reserveBefore = pool.withdrawalReserve(); + uint256 pooledBefore = token.totalPooledQRL(); + + pool.fundWithdrawalReserve(50 ether); + + assertEq(pool.withdrawalReserve(), reserveBefore + 50 ether); + assertEq(token.totalPooledQRL(), pooledBefore - 50 ether); + } + + function test_FundWithdrawalReserve_EmitsEvent() public { + // Need deposits first so there's totalPooledQRL to reclassify + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.expectEmit(false, false, false, true); + emit WithdrawalReserveFunded(50 ether); + pool.fundWithdrawalReserve(50 ether); + } + + // ========================================================================= + // MULTI-USER SCENARIOS + // ========================================================================= + + function test_MultipleUsersWithdrawalQueue() public { + // User1 and User2 both deposit + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user2); + pool.deposit{value: 100 ether}(); + + // Verify initial state + assertEq(token.totalPooledQRL(), 200 ether); + assertEq(token.totalShares(), 200 ether); + + // Both request withdrawals FIRST (captures QRL value at 1:1 rate) + vm.prank(user1); + pool.requestWithdrawal(50 ether); + + vm.prank(user2); + pool.requestWithdrawal(50 ether); + + assertEq(pool.totalWithdrawalShares(), 100 ether); + + // Fund withdrawal reserve AFTER requests (reclassify enough for both claims) + pool.fundWithdrawalReserve(100 ether); + + // Verify reserve and pooled state + assertEq(token.totalPooledQRL(), 100 ether); + assertEq(pool.withdrawalReserve(), 100 ether); + + // Wait for delay + vm.roll(block.number + 129); + + // User1 claims - should receive exactly 50 ether + uint256 user1BalanceBefore = user1.balance; + vm.prank(user1); + uint256 user1Claimed = pool.claimWithdrawal(); + assertEq(user1Claimed, 50 ether); + assertEq(user1.balance - user1BalanceBefore, 50 ether); + + // User2 claims - should also receive exactly 50 ether + uint256 user2BalanceBefore = user2.balance; + vm.prank(user2); + uint256 user2Claimed = pool.claimWithdrawal(); + assertEq(user2Claimed, 50 ether); + assertEq(user2.balance - user2BalanceBefore, 50 ether); + + // Queue should be empty + assertEq(pool.totalWithdrawalShares(), 0); + } + + function test_RewardsDistributedProportionally() public { + // User1 deposits 100 QRL + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // User2 deposits 200 QRL + vm.prank(user2); + pool.deposit{value: 200 ether}(); + + // Add 30 QRL rewards (10% of 300) + vm.deal(address(pool), 330 ether); + pool.syncRewards(); + + // User1 has 100/300 = 33.33% of shares -> 33.33% of 330 = 110 QRL (approx) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); + + // User2 has 200/300 = 66.67% of shares -> 66.67% of 330 = 220 QRL (approx) + assertApproxEqRel(token.getQRLValue(user2), 220 ether, 1e14); + } + + // ========================================================================= + // MIN DEPOSIT FLOOR TESTS + // ========================================================================= + + function test_SetMinDepositFloor() public { + // Default floor is 100 ether + assertEq(pool.minDepositFloor(), 100 ether); + + // Owner can lower the floor + pool.setMinDepositFloor(1 ether); + assertEq(pool.minDepositFloor(), 1 ether); + + // Owner can raise it back + pool.setMinDepositFloor(50 ether); + assertEq(pool.minDepositFloor(), 50 ether); + } + + function test_SetMinDepositFloor_BelowAbsoluteMin_Reverts() public { + // Cannot set floor below ABSOLUTE_MIN_DEPOSIT (0.001 ether) + vm.expectRevert(DepositPoolV2.BelowAbsoluteMin.selector); + pool.setMinDepositFloor(0.0001 ether); + + // Zero also reverts + vm.expectRevert(DepositPoolV2.BelowAbsoluteMin.selector); + pool.setMinDepositFloor(0); + } + + function test_SetMinDepositFloor_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.setMinDepositFloor(1 ether); + } + + function test_SetMinDepositFloor_EmitsEvent() public { + vm.expectEmit(false, false, false, true); + emit MinDepositFloorUpdated(1 ether); + pool.setMinDepositFloor(1 ether); + } + + function test_SetMinDeposit_AfterFloorLowered() public { + // Lower the floor first + pool.setMinDepositFloor(1 ether); + assertEq(pool.minDepositFloor(), 1 ether); + + // Now we can lower minDeposit below the old 100 ether floor + pool.setMinDeposit(5 ether); + assertEq(pool.minDeposit(), 5 ether); + + // Deposits at the new lower minimum work + vm.deal(user1, 10 ether); + vm.prank(user1); + uint256 shares = pool.deposit{value: 5 ether}(); + assertEq(shares, 5 ether); + + // Still cannot go below the new floor + vm.expectRevert(DepositPoolV2.BelowMinDepositFloor.selector); + pool.setMinDeposit(0.5 ether); + } + + // ========================================================================= + // EVENT DECLARATIONS + // ========================================================================= + + event MinDepositUpdated(uint256 newMinDeposit); + event MinDepositFloorUpdated(uint256 newFloor); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event ValidatorFunded(uint256 indexed validatorId, bytes pubkey, uint256 amount); + event WithdrawalReserveFunded(uint256 amount); +} diff --git a/hyperion/test/ValidatorManager.t.hyp b/hyperion/test/ValidatorManager.t.hyp new file mode 100644 index 0000000..3197256 --- /dev/null +++ b/hyperion/test/ValidatorManager.t.hyp @@ -0,0 +1,722 @@ +// SPDX-License-Identifier: GPL-3.0 +// Generated from ../test/ValidatorManager.t.sol by scripts/sync-hyperion.js. +// Edit the Solidity source first, then re-run this script. +pragma hyperion ^0.8.24; + +import "forge-std/Test.hyp"; +import "../contracts/ValidatorManager.hyp"; + +/** + * @title ValidatorManager Tests + * @notice Unit tests for validator lifecycle management + */ +contract ValidatorManagerTest is Test { + ValidatorManager public manager; + address public owner; + address public depositPool; + address public operator; + address public randomUser; + + // Dilithium pubkey is 2592 bytes + uint256 constant PUBKEY_LENGTH = 2592; + uint256 constant VALIDATOR_STAKE = 40_000 ether; + + // Events to test + event ValidatorRegistered(uint256 indexed validatorId, bytes pubkey, ValidatorManager.ValidatorStatus status); + event ValidatorActivated(uint256 indexed validatorId, uint256 activatedBlock); + event ValidatorExitRequested(uint256 indexed validatorId, uint256 requestBlock); + event ValidatorExited(uint256 indexed validatorId, uint256 exitedBlock); + event ValidatorSlashed(uint256 indexed validatorId, uint256 slashedBlock); + event DepositPoolSet(address indexed depositPool); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + function setUp() public { + owner = address(this); + depositPool = address(0x1); + operator = address(0x2); + randomUser = address(0x3); + + manager = new ValidatorManager(); + manager.setDepositPool(depositPool); + } + + // ========================================================================= + // HELPERS + // ========================================================================= + + function _generatePubkey(uint256 seed) internal pure returns (bytes memory) { + bytes memory pubkey = new bytes(PUBKEY_LENGTH); + for (uint256 i = 0; i < PUBKEY_LENGTH; i++) { + pubkey[i] = bytes1(uint8(uint256(keccak256(abi.encodePacked(seed, i))) % 256)); + } + return pubkey; + } + + function _registerValidator(uint256 seed) internal returns (uint256 validatorId, bytes memory pubkey) { + pubkey = _generatePubkey(seed); + vm.prank(depositPool); + validatorId = manager.registerValidator(pubkey); + } + + function _registerAndActivate(uint256 seed) internal returns (uint256 validatorId, bytes memory pubkey) { + (validatorId, pubkey) = _registerValidator(seed); + manager.activateValidator(validatorId); + } + + // ========================================================================= + // INITIALIZATION TESTS + // ========================================================================= + + function test_InitialState() public view { + assertEq(manager.owner(), owner); + assertEq(manager.depositPool(), depositPool); + assertEq(manager.totalValidators(), 0); + assertEq(manager.activeValidatorCount(), 0); + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.VALIDATOR_STAKE(), VALIDATOR_STAKE); + } + + function test_GetStats_Initial() public view { + (uint256 total, uint256 pending, uint256 active, uint256 totalStaked) = manager.getStats(); + assertEq(total, 0); + assertEq(pending, 0); + assertEq(active, 0); + assertEq(totalStaked, 0); + } + + // ========================================================================= + // VALIDATOR REGISTRATION TESTS + // ========================================================================= + + function test_RegisterValidator() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + assertEq(validatorId, 1); + assertEq(manager.totalValidators(), 1); + assertEq(manager.pendingValidatorCount(), 1); + assertEq(manager.activeValidatorCount(), 0); + + ( + bytes memory storedPubkey, + ValidatorManager.ValidatorStatus status, + uint256 activatedBlock, + uint256 exitedBlock + ) = manager.getValidator(validatorId); + + assertEq(storedPubkey, pubkey); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Pending)); + assertEq(activatedBlock, 0); + assertEq(exitedBlock, 0); + } + + function test_RegisterValidator_EmitsEvent() public { + bytes memory pubkey = _generatePubkey(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorRegistered(1, pubkey, ValidatorManager.ValidatorStatus.Pending); + + vm.prank(depositPool); + manager.registerValidator(pubkey); + } + + function test_RegisterValidator_ByOwner() public { + bytes memory pubkey = _generatePubkey(1); + uint256 validatorId = manager.registerValidator(pubkey); + assertEq(validatorId, 1); + } + + function test_RegisterValidator_NotAuthorized_Reverts() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotAuthorized.selector); + manager.registerValidator(pubkey); + } + + function test_RegisterValidator_InvalidPubkeyLength_Reverts() public { + bytes memory shortPubkey = new bytes(100); + + vm.prank(depositPool); + vm.expectRevert(ValidatorManager.InvalidPubkeyLength.selector); + manager.registerValidator(shortPubkey); + } + + function test_RegisterValidator_EmptyPubkey_Reverts() public { + bytes memory emptyPubkey = new bytes(0); + + vm.prank(depositPool); + vm.expectRevert(ValidatorManager.InvalidPubkeyLength.selector); + manager.registerValidator(emptyPubkey); + } + + function test_RegisterValidator_Duplicate_Reverts() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(depositPool); + manager.registerValidator(pubkey); + + vm.prank(depositPool); + vm.expectRevert(ValidatorManager.ValidatorAlreadyExists.selector); + manager.registerValidator(pubkey); + } + + function test_RegisterValidator_MultipleValidators() public { + for (uint256 i = 1; i <= 5; i++) { + bytes memory pubkey = _generatePubkey(i); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + assertEq(validatorId, i); + } + + assertEq(manager.totalValidators(), 5); + assertEq(manager.pendingValidatorCount(), 5); + } + + // ========================================================================= + // VALIDATOR ACTIVATION TESTS + // ========================================================================= + + function test_ActivateValidator() public { + (uint256 validatorId,) = _registerValidator(1); + + assertEq(manager.pendingValidatorCount(), 1); + assertEq(manager.activeValidatorCount(), 0); + + manager.activateValidator(validatorId); + + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.activeValidatorCount(), 1); + + (, ValidatorManager.ValidatorStatus status, uint256 activatedBlock,) = manager.getValidator(validatorId); + + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Active)); + assertEq(activatedBlock, block.number); + } + + function test_ActivateValidator_EmitsEvent() public { + (uint256 validatorId,) = _registerValidator(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorActivated(validatorId, block.number); + + manager.activateValidator(validatorId); + } + + function test_ActivateValidator_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.activateValidator(validatorId); + } + + function test_ActivateValidator_NotPending_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + // Already active, cannot activate again + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.activateValidator(validatorId); + } + + function test_ActivateValidator_NonExistent_Reverts() public { + // Validator 999 doesn't exist (status is None) + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.activateValidator(999); + } + + // ========================================================================= + // BATCH ACTIVATION TESTS + // ========================================================================= + + function test_BatchActivateValidators() public { + // Register 5 validators + uint256[] memory ids = new uint256[](5); + for (uint256 i = 0; i < 5; i++) { + (ids[i],) = _registerValidator(i + 1); + } + + assertEq(manager.pendingValidatorCount(), 5); + assertEq(manager.activeValidatorCount(), 0); + + manager.batchActivateValidators(ids); + + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.activeValidatorCount(), 5); + } + + function test_BatchActivateValidators_SkipsNonPending() public { + // Register 3 validators + (uint256 id1,) = _registerValidator(1); + (uint256 id2,) = _registerValidator(2); + (uint256 id3,) = _registerValidator(3); + + // Activate id2 individually first + manager.activateValidator(id2); + + uint256[] memory ids = new uint256[](3); + ids[0] = id1; + ids[1] = id2; // Already active, should be skipped + ids[2] = id3; + + manager.batchActivateValidators(ids); + + // All should be active now + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.activeValidatorCount(), 3); + } + + function test_BatchActivateValidators_EmptyArray() public { + uint256[] memory ids = new uint256[](0); + manager.batchActivateValidators(ids); + // Should not revert, just do nothing + } + + function test_BatchActivateValidators_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + uint256[] memory ids = new uint256[](1); + ids[0] = validatorId; + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.batchActivateValidators(ids); + } + + // ========================================================================= + // EXIT REQUEST TESTS + // ========================================================================= + + function test_RequestValidatorExit() public { + (uint256 validatorId,) = _registerAndActivate(1); + + manager.requestValidatorExit(validatorId); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exiting)); + + // Counter should still show as active (exiting validators count as active until fully exited) + assertEq(manager.activeValidatorCount(), 1); + } + + function test_RequestValidatorExit_EmitsEvent() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorExitRequested(validatorId, block.number); + + manager.requestValidatorExit(validatorId); + } + + function test_RequestValidatorExit_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.requestValidatorExit(validatorId); + } + + function test_RequestValidatorExit_NotActive_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + + // Still pending, cannot request exit + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.requestValidatorExit(validatorId); + } + + // ========================================================================= + // MARK EXITED TESTS + // ========================================================================= + + function test_MarkValidatorExited() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + assertEq(manager.activeValidatorCount(), 1); + + manager.markValidatorExited(validatorId); + + (, ValidatorManager.ValidatorStatus status,, uint256 exitedBlock) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exited)); + assertEq(exitedBlock, block.number); + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorExited_EmitsEvent() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + vm.expectEmit(true, false, false, true); + emit ValidatorExited(validatorId, block.number); + + manager.markValidatorExited(validatorId); + } + + function test_MarkValidatorExited_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.markValidatorExited(validatorId); + } + + function test_MarkValidatorExited_NotExiting_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + // Still active, not exiting + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorExited(validatorId); + } + + // ========================================================================= + // SLASHING TESTS (M-1 FIX VERIFICATION) + // ========================================================================= + + function test_MarkValidatorSlashed_FromActive() public { + (uint256 validatorId,) = _registerAndActivate(1); + + assertEq(manager.activeValidatorCount(), 1); + + manager.markValidatorSlashed(validatorId); + + (, ValidatorManager.ValidatorStatus status,, uint256 exitedBlock) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Slashed)); + assertEq(exitedBlock, block.number); + + // M-1 FIX: Counter should decrement when slashing from Active + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorSlashed_FromExiting() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + assertEq(manager.activeValidatorCount(), 1); + + manager.markValidatorSlashed(validatorId); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Slashed)); + + // Counter should decrement - Exiting validators still count as active + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorSlashed_MultipleActiveValidators() public { + // Register and activate 3 validators + (uint256 id1,) = _registerAndActivate(1); + (uint256 id2,) = _registerAndActivate(2); + (uint256 id3,) = _registerAndActivate(3); + + assertEq(manager.activeValidatorCount(), 3); + + // Slash the middle one + manager.markValidatorSlashed(id2); + + // M-1 FIX: Counter should be 2 now + assertEq(manager.activeValidatorCount(), 2); + + // Slash another + manager.markValidatorSlashed(id1); + assertEq(manager.activeValidatorCount(), 1); + + // Slash the last one + manager.markValidatorSlashed(id3); + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorSlashed_EmitsEvent() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorSlashed(validatorId, block.number); + + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_FromPending_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_FromExited_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + manager.markValidatorExited(validatorId); + + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_AlreadySlashed_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.markValidatorSlashed(validatorId); + + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorSlashed(validatorId); + } + + // ========================================================================= + // VIEW FUNCTION TESTS + // ========================================================================= + + function test_GetValidatorIdByPubkey() public { + bytes memory pubkey = _generatePubkey(42); + + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + uint256 lookupId = manager.getValidatorIdByPubkey(pubkey); + assertEq(lookupId, validatorId); + } + + function test_GetValidatorIdByPubkey_NotFound() public view { + bytes memory unknownPubkey = _generatePubkey(999); + uint256 lookupId = manager.getValidatorIdByPubkey(unknownPubkey); + assertEq(lookupId, 0); + } + + function test_GetValidatorStatus() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(depositPool); + manager.registerValidator(pubkey); + + ValidatorManager.ValidatorStatus status = manager.getValidatorStatus(pubkey); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Pending)); + } + + function test_GetValidatorStatus_NotFound() public view { + bytes memory unknownPubkey = _generatePubkey(999); + ValidatorManager.ValidatorStatus status = manager.getValidatorStatus(unknownPubkey); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.None)); + } + + function test_GetStats() public { + // Register 3 validators + _registerValidator(1); + _registerValidator(2); + (uint256 id3,) = _registerValidator(3); + + // Activate 1 + manager.activateValidator(id3); + + (uint256 total, uint256 pending, uint256 active, uint256 totalStaked) = manager.getStats(); + + assertEq(total, 3); + assertEq(pending, 2); + assertEq(active, 1); + assertEq(totalStaked, VALIDATOR_STAKE); + } + + function test_GetValidatorsByStatus() public { + // Register 5 validators + _registerValidator(1); + (uint256 id2,) = _registerValidator(2); + _registerValidator(3); + (uint256 id4,) = _registerValidator(4); + _registerValidator(5); + + // Activate some + manager.activateValidator(id2); + manager.activateValidator(id4); + + // Get pending validators + uint256[] memory pendingIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.Pending); + assertEq(pendingIds.length, 3); + + // Get active validators + uint256[] memory activeIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.Active); + assertEq(activeIds.length, 2); + assertEq(activeIds[0], id2); + assertEq(activeIds[1], id4); + + // Request exit for one + manager.requestValidatorExit(id2); + uint256[] memory exitingIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.Exiting); + assertEq(exitingIds.length, 1); + assertEq(exitingIds[0], id2); + } + + function test_GetValidatorsByStatus_None() public view { + uint256[] memory noneIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.None); + assertEq(noneIds.length, 0); + } + + // ========================================================================= + // ADMIN FUNCTION TESTS + // ========================================================================= + + function test_SetDepositPool() public { + ValidatorManager newManager = new ValidatorManager(); + address newDepositPool = address(0x999); + + newManager.setDepositPool(newDepositPool); + + assertEq(newManager.depositPool(), newDepositPool); + } + + function test_SetDepositPool_EmitsEvent() public { + ValidatorManager newManager = new ValidatorManager(); + address newDepositPool = address(0x999); + + vm.expectEmit(true, false, false, false); + emit DepositPoolSet(newDepositPool); + + newManager.setDepositPool(newDepositPool); + } + + function test_SetDepositPool_NotOwner_Reverts() public { + ValidatorManager newManager = new ValidatorManager(); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + newManager.setDepositPool(address(0x999)); + } + + function test_SetDepositPool_ZeroAddress_Reverts() public { + ValidatorManager newManager = new ValidatorManager(); + + vm.expectRevert(ValidatorManager.ZeroAddress.selector); + newManager.setDepositPool(address(0)); + } + + function test_TransferOwnership() public { + address newOwner = address(0x888); + + manager.transferOwnership(newOwner); + + assertEq(manager.owner(), newOwner); + } + + function test_TransferOwnership_EmitsEvent() public { + address newOwner = address(0x888); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, newOwner); + + manager.transferOwnership(newOwner); + } + + function test_TransferOwnership_NotOwner_Reverts() public { + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.transferOwnership(address(0x888)); + } + + function test_TransferOwnership_ZeroAddress_Reverts() public { + vm.expectRevert(ValidatorManager.ZeroAddress.selector); + manager.transferOwnership(address(0)); + } + + function test_TransferOwnership_NewOwnerCanOperate() public { + address newOwner = address(0x888); + manager.transferOwnership(newOwner); + + (uint256 validatorId,) = _registerValidator(1); + + // New owner can activate + vm.prank(newOwner); + manager.activateValidator(validatorId); + + assertEq(manager.activeValidatorCount(), 1); + } + + // ========================================================================= + // FULL LIFECYCLE TEST + // ========================================================================= + + function test_FullValidatorLifecycle() public { + // 1. Register + bytes memory pubkey = _generatePubkey(1); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Pending)); + + // 2. Activate + manager.activateValidator(validatorId); + (, status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Active)); + + // 3. Request exit + manager.requestValidatorExit(validatorId); + (, status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exiting)); + + // 4. Mark exited + manager.markValidatorExited(validatorId); + (, status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exited)); + } + + function test_FullValidatorLifecycle_WithSlashing() public { + // 1. Register + bytes memory pubkey = _generatePubkey(1); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + // 2. Activate + manager.activateValidator(validatorId); + assertEq(manager.activeValidatorCount(), 1); + + // 3. Slashed while active + manager.markValidatorSlashed(validatorId); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Slashed)); + assertEq(manager.activeValidatorCount(), 0); + } + + // ========================================================================= + // FUZZ TESTS + // ========================================================================= + + function testFuzz_RegisterMultipleValidators(uint8 count) public { + vm.assume(count > 0 && count <= 50); + + for (uint256 i = 1; i <= count; i++) { + bytes memory pubkey = _generatePubkey(i); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + assertEq(validatorId, i); + } + + assertEq(manager.totalValidators(), count); + assertEq(manager.pendingValidatorCount(), count); + } + + function testFuzz_SlashingCounterCorrectness(uint8 activeCount, uint8 slashCount) public { + vm.assume(activeCount > 0 && activeCount <= 20); + vm.assume(slashCount <= activeCount); + + // Register and activate validators + uint256[] memory ids = new uint256[](activeCount); + for (uint256 i = 0; i < activeCount; i++) { + (ids[i],) = _registerAndActivate(i + 1); + } + + assertEq(manager.activeValidatorCount(), activeCount); + + // Slash some validators + for (uint256 i = 0; i < slashCount; i++) { + manager.markValidatorSlashed(ids[i]); + } + + // Verify counter is correct (M-1 fix verification) + assertEq(manager.activeValidatorCount(), activeCount - slashCount); + } +} diff --git a/hyperion/test/stQRL-v2.t.hyp b/hyperion/test/stQRL-v2.t.hyp new file mode 100644 index 0000000..b90dda6 --- /dev/null +++ b/hyperion/test/stQRL-v2.t.hyp @@ -0,0 +1,788 @@ +// SPDX-License-Identifier: GPL-3.0 +// Generated from ../test/stQRL-v2.t.sol by scripts/sync-hyperion.js. +// Edit the Solidity source first, then re-run this script. +pragma hyperion ^0.8.24; + +import "forge-std/Test.hyp"; +import "../contracts/stQRL-v2.hyp"; + +/** + * @title stQRL v2 Tests + * @notice Unit tests for the fixed-balance stQRL token + */ +contract stQRLv2Test is Test { + stQRLv2 public token; + address public owner; + address public depositPool; + address public user1; + address public user2; + + event Transfer(address indexed from, address indexed to, uint256 value); + event SharesMinted(address indexed to, uint256 sharesAmount, uint256 qrlAmount); + event SharesBurned(address indexed from, uint256 sharesAmount, uint256 qrlAmount); + event TotalPooledQRLUpdated(uint256 previousAmount, uint256 newAmount); + + function setUp() public { + owner = address(this); + depositPool = address(0x1); + user1 = address(0x2); + user2 = address(0x3); + + token = new stQRLv2(); + token.setDepositPool(depositPool); + } + + // ========================================================================= + // INITIALIZATION TESTS + // ========================================================================= + + function test_InitialState() public view { + assertEq(token.name(), "Staked QRL"); + assertEq(token.symbol(), "stQRL"); + assertEq(token.decimals(), 18); + assertEq(token.totalSupply(), 0); + assertEq(token.totalShares(), 0); + assertEq(token.totalPooledQRL(), 0); + assertEq(token.owner(), owner); + assertEq(token.depositPool(), depositPool); + } + + function test_InitialExchangeRate() public view { + // Before any deposits, exchange rate should be 1:1 + assertEq(token.getExchangeRate(), 1e18); + } + + // ========================================================================= + // SHARE & VALUE MATH TESTS + // ========================================================================= + + function test_FirstDeposit_OneToOneRatio() public { + uint256 amount = 100 ether; + + // Order matters with virtual shares: mint FIRST, then update pooled + // This matches how DepositPool.deposit() works + vm.startPrank(depositPool); + uint256 shares = token.mintShares(user1, amount); + token.updateTotalPooledQRL(amount); + vm.stopPrank(); + + // First deposit should be 1:1 + assertEq(shares, amount); + assertEq(token.balanceOf(user1), amount); // balanceOf returns shares + assertEq(token.sharesOf(user1), amount); + assertEq(token.totalSupply(), amount); // totalSupply returns total shares + assertEq(token.getQRLValue(user1), amount); // QRL value equals shares at 1:1 + } + + function test_RewardsIncreaseQRLValue() public { + // Initial deposit of 100 QRL + uint256 initialDeposit = 100 ether; + + // Mint first, then update (matches DepositPool behavior) + vm.startPrank(depositPool); + token.mintShares(user1, initialDeposit); + token.updateTotalPooledQRL(initialDeposit); + vm.stopPrank(); + + assertEq(token.balanceOf(user1), 100 ether); // shares + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); // QRL value (tiny precision diff from virtual shares) + + // Simulate 10 QRL rewards (10% increase) + vm.prank(depositPool); + token.updateTotalPooledQRL(110 ether); + + // User's shares remain the same (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // But QRL value increases (use approx due to virtual shares precision) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); + assertEq(token.sharesOf(user1), 100 ether); + } + + function test_SlashingDecreasesQRLValue() public { + // Initial deposit of 100 QRL + uint256 initialDeposit = 100 ether; + + // Mint first, then update (matches DepositPool behavior) + vm.startPrank(depositPool); + token.mintShares(user1, initialDeposit); + token.updateTotalPooledQRL(initialDeposit); + vm.stopPrank(); + + assertEq(token.balanceOf(user1), 100 ether); // shares + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); // QRL value + + // Simulate 5% slashing (pool drops to 95 QRL) + vm.prank(depositPool); + token.updateTotalPooledQRL(95 ether); + + // User's shares remain the same (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // But QRL value decreases (use approx due to virtual shares precision) + assertApproxEqRel(token.getQRLValue(user1), 95 ether, 1e14); + assertEq(token.sharesOf(user1), 100 ether); + } + + function test_MultipleUsers_RewardDistribution() public { + // User1 deposits 100 QRL + // Order: mint shares FIRST (calculates at current rate), THEN update pooled + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // User2 deposits 50 QRL (total now 150 QRL) + // Same order: mint first (at 1:1 rate), then update + vm.startPrank(depositPool); + token.mintShares(user2, 50 ether); + token.updateTotalPooledQRL(150 ether); + vm.stopPrank(); + + // Check shares (fixed-balance: balanceOf returns shares) + assertEq(token.balanceOf(user1), 100 ether); + assertEq(token.balanceOf(user2), 50 ether); + + // Check QRL values before rewards (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); + assertApproxEqRel(token.getQRLValue(user2), 50 ether, 1e14); + + // Add 30 QRL rewards (20% increase, total now 180 QRL) + vm.prank(depositPool); + token.updateTotalPooledQRL(180 ether); + + // Shares remain the same (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + assertEq(token.balanceOf(user2), 50 ether); + + // QRL values should be distributed proportionally (approx due to virtual shares) + // User1 has 100/150 = 66.67% of shares -> gets 66.67% of 180 = 120 QRL + // User2 has 50/150 = 33.33% of shares -> gets 33.33% of 180 = 60 QRL + assertApproxEqRel(token.getQRLValue(user1), 120 ether, 1e14); + assertApproxEqRel(token.getQRLValue(user2), 60 ether, 1e14); + } + + function test_ShareConversion_AfterRewards() public { + // Deposit 100 QRL - mint first, then update + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // Add 50 QRL rewards (now 150 QRL, still 100 shares) + vm.prank(depositPool); + token.updateTotalPooledQRL(150 ether); + + // New deposit should get fewer shares + // With virtual shares: 100 * (100e18 + 1000) / (150e18 + 1000) ≈ 66.67 shares + uint256 expectedShares = token.getSharesByPooledQRL(100 ether); + // At rate of 1.5 QRL/share, 100 QRL ≈ 66.67 shares + assertApproxEqRel(expectedShares, 66.67 ether, 1e16); // 1% tolerance + + // And those shares should be worth 100 QRL + assertApproxEqRel( + token.getPooledQRLByShares(expectedShares), + 100 ether, + 1e15 // 0.1% tolerance for rounding + ); + } + + // ========================================================================= + // EDGE CASE TESTS + // ========================================================================= + + function test_ZeroShares_ReturnsZeroBalance() public view { + assertEq(token.balanceOf(user1), 0); + assertEq(token.getPooledQRLByShares(0), 0); + } + + function test_ZeroPooled_ZeroTotalShares() public view { + // Before any deposits, with virtual shares the math is: + // getSharesByPooledQRL(100e18) = 100e18 * (0 + 1000) / (0 + 1000) = 100e18 + assertEq(token.getSharesByPooledQRL(100 ether), 100 ether); + // getPooledQRLByShares(100e18) = 100e18 * (0 + 1000) / (0 + 1000) = 100e18 + // Virtual shares ensure 1:1 ratio even with empty pool + assertEq(token.getPooledQRLByShares(100 ether), 100 ether); + } + + function test_LargeNumbers() public { + uint256 largeAmount = 1_000_000_000 ether; // 1 billion QRL + + // Mint first, then update (matches DepositPool behavior) + vm.startPrank(depositPool); + token.mintShares(user1, largeAmount); + token.updateTotalPooledQRL(largeAmount); + vm.stopPrank(); + + assertEq(token.balanceOf(user1), largeAmount); // shares + assertApproxEqRel(token.getQRLValue(user1), largeAmount, 1e14); // QRL value (approx due to virtual shares) + + // Add 10% rewards + uint256 newTotal = largeAmount + (largeAmount / 10); + vm.prank(depositPool); + token.updateTotalPooledQRL(newTotal); + + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), largeAmount); + // QRL value reflects rewards (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), newTotal, 1e14); + } + + function test_SmallNumbers() public { + uint256 smallAmount = 1; // 1 wei + + // Mint first, then update (matches DepositPool behavior) + vm.startPrank(depositPool); + token.mintShares(user1, smallAmount); + token.updateTotalPooledQRL(smallAmount); + vm.stopPrank(); + + assertEq(token.balanceOf(user1), smallAmount); + assertEq(token.sharesOf(user1), smallAmount); + } + + function testFuzz_ExchangeRateMath(uint256 deposit, uint256 rewardPercent) public { + // Bound inputs to reasonable ranges + deposit = bound(deposit, 1 ether, 1_000_000_000 ether); + rewardPercent = bound(rewardPercent, 0, 100); // 0-100% rewards + + // Mint first, then update (matches DepositPool behavior) + vm.startPrank(depositPool); + token.mintShares(user1, deposit); + token.updateTotalPooledQRL(deposit); + vm.stopPrank(); + + uint256 rewards = (deposit * rewardPercent) / 100; + uint256 newTotal = deposit + rewards; + + vm.prank(depositPool); + token.updateTotalPooledQRL(newTotal); + + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), deposit); + // QRL value should equal new total (user owns all shares) + // Use approx due to tiny precision difference from virtual shares + assertApproxEqRel(token.getQRLValue(user1), newTotal, 1e14); + } + + // ========================================================================= + // ERC-20 TRANSFER TESTS + // ========================================================================= + + function test_Transfer() public { + // Setup: user1 has 100 shares - mint first, then update + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // Transfer 30 shares to user2 + vm.prank(user1); + token.transfer(user2, 30 ether); + + assertEq(token.balanceOf(user1), 70 ether); + assertEq(token.balanceOf(user2), 30 ether); + } + + function test_TransferAfterRewards() public { + // Setup: user1 has 100 shares - mint first, then update + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // Add 50% rewards (user1's shares now worth 150 QRL) + vm.prank(depositPool); + token.updateTotalPooledQRL(150 ether); + + assertEq(token.balanceOf(user1), 100 ether); // still 100 shares + assertApproxEqRel(token.getQRLValue(user1), 150 ether, 1e14); // worth 150 QRL (approx) + + // Transfer 50 shares (half) to user2 + vm.prank(user1); + token.transfer(user2, 50 ether); + + // Each user has 50 shares + assertEq(token.balanceOf(user1), 50 ether); + assertEq(token.balanceOf(user2), 50 ether); + // Each user's shares worth 75 QRL (half of 150 total) (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 75 ether, 1e14); + assertApproxEqRel(token.getQRLValue(user2), 75 ether, 1e14); + } + + function test_TransferFrom() public { + // Setup: user1 has 100 shares - mint first, then update + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // user1 approves user2 + vm.prank(user1); + token.approve(user2, 50 ether); + + // user2 transfers from user1 + vm.prank(user2); + token.transferFrom(user1, user2, 50 ether); + + assertEq(token.balanceOf(user1), 50 ether); + assertEq(token.balanceOf(user2), 50 ether); + } + + // ========================================================================= + // ACCESS CONTROL TESTS + // ========================================================================= + + function test_OnlyDepositPoolCanMint() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotDepositPool.selector); + token.mintShares(user1, 100 ether); + } + + function test_OnlyDepositPoolCanBurn() public { + // First mint some shares + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.NotDepositPool.selector); + token.burnShares(user1, 50 ether); + } + + function test_OnlyDepositPoolCanUpdatePooledQRL() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotDepositPool.selector); + token.updateTotalPooledQRL(100 ether); + } + + function test_OnlyOwnerCanSetDepositPool() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.setDepositPool(address(0x123)); + } + + function test_DepositPoolCanOnlyBeSetOnce() public { + // Already set in setUp, should revert + vm.expectRevert(stQRLv2.DepositPoolAlreadySet.selector); + token.setDepositPool(address(0x123)); + } + + // ========================================================================= + // PAUSE TESTS + // ========================================================================= + + function test_PauseBlocksTransfers() public { + // Setup + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + // Pause + token.pause(); + + // Transfer should fail + vm.prank(user1); + vm.expectRevert(stQRLv2.ContractPaused.selector); + token.transfer(user2, 50 ether); + } + + function test_UnpauseAllowsTransfers() public { + // Setup - mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // Pause then unpause + token.pause(); + token.unpause(); + + // Transfer should work + vm.prank(user1); + token.transfer(user2, 50 ether); + assertEq(token.balanceOf(user2), 50 ether); + } + + // ========================================================================= + // APPROVE TESTS + // ========================================================================= + + function test_Approve() public { + // Setup + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + // Approve + vm.prank(user1); + bool success = token.approve(user2, 50 ether); + + assertTrue(success); + assertEq(token.allowance(user1, user2), 50 ether); + } + + function test_Approve_ZeroAddress_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.approve(address(0), 50 ether); + } + + function test_Approve_EmitsEvent() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Approval(user1, user2, 50 ether); + token.approve(user2, 50 ether); + } + + // ========================================================================= + // TRANSFER ERROR TESTS + // ========================================================================= + + function test_Transfer_ToZeroAddress_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.transfer(address(0), 50 ether); + } + + function test_Transfer_ZeroAmount_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.transfer(user2, 0); + } + + function test_Transfer_InsufficientBalance_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.InsufficientBalance.selector); + token.transfer(user2, 150 ether); + } + + function test_Transfer_EmitsEvent() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, user2, 50 ether); + token.transfer(user2, 50 ether); + } + + // ========================================================================= + // TRANSFERFROM ERROR TESTS + // ========================================================================= + + function test_TransferFrom_ZeroAmount_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + token.approve(user2, 50 ether); + + vm.prank(user2); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.transferFrom(user1, user2, 0); + } + + function test_TransferFrom_InsufficientAllowance_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + token.approve(user2, 30 ether); + + vm.prank(user2); + vm.expectRevert(stQRLv2.InsufficientAllowance.selector); + token.transferFrom(user1, user2, 50 ether); + } + + function test_TransferFrom_UnlimitedAllowance() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // Approve unlimited + vm.prank(user1); + token.approve(user2, type(uint256).max); + + // Transfer + vm.prank(user2); + token.transferFrom(user1, user2, 50 ether); + + // Allowance should remain unlimited + assertEq(token.allowance(user1, user2), type(uint256).max); + } + + function test_TransferFrom_WhenPaused_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + token.approve(user2, 50 ether); + + token.pause(); + + vm.prank(user2); + vm.expectRevert(stQRLv2.ContractPaused.selector); + token.transferFrom(user1, user2, 50 ether); + } + + // ========================================================================= + // MINT/BURN ERROR TESTS + // ========================================================================= + + function test_MintShares_ToZeroAddress_Reverts() public { + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.mintShares(address(0), 100 ether); + } + + function test_MintShares_ZeroAmount_Reverts() public { + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.mintShares(user1, 0); + } + + function test_MintShares_WhenPaused_Reverts() public { + token.pause(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ContractPaused.selector); + token.mintShares(user1, 100 ether); + } + + function test_MintShares_EmitsEvents() public { + // Mint first (correct order) - pool is empty so 1:1 ratio + vm.prank(depositPool); + vm.expectEmit(true, false, false, true); + emit SharesMinted(user1, 100 ether, 100 ether); + vm.expectEmit(true, true, false, true); + emit Transfer(address(0), user1, 100 ether); + token.mintShares(user1, 100 ether); + } + + function test_BurnShares_FromZeroAddress_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.burnShares(address(0), 50 ether); + } + + function test_BurnShares_ZeroAmount_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.burnShares(user1, 0); + } + + function test_BurnShares_InsufficientBalance_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.InsufficientBalance.selector); + token.burnShares(user1, 150 ether); + } + + function test_BurnShares_WhenPaused_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + token.pause(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ContractPaused.selector); + token.burnShares(user1, 50 ether); + } + + function test_BurnShares_EmitsEvents() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // At 1:1 rate, 50 shares = 50 QRL (with tiny virtual shares diff) + uint256 expectedQRL = token.getPooledQRLByShares(50 ether); + + vm.prank(depositPool); + vm.expectEmit(true, false, false, true); + emit SharesBurned(user1, 50 ether, expectedQRL); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, address(0), 50 ether); + token.burnShares(user1, 50 ether); + } + + function test_BurnShares_ReturnsCorrectQRLAmount() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + // Add 50% rewards + token.updateTotalPooledQRL(150 ether); + vm.stopPrank(); + + vm.prank(depositPool); + uint256 qrlAmount = token.burnShares(user1, 50 ether); + + // 50 shares at ~1.5 QRL/share ≈ 75 QRL (approx due to virtual shares) + assertApproxEqRel(qrlAmount, 75 ether, 1e14); + } + + // ========================================================================= + // ADMIN FUNCTION TESTS + // ========================================================================= + + function test_SetDepositPool_ZeroAddress_Reverts() public { + // Deploy fresh token without depositPool set + stQRLv2 freshToken = new stQRLv2(); + + vm.expectRevert(stQRLv2.ZeroAddress.selector); + freshToken.setDepositPool(address(0)); + } + + function test_TransferOwnership() public { + address newOwner = address(0x999); + + token.transferOwnership(newOwner); + + assertEq(token.owner(), newOwner); + } + + function test_TransferOwnership_ZeroAddress_Reverts() public { + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.transferOwnership(address(0)); + } + + function test_TransferOwnership_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.transferOwnership(user1); + } + + function test_TransferOwnership_EmitsEvent() public { + address newOwner = address(0x999); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, newOwner); + token.transferOwnership(newOwner); + } + + function test_RenounceOwnership() public { + token.renounceOwnership(); + + assertEq(token.owner(), address(0)); + } + + function test_RenounceOwnership_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.renounceOwnership(); + } + + function test_RenounceOwnership_EmitsEvent() public { + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, address(0)); + token.renounceOwnership(); + } + + function test_OnlyOwnerCanPause() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.pause(); + } + + function test_OnlyOwnerCanUnpause() public { + token.pause(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.unpause(); + } + + // ========================================================================= + // GETQRLVALUE TESTS + // ========================================================================= + + function test_GetQRLValue_ReturnsCorrectValue() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); + + // Add rewards + vm.prank(depositPool); + token.updateTotalPooledQRL(150 ether); + + assertApproxEqRel(token.getQRLValue(user1), 150 ether, 1e14); + } + + function test_GetQRLValue_ZeroShares() public view { + assertEq(token.getQRLValue(user1), 0); + } + + // ========================================================================= + // EVENT DECLARATIONS + // ========================================================================= + + event Approval(address indexed owner, address indexed spender, uint256 value); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); +} diff --git a/package.json b/package.json index 20a378d..843f1d9 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,12 @@ "main": "index.js", "scripts": { "compile": "node scripts/compile.js", + "compile:solidity": "node scripts/compile.js", + "sync:hyperion": "node scripts/sync-hyperion.js", + "compile:hyperion": "node scripts/compile-hyperion.js", "deploy:test": "node scripts/deploy-test-token.js", "deploy": "node scripts/deploy.js", + "deploy:hyperion": "node scripts/deploy-hyperion.js", "test": "node scripts/test.js" }, "keywords": [ diff --git a/scripts/compile-hyperion.js b/scripts/compile-hyperion.js new file mode 100644 index 0000000..33b8fed --- /dev/null +++ b/scripts/compile-hyperion.js @@ -0,0 +1,112 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const { syncHyperionSources } = require('./sync-hyperion'); + +const repoRoot = path.join(__dirname, '..'); +const hyperionContractsDir = path.join(repoRoot, 'hyperion', 'contracts'); +const hyperionArtifactsDir = path.join(repoRoot, 'hyperion', 'artifacts'); +const compilerBinary = process.env.HYPERION_COMPILER || process.env.HYPC_BIN || 'hypc'; + +function ensureCompilerAvailable() { + const result = spawnSync(compilerBinary, ['--version'], { encoding: 'utf8' }); + + if (result.error && result.error.code === 'ENOENT') { + throw new Error( + `Hyperion compiler not found: ${compilerBinary}. ` + + 'Install hypc and/or set HYPERION_COMPILER=/path/to/hypc.' + ); + } + + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || 'Unable to execute hypc.').trim()); + } +} + +function discoverPrimaryContractName(source) { + const matches = [ + ...source.matchAll(/^\s*(?:abstract\s+)?contract\s+([A-Za-z_][A-Za-z0-9_]*)\b/gm) + ]; + + if (matches.length === 0) { + throw new Error('No deployable contract definition found in Hyperion source.'); + } + + return matches[matches.length - 1][1]; +} + +function clearArtifactsDir() { + fs.mkdirSync(hyperionArtifactsDir, { recursive: true }); + + for (const file of fs.readdirSync(hyperionArtifactsDir)) { + fs.rmSync(path.join(hyperionArtifactsDir, file), { force: true, recursive: true }); + } +} + +function compileSources(selectedSources = []) { + syncHyperionSources(); + ensureCompilerAvailable(); + clearArtifactsDir(); + + const availableSources = fs.readdirSync(hyperionContractsDir) + .filter(file => file.endsWith('.hyp')) + .sort(); + + const sourceFiles = selectedSources.length > 0 + ? selectedSources.map(file => (file.endsWith('.hyp') ? file : `${file}.hyp`)) + : availableSources; + + const manifest = { + compiler: compilerBinary, + generatedAt: new Date().toISOString(), + contracts: [] + }; + + for (const sourceFile of sourceFiles) { + if (!availableSources.includes(sourceFile)) { + throw new Error(`Hyperion source not found: ${sourceFile}`); + } + + const sourcePath = path.join(hyperionContractsDir, sourceFile); + const source = fs.readFileSync(sourcePath, 'utf8'); + const contractName = discoverPrimaryContractName(source); + + console.log(`Compiling ${sourceFile} with ${compilerBinary}...`); + execFileSync( + compilerBinary, + [ + '--abi', + '--bin', + `--base-path=${hyperionContractsDir}`, + `--allow-paths=${repoRoot},${hyperionContractsDir}`, + `--output-dir=${hyperionArtifactsDir}`, + '--overwrite', + sourcePath + ], + { stdio: 'inherit' } + ); + + manifest.contracts.push({ + sourceFile, + contractName, + abiFile: `${contractName}.abi`, + binFile: `${contractName}.bin` + }); + } + + const manifestPath = path.join(hyperionArtifactsDir, 'manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + console.log(`Wrote ${manifestPath}`); +} + +if (require.main === module) { + try { + compileSources(process.argv.slice(2)); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} + +module.exports = { compileSources }; diff --git a/scripts/compile.js b/scripts/compile.js index e5b722b..f7e3445 100644 --- a/scripts/compile.js +++ b/scripts/compile.js @@ -2,7 +2,7 @@ const solc = require('solc'); const fs = require('fs'); const path = require('path'); -const contractsDir = path.join(__dirname, '..', 'contracts'); +const contractsDir = path.join(__dirname, '..', 'contracts', 'solidity'); const artifactsDir = path.join(__dirname, '..', 'artifacts'); // Ensure artifacts directory exists @@ -87,7 +87,7 @@ if (args.length > 0) { const files = fs.readdirSync(contractsDir).filter(f => f.endsWith('.sol')); if (files.length === 0) { - console.log('No .sol files found in contracts/'); + console.log('No .sol files found in contracts/solidity/'); } else { files.forEach(file => { const contractName = file.replace('.sol', ''); diff --git a/scripts/deploy-hyperion.js b/scripts/deploy-hyperion.js new file mode 100644 index 0000000..e7bcfcb --- /dev/null +++ b/scripts/deploy-hyperion.js @@ -0,0 +1,165 @@ +const fs = require('fs'); +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const { Web3 } = require('@theqrl/web3'); +const { MnemonicToSeedBin } = require('@theqrl/wallet.js'); + +const repoRoot = path.join(__dirname, '..'); +const configPath = process.env.HYPERION_CONFIG || path.join(repoRoot, 'config', 'testnet-hyperion.json'); +const manifestPath = path.join(repoRoot, 'hyperion', 'artifacts', 'manifest.json'); + +function loadJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function loadDeployConfig() { + if (!fs.existsSync(configPath)) { + throw new Error(`Config not found: ${configPath}`); + } + + return loadJson(configPath); +} + +function loadManifest() { + if (!fs.existsSync(manifestPath)) { + throw new Error( + `Manifest not found: ${manifestPath}. Run "npm run compile:hyperion" first.` + ); + } + + return loadJson(manifestPath); +} + +function loadArtifact(contractName) { + const manifest = loadManifest(); + const entry = manifest.contracts.find(item => item.contractName === contractName); + + if (!entry) { + throw new Error(`Contract ${contractName} not found in ${manifestPath}`); + } + + const artifactsDir = path.dirname(manifestPath); + const abiPath = path.join(artifactsDir, entry.abiFile); + const binPath = path.join(artifactsDir, entry.binFile); + + if (!fs.existsSync(abiPath) || !fs.existsSync(binPath)) { + throw new Error(`Missing Hyperion artifact files for ${contractName}`); + } + + return { + abi: loadJson(abiPath), + bytecode: `0x${fs.readFileSync(binPath, 'utf8').trim()}` + }; +} + +function getAccount(web3) { + if (!process.env.TESTNET_SEED) { + throw new Error('TESTNET_SEED environment variable is required'); + } + + const seedBin = MnemonicToSeedBin(process.env.TESTNET_SEED); + const seedHex = `0x${Buffer.from(seedBin).toString('hex')}`; + const account = web3.zond.accounts.seedToAccount(seedHex); + web3.zond.accounts.wallet.add(account); + return account; +} + +async function deployContract(web3, account, contractName, constructorArgs = []) { + const artifact = loadArtifact(contractName); + console.log(`\nDeploying ${contractName}...`); + + const contract = new web3.zond.Contract(artifact.abi); + const deployTx = contract.deploy({ + data: artifact.bytecode, + arguments: constructorArgs + }); + + const gas = await deployTx.estimateGas({ from: account.address }); + console.log(` Gas estimate: ${gas}`); + + const deployed = await deployTx.send({ + from: account.address, + gas: Math.floor(Number(gas) * 1.2) + }); + + console.log(` Address: ${deployed.options.address}`); + return deployed; +} + +async function sendConfiguredTx(method, account, label) { + const gas = await method.estimateGas({ from: account.address }); + const tx = await method.send({ + from: account.address, + gas: Math.floor(Number(gas) * 1.2) + }); + + console.log(`${label}: ${tx.transactionHash || 'submitted'}`); +} + +async function main() { + const config = loadDeployConfig(); + + console.log('='.repeat(60)); + console.log('QuantaPool Hyperion v2 Deployment'); + console.log('='.repeat(60)); + console.log(`Config: ${configPath}`); + console.log(`Provider: ${config.provider}`); + + const web3 = new Web3(config.provider); + const chainId = await web3.zond.getChainId(); + console.log(`Connected to chain ID: ${chainId}`); + + const account = getAccount(web3); + console.log(`Deployer: ${account.address}`); + + const balance = await web3.zond.getBalance(account.address); + console.log(`Balance: ${web3.utils.fromWei(balance, 'ether')} QRL`); + + const stQRL = await deployContract(web3, account, 'stQRLv2'); + const depositPool = await deployContract(web3, account, 'DepositPoolV2'); + const validatorManager = await deployContract(web3, account, 'ValidatorManager'); + + console.log('\nConfiguring contract links...'); + + await sendConfiguredTx( + new web3.zond.Contract(loadArtifact('DepositPoolV2').abi, depositPool.options.address) + .methods.setStQRL(stQRL.options.address), + account, + ' DepositPoolV2.setStQRL' + ); + + await sendConfiguredTx( + new web3.zond.Contract(loadArtifact('stQRLv2').abi, stQRL.options.address) + .methods.setDepositPool(depositPool.options.address), + account, + ' stQRLv2.setDepositPool' + ); + + await sendConfiguredTx( + new web3.zond.Contract(loadArtifact('ValidatorManager').abi, validatorManager.options.address) + .methods.setDepositPool(depositPool.options.address), + account, + ' ValidatorManager.setDepositPool' + ); + + config.contracts = { + stQRLV2: stQRL.options.address, + depositPoolV2: depositPool.options.address, + validatorManager: validatorManager.options.address + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + console.log('\nDeployment complete.'); + console.log(`stQRLV2: ${stQRL.options.address}`); + console.log(`DepositPoolV2: ${depositPool.options.address}`); + console.log(`ValidatorManager: ${validatorManager.options.address}`); + console.log(`Updated config: ${configPath}`); +} + +main().catch(error => { + console.error(error.message); + process.exit(1); +}); diff --git a/scripts/sync-hyperion.js b/scripts/sync-hyperion.js new file mode 100644 index 0000000..9b12c28 --- /dev/null +++ b/scripts/sync-hyperion.js @@ -0,0 +1,110 @@ +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..'); +const solidityContractsDir = path.join(repoRoot, 'contracts', 'solidity'); +const solidityTestsDir = path.join(repoRoot, 'test'); +const hyperionContractsDir = path.join(repoRoot, 'hyperion', 'contracts'); +const hyperionTestsDir = path.join(repoRoot, 'hyperion', 'test'); +const mirroredTestFiles = [ + 'DepositPool-v2.t.sol', + 'ValidatorManager.t.sol', + 'stQRL-v2.t.sol' +]; + +function toHyperionSource(source, sourceFile, sourceDirLabel) { + const pragmaUpdated = source.replace(/^pragma solidity\b/m, 'pragma hyperion'); + + if (pragmaUpdated === source) { + throw new Error(`Could not find Solidity pragma in ${sourceDirLabel}/${sourceFile}`); + } + + const importsUpdated = pragmaUpdated + .replace(/\.\.\/contracts\/solidity\//g, '../contracts/') + .replace(/(import\s+[^'"]*["'][^'"]+)\.sol(["'];)/g, '$1.hyp$2'); + + const banner = + `// Generated from ../${sourceDirLabel}/${sourceFile} by scripts/sync-hyperion.js.\n` + + '// Edit the Solidity source first, then re-run this script.\n'; + + if (importsUpdated.startsWith('// SPDX-License-Identifier:')) { + const firstNewline = importsUpdated.indexOf('\n'); + return `${importsUpdated.slice(0, firstNewline + 1)}${banner}${importsUpdated.slice(firstNewline + 1)}`; + } + + return `${banner}${importsUpdated}`; +} + +function clearGeneratedDir(dir) { + fs.mkdirSync(dir, { recursive: true }); + + for (const file of fs.readdirSync(dir)) { + if (file.endsWith('.hyp')) { + fs.rmSync(path.join(dir, file), { force: true }); + } + } +} + +function syncDirectory(sourceDir, targetDir, sourceFiles, sourceDirLabel) { + const syncedFiles = []; + + for (const sourceFile of sourceFiles) { + const sourcePath = path.join(sourceDir, sourceFile); + + if (!fs.existsSync(sourcePath)) { + throw new Error(`Source file not found: ${sourcePath}`); + } + + const source = fs.readFileSync(sourcePath, 'utf8'); + const targetFile = sourceFile.replace(/\.sol$/, '.hyp'); + const targetPath = path.join(targetDir, targetFile); + const converted = toHyperionSource(source, sourceFile, sourceDirLabel); + + fs.writeFileSync(targetPath, converted); + syncedFiles.push(targetFile); + console.log(`Synced ${path.relative(repoRoot, targetPath)}`); + } + + return syncedFiles; +} + +function syncHyperionSources() { + clearGeneratedDir(hyperionContractsDir); + clearGeneratedDir(hyperionTestsDir); + + const contractFiles = fs.readdirSync(solidityContractsDir) + .filter(file => file.endsWith('.sol')) + .sort(); + + if (contractFiles.length === 0) { + throw new Error('No Solidity contracts found in contracts/solidity/.'); + } + + const testFiles = mirroredTestFiles.slice().sort(); + + return { + contracts: syncDirectory( + solidityContractsDir, + hyperionContractsDir, + contractFiles, + 'contracts/solidity' + ), + tests: syncDirectory( + solidityTestsDir, + hyperionTestsDir, + testFiles, + 'test' + ) + }; +} + +if (require.main === module) { + try { + syncHyperionSources(); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} + +module.exports = { syncHyperionSources }; diff --git a/test/DepositPool-v2.t.sol b/test/DepositPool-v2.t.sol index b4cc4fe..e58c629 100644 --- a/test/DepositPool-v2.t.sol +++ b/test/DepositPool-v2.t.sol @@ -2,8 +2,8 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; -import "../contracts/stQRL-v2.sol"; -import "../contracts/DepositPool-v2.sol"; +import "../contracts/solidity/stQRL-v2.sol"; +import "../contracts/solidity/DepositPool-v2.sol"; /** * @title DepositPool v2 Integration Tests @@ -65,12 +65,12 @@ contract DepositPoolV2Test is Test { pool.deposit{value: 100 ether}(); vm.prank(user2); - pool.deposit{value: 50 ether}(); + pool.deposit{value: 200 ether}(); assertEq(token.balanceOf(user1), 100 ether); - assertEq(token.balanceOf(user2), 50 ether); - assertEq(pool.bufferedQRL(), 150 ether); - assertEq(token.totalSupply(), 150 ether); + assertEq(token.balanceOf(user2), 200 ether); + assertEq(pool.bufferedQRL(), 300 ether); + assertEq(token.totalSupply(), 300 ether); } function test_DepositAfterRewards() public { @@ -82,18 +82,20 @@ contract DepositPoolV2Test is Test { vm.deal(address(pool), 150 ether); // 50 QRL rewards pool.syncRewards(); - // User1's balance should have increased - assertEq(token.balanceOf(user1), 150 ether); + // User1's shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // But QRL value increased (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 150 ether, 1e14); - // User2 deposits 150 QRL (should get 100 shares at new rate) + // User2 deposits 150 QRL (should get ~100 shares at new rate) vm.prank(user2); uint256 shares = pool.deposit{value: 150 ether}(); // User2 gets shares based on current rate // Rate: 150 QRL / 100 shares = 1.5 QRL per share - // For 150 QRL: 150 / 1.5 = 100 shares - assertEq(shares, 100 ether); - assertEq(token.sharesOf(user2), 100 ether); + // For 150 QRL: 150 / 1.5 ≈ 100 shares (approx due to virtual shares) + assertApproxEqRel(shares, 100 ether, 1e14); + assertApproxEqRel(token.sharesOf(user2), 100 ether, 1e14); } // ========================================================================= @@ -119,7 +121,10 @@ contract DepositPoolV2Test is Test { assertEq(token.totalPooledQRL(), 110 ether); assertEq(pool.totalRewardsReceived(), 10 ether); - assertEq(token.balanceOf(user1), 110 ether); + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // QRL value reflects rewards (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); } function test_SyncRewards_DetectsSlashing() public { @@ -157,16 +162,19 @@ contract DepositPoolV2Test is Test { // Request withdrawal vm.prank(user1); - uint256 qrlAmount = pool.requestWithdrawal(50 ether); + (uint256 requestId, uint256 qrlAmount) = pool.requestWithdrawal(50 ether); + assertEq(requestId, 0); assertEq(qrlAmount, 50 ether); - (uint256 shares, uint256 qrl, uint256 requestBlock, bool canClaim,) = pool.getWithdrawalRequest(user1); + (uint256 shares, uint256 qrl, uint256 requestBlock, bool canClaim,, bool claimed) = + pool.getWithdrawalRequest(user1, 0); assertEq(shares, 50 ether); assertEq(qrl, 50 ether); assertEq(requestBlock, block.number); assertFalse(canClaim); // Not enough time passed + assertFalse(claimed); } function test_ClaimWithdrawal() public { @@ -174,13 +182,13 @@ contract DepositPoolV2Test is Test { vm.prank(user1); pool.deposit{value: 100 ether}(); - // Add to withdrawal reserve (simulating validator exit) - pool.fundWithdrawalReserve{value: 100 ether}(); - - // Request withdrawal + // Request withdrawal FIRST (captures QRL value at current 1:1 rate) vm.prank(user1); pool.requestWithdrawal(50 ether); + // Fund withdrawal reserve AFTER request (reclassify pooled QRL for the claim) + pool.fundWithdrawalReserve(50 ether); + // Wait for withdrawal delay vm.roll(block.number + 129); // > 128 blocks @@ -198,11 +206,11 @@ contract DepositPoolV2Test is Test { vm.prank(user1); pool.deposit{value: 100 ether}(); - pool.fundWithdrawalReserve{value: 100 ether}(); - vm.prank(user1); pool.requestWithdrawal(50 ether); + pool.fundWithdrawalReserve(50 ether); + // Try to claim immediately (should fail) vm.prank(user1); vm.expectRevert(DepositPoolV2.WithdrawalNotReady.selector); @@ -230,12 +238,12 @@ contract DepositPoolV2Test is Test { pool.deposit{value: 100 ether}(); vm.prank(user1); - pool.requestWithdrawal(50 ether); + (uint256 requestId,) = pool.requestWithdrawal(50 ether); assertEq(pool.totalWithdrawalShares(), 50 ether); vm.prank(user1); - pool.cancelWithdrawal(); + pool.cancelWithdrawal(requestId); assertEq(pool.totalWithdrawalShares(), 0); } @@ -249,26 +257,31 @@ contract DepositPoolV2Test is Test { vm.deal(address(pool), 110 ether); pool.syncRewards(); - // User now has 110 QRL worth - assertEq(token.balanceOf(user1), 110 ether); - - // Fund withdrawal reserve - pool.fundWithdrawalReserve{value: 110 ether}(); + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // User's shares now worth 110 QRL (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); - // Request withdrawal of all shares (100 shares = 110 QRL now) + // Request withdrawal of all shares BEFORE funding reserve + // (so shares are valued at current rate: ~110 QRL for 100 shares) vm.prank(user1); - uint256 qrlAmount = pool.requestWithdrawal(100 ether); + (, uint256 qrlAmount) = pool.requestWithdrawal(100 ether); + + // Approx due to virtual shares + assertApproxEqRel(qrlAmount, 110 ether, 1e14); - assertEq(qrlAmount, 110 ether); + // Fund withdrawal reserve (reclassify from totalPooledQRL to cover the claim) + pool.fundWithdrawalReserve(token.totalPooledQRL()); vm.roll(block.number + 129); uint256 balanceBefore = user1.balance; vm.prank(user1); - pool.claimWithdrawal(); + uint256 claimed = pool.claimWithdrawal(); - // Should receive 110 QRL (original + rewards) - assertEq(user1.balance - balanceBefore, 110 ether); + // Should receive ~110 QRL (original + rewards) + assertApproxEqRel(user1.balance - balanceBefore, 110 ether, 1e14); + assertEq(user1.balance - balanceBefore, claimed); } // ========================================================================= @@ -280,18 +293,40 @@ contract DepositPoolV2Test is Test { vm.prank(user1); pool.deposit{value: 100 ether}(); - // Request withdrawal before slashing + // User's shares are worth 100 QRL initially (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); + + // Simulate slashing by directly reducing the contract balance + // In real scenarios, this happens through validator slashing on the beacon chain + vm.deal(address(pool), 90 ether); // Was 100, now 90 + + // Sync to detect the "slashing" + pool.syncRewards(); + + // User's shares now worth less (90 QRL instead of 100) (approx) + assertApproxEqRel(token.getQRLValue(user1), 90 ether, 1e14); + + // Request withdrawal of all shares FIRST (captures slashed QRL value ~90) + vm.prank(user1); + (, uint256 qrlAmount) = pool.requestWithdrawal(100 ether); + + // Should only get ~90 QRL (slashed amount) (approx due to virtual shares) + assertApproxEqRel(qrlAmount, 90 ether, 1e14); + + // Fund withdrawal reserve AFTER request (reclassify pooled QRL for the claim) + pool.fundWithdrawalReserve(token.totalPooledQRL()); + } + + function test_SlashingDetected_EmitsEvent() public { vm.prank(user1); - pool.requestWithdrawal(100 ether); + pool.deposit{value: 100 ether}(); - // Simulate 10% slashing by reducing pool balance - // In reality this would happen through validator balance decrease - // We simulate by manually updating totalPooledQRL - // For this test, we need a different approach since we can't easily - // reduce the contract's ETH balance + // Simulate slashing by directly reducing the contract balance + vm.deal(address(pool), 90 ether); // Was 100, now 90 - // Let's test the rebasing math instead - // After slashing, the user's share value should decrease + vm.expectEmit(true, true, true, true); + emit SlashingDetected(10 ether, 90 ether, block.number); + pool.syncRewards(); } // ========================================================================= @@ -300,31 +335,31 @@ contract DepositPoolV2Test is Test { function test_CanFundValidator() public { // Fund users with enough ETH for this test - vm.deal(user1, 5000 ether); - vm.deal(user2, 5000 ether); + vm.deal(user1, 20000 ether); + vm.deal(user2, 20000 ether); // Deposit less than threshold vm.prank(user1); - pool.deposit{value: 5000 ether}(); + pool.deposit{value: 20000 ether}(); (bool possible, uint256 buffered) = pool.canFundValidator(); assertFalse(possible); - assertEq(buffered, 5000 ether); + assertEq(buffered, 20000 ether); // Deposit more to reach threshold vm.prank(user2); - pool.deposit{value: 5000 ether}(); + pool.deposit{value: 20000 ether}(); (possible, buffered) = pool.canFundValidator(); assertTrue(possible); - assertEq(buffered, 10000 ether); + assertEq(buffered, 40000 ether); } function test_FundValidatorMVP() public { - // Deposit enough for validator - vm.deal(user1, 10000 ether); + // Deposit enough for validator (40,000 QRL per Zond mainnet config) + vm.deal(user1, 40000 ether); vm.prank(user1); - pool.deposit{value: 10000 ether}(); + pool.deposit{value: 40000 ether}(); uint256 validatorId = pool.fundValidatorMVP(); @@ -381,9 +416,9 @@ contract DepositPoolV2Test is Test { // ========================================================================= function test_OnlyOwnerCanFundValidator() public { - vm.deal(user1, 10000 ether); + vm.deal(user1, 40000 ether); vm.prank(user1); - pool.deposit{value: 10000 ether}(); + pool.deposit{value: 40000 ether}(); vm.prank(user1); vm.expectRevert(DepositPoolV2.NotOwner.selector); @@ -409,7 +444,7 @@ contract DepositPoolV2Test is Test { // ========================================================================= function testFuzz_DepositAndWithdraw(uint256 amount) public { - amount = bound(amount, 0.1 ether, 10000 ether); + amount = bound(amount, 100 ether, 10000 ether); vm.deal(user1, amount * 2); @@ -418,13 +453,14 @@ contract DepositPoolV2Test is Test { assertEq(token.balanceOf(user1), amount); - // Fund reserve and request withdrawal - pool.fundWithdrawalReserve{value: amount}(); - + // Request withdrawal FIRST (captures QRL value at current rate) uint256 shares = token.sharesOf(user1); vm.prank(user1); pool.requestWithdrawal(shares); + // Fund reserve AFTER request (reclassify deposited QRL for the claim) + pool.fundWithdrawalReserve(amount); + vm.roll(block.number + 129); uint256 balanceBefore = user1.balance; @@ -434,4 +470,535 @@ contract DepositPoolV2Test is Test { // Should get back approximately the same amount (minus any rounding) assertApproxEqRel(user1.balance - balanceBefore, amount, 1e15); } + + // ========================================================================= + // DEPOSIT ERROR TESTS + // ========================================================================= + + function test_Deposit_StQRLNotSet_Reverts() public { + // Deploy fresh pool without stQRL set + DepositPoolV2 freshPool = new DepositPoolV2(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.StQRLNotSet.selector); + freshPool.deposit{value: 1 ether}(); + } + + function test_Deposit_ZeroAmount_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.BelowMinDeposit.selector); + pool.deposit{value: 0}(); + } + + function test_Deposit_EmitsEvent() public { + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit Deposited(user1, 100 ether, 100 ether); + pool.deposit{value: 100 ether}(); + } + + // ========================================================================= + // WITHDRAWAL ERROR TESTS + // ========================================================================= + + function test_RequestWithdrawal_ZeroShares_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.ZeroAmount.selector); + pool.requestWithdrawal(0); + } + + function test_RequestWithdrawal_InsufficientShares_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.InsufficientShares.selector); + pool.requestWithdrawal(150 ether); + } + + function test_MultipleWithdrawalRequests() public { + // Multiple withdrawal requests are now allowed + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + (uint256 requestId1,) = pool.requestWithdrawal(50 ether); + + vm.prank(user1); + (uint256 requestId2,) = pool.requestWithdrawal(25 ether); + + assertEq(requestId1, 0); + assertEq(requestId2, 1); + assertEq(pool.totalWithdrawalShares(), 75 ether); + + // Verify both requests exist + (uint256 total, uint256 pending) = pool.getWithdrawalRequestCount(user1); + assertEq(total, 2); + assertEq(pending, 2); + } + + function test_RequestWithdrawal_WhenPaused_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + pool.pause(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.ContractPaused.selector); + pool.requestWithdrawal(50 ether); + } + + function test_RequestWithdrawal_EmitsEvent() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit WithdrawalRequested(user1, 50 ether, 50 ether, block.number); + pool.requestWithdrawal(50 ether); + } + + function test_ClaimWithdrawal_NoRequest_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NoWithdrawalPending.selector); + pool.claimWithdrawal(); + } + + function test_ClaimWithdrawal_EmitsEvent() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Request FIRST (captures QRL value), then fund reserve + vm.prank(user1); + pool.requestWithdrawal(50 ether); + + pool.fundWithdrawalReserve(50 ether); + + vm.roll(block.number + 129); + + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit WithdrawalClaimed(user1, 50 ether, 50 ether); + pool.claimWithdrawal(); + } + + function test_CancelWithdrawal_NoRequest_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.InvalidWithdrawalIndex.selector); + pool.cancelWithdrawal(0); + } + + // ========================================================================= + // VALIDATOR FUNDING ERROR TESTS + // ========================================================================= + + function test_FundValidatorMVP_InsufficientBuffer_Reverts() public { + // Deposit less than validator stake + vm.deal(user1, 5000 ether); + vm.prank(user1); + pool.deposit{value: 5000 ether}(); + + vm.expectRevert(DepositPoolV2.InsufficientBuffer.selector); + pool.fundValidatorMVP(); + } + + function test_FundValidatorMVP_EmitsEvent() public { + vm.deal(user1, 40000 ether); + vm.prank(user1); + pool.deposit{value: 40000 ether}(); + + vm.expectEmit(true, false, false, true); + emit ValidatorFunded(0, "", 40000 ether); + pool.fundValidatorMVP(); + } + + // ========================================================================= + // ADMIN FUNCTION TESTS + // ========================================================================= + + function test_SetStQRL() public { + DepositPoolV2 freshPool = new DepositPoolV2(); + address newStQRL = address(0x123); + + freshPool.setStQRL(newStQRL); + + assertEq(address(freshPool.stQRL()), newStQRL); + } + + function test_SetStQRL_ZeroAddress_Reverts() public { + DepositPoolV2 freshPool = new DepositPoolV2(); + + vm.expectRevert(DepositPoolV2.ZeroAddress.selector); + freshPool.setStQRL(address(0)); + } + + function test_SetStQRL_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.setStQRL(address(0x123)); + } + + function test_SetStQRL_AlreadySet_Reverts() public { + // stQRL is already set in setUp() + vm.expectRevert(DepositPoolV2.StQRLAlreadySet.selector); + pool.setStQRL(address(0x123)); + } + + function test_SetMinDeposit() public { + pool.setMinDeposit(200 ether); + assertEq(pool.minDeposit(), 200 ether); + + // Cannot set below the current floor (100 ether by default) + vm.expectRevert(DepositPoolV2.BelowMinDepositFloor.selector); + pool.setMinDeposit(50 ether); + } + + function test_SetMinDeposit_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.setMinDeposit(200 ether); + } + + function test_SetMinDeposit_EmitsEvent() public { + vm.expectEmit(false, false, false, true); + emit MinDepositUpdated(200 ether); + pool.setMinDeposit(200 ether); + } + + function test_Unpause() public { + pool.pause(); + assertTrue(pool.paused()); + + pool.unpause(); + assertFalse(pool.paused()); + } + + function test_Unpause_NotOwner_Reverts() public { + pool.pause(); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.unpause(); + } + + function test_TransferOwnership() public { + address newOwner = address(0x999); + + pool.transferOwnership(newOwner); + + assertEq(pool.owner(), newOwner); + } + + function test_TransferOwnership_ZeroAddress_Reverts() public { + vm.expectRevert(DepositPoolV2.ZeroAddress.selector); + pool.transferOwnership(address(0)); + } + + function test_TransferOwnership_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.transferOwnership(user1); + } + + function test_TransferOwnership_EmitsEvent() public { + address newOwner = address(0x999); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, newOwner); + pool.transferOwnership(newOwner); + } + + function test_EmergencyWithdraw() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Send some excess funds to the contract (stuck tokens) + vm.deal(address(pool), 110 ether); // 100 pooled + 10 excess + + address recipient = address(0x999); + uint256 balanceBefore = recipient.balance; + + // Can only withdraw excess (10 ether) + pool.emergencyWithdraw(recipient, 10 ether); + + assertEq(recipient.balance - balanceBefore, 10 ether); + } + + function test_EmergencyWithdraw_ExceedsRecoverable_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // No excess funds - balance equals pooled QRL + // Try to withdraw pool funds + vm.expectRevert(DepositPoolV2.ExceedsRecoverableAmount.selector); + pool.emergencyWithdraw(address(0x999), 10 ether); + } + + function test_EmergencyWithdraw_ZeroAddress_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add excess funds + vm.deal(address(pool), 110 ether); + + vm.expectRevert(DepositPoolV2.ZeroAddress.selector); + pool.emergencyWithdraw(address(0), 10 ether); + } + + function test_EmergencyWithdraw_ZeroAmount_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.expectRevert(DepositPoolV2.ZeroAmount.selector); + pool.emergencyWithdraw(address(0x999), 0); + } + + function test_EmergencyWithdraw_NotOwner_Reverts() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add excess funds + vm.deal(address(pool), 110 ether); + + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.emergencyWithdraw(user1, 10 ether); + } + + // ========================================================================= + // VIEW FUNCTION TESTS + // ========================================================================= + + function test_PreviewDeposit() public view { + // Before any deposits, 1:1 ratio + uint256 shares = pool.previewDeposit(100 ether); + assertEq(shares, 100 ether); + } + + function test_PreviewDeposit_AfterRewards() public { + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Add 50% rewards + vm.deal(address(pool), 150 ether); + pool.syncRewards(); + + // 100 QRL should now get fewer shares + uint256 shares = pool.previewDeposit(100 ether); + // At 1.5 QRL/share rate, 100 QRL = 66.67 shares + assertApproxEqRel(shares, 66.67 ether, 1e16); + } + + function test_PreviewDeposit_StQRLNotSet() public { + DepositPoolV2 freshPool = new DepositPoolV2(); + + // Should return 1:1 if stQRL not set + uint256 shares = freshPool.previewDeposit(100 ether); + assertEq(shares, 100 ether); + } + + // ========================================================================= + // RECEIVE FUNCTION TESTS + // ========================================================================= + + function test_Receive_IsNoOp() public { + // receive() is a no-op — incoming ETH does NOT auto-add to withdrawalReserve. + // _syncRewards() will later detect it as a balance increase (rewards). + uint256 reserveBefore = pool.withdrawalReserve(); + + // Send ETH directly to contract + (bool success,) = address(pool).call{value: 50 ether}(""); + assertTrue(success); + + // withdrawalReserve unchanged (receive is no-op) + assertEq(pool.withdrawalReserve(), reserveBefore); + + // syncRewards picks it up as rewards + pool.syncRewards(); + assertEq(pool.totalRewardsReceived(), 50 ether); + } + + function test_Receive_DetectedAsRewardsBySyncRewards() public { + // Deposit first so there's an existing totalPooledQRL baseline + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // Send ETH directly — receive() is a no-op, no event emitted + (bool success,) = address(pool).call{value: 50 ether}(""); + assertTrue(success); + + // syncRewards detects the 50 ether increase as rewards + vm.expectEmit(true, true, true, true); + emit RewardsSynced(50 ether, 150 ether, block.number); + pool.syncRewards(); + } + + function test_FundWithdrawalReserve() public { + // Need deposits first so there's totalPooledQRL to reclassify + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + uint256 reserveBefore = pool.withdrawalReserve(); + uint256 pooledBefore = token.totalPooledQRL(); + + pool.fundWithdrawalReserve(50 ether); + + assertEq(pool.withdrawalReserve(), reserveBefore + 50 ether); + assertEq(token.totalPooledQRL(), pooledBefore - 50 ether); + } + + function test_FundWithdrawalReserve_EmitsEvent() public { + // Need deposits first so there's totalPooledQRL to reclassify + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.expectEmit(false, false, false, true); + emit WithdrawalReserveFunded(50 ether); + pool.fundWithdrawalReserve(50 ether); + } + + // ========================================================================= + // MULTI-USER SCENARIOS + // ========================================================================= + + function test_MultipleUsersWithdrawalQueue() public { + // User1 and User2 both deposit + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + vm.prank(user2); + pool.deposit{value: 100 ether}(); + + // Verify initial state + assertEq(token.totalPooledQRL(), 200 ether); + assertEq(token.totalShares(), 200 ether); + + // Both request withdrawals FIRST (captures QRL value at 1:1 rate) + vm.prank(user1); + pool.requestWithdrawal(50 ether); + + vm.prank(user2); + pool.requestWithdrawal(50 ether); + + assertEq(pool.totalWithdrawalShares(), 100 ether); + + // Fund withdrawal reserve AFTER requests (reclassify enough for both claims) + pool.fundWithdrawalReserve(100 ether); + + // Verify reserve and pooled state + assertEq(token.totalPooledQRL(), 100 ether); + assertEq(pool.withdrawalReserve(), 100 ether); + + // Wait for delay + vm.roll(block.number + 129); + + // User1 claims - should receive exactly 50 ether + uint256 user1BalanceBefore = user1.balance; + vm.prank(user1); + uint256 user1Claimed = pool.claimWithdrawal(); + assertEq(user1Claimed, 50 ether); + assertEq(user1.balance - user1BalanceBefore, 50 ether); + + // User2 claims - should also receive exactly 50 ether + uint256 user2BalanceBefore = user2.balance; + vm.prank(user2); + uint256 user2Claimed = pool.claimWithdrawal(); + assertEq(user2Claimed, 50 ether); + assertEq(user2.balance - user2BalanceBefore, 50 ether); + + // Queue should be empty + assertEq(pool.totalWithdrawalShares(), 0); + } + + function test_RewardsDistributedProportionally() public { + // User1 deposits 100 QRL + vm.prank(user1); + pool.deposit{value: 100 ether}(); + + // User2 deposits 200 QRL + vm.prank(user2); + pool.deposit{value: 200 ether}(); + + // Add 30 QRL rewards (10% of 300) + vm.deal(address(pool), 330 ether); + pool.syncRewards(); + + // User1 has 100/300 = 33.33% of shares -> 33.33% of 330 = 110 QRL (approx) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); + + // User2 has 200/300 = 66.67% of shares -> 66.67% of 330 = 220 QRL (approx) + assertApproxEqRel(token.getQRLValue(user2), 220 ether, 1e14); + } + + // ========================================================================= + // MIN DEPOSIT FLOOR TESTS + // ========================================================================= + + function test_SetMinDepositFloor() public { + // Default floor is 100 ether + assertEq(pool.minDepositFloor(), 100 ether); + + // Owner can lower the floor + pool.setMinDepositFloor(1 ether); + assertEq(pool.minDepositFloor(), 1 ether); + + // Owner can raise it back + pool.setMinDepositFloor(50 ether); + assertEq(pool.minDepositFloor(), 50 ether); + } + + function test_SetMinDepositFloor_BelowAbsoluteMin_Reverts() public { + // Cannot set floor below ABSOLUTE_MIN_DEPOSIT (0.001 ether) + vm.expectRevert(DepositPoolV2.BelowAbsoluteMin.selector); + pool.setMinDepositFloor(0.0001 ether); + + // Zero also reverts + vm.expectRevert(DepositPoolV2.BelowAbsoluteMin.selector); + pool.setMinDepositFloor(0); + } + + function test_SetMinDepositFloor_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(DepositPoolV2.NotOwner.selector); + pool.setMinDepositFloor(1 ether); + } + + function test_SetMinDepositFloor_EmitsEvent() public { + vm.expectEmit(false, false, false, true); + emit MinDepositFloorUpdated(1 ether); + pool.setMinDepositFloor(1 ether); + } + + function test_SetMinDeposit_AfterFloorLowered() public { + // Lower the floor first + pool.setMinDepositFloor(1 ether); + assertEq(pool.minDepositFloor(), 1 ether); + + // Now we can lower minDeposit below the old 100 ether floor + pool.setMinDeposit(5 ether); + assertEq(pool.minDeposit(), 5 ether); + + // Deposits at the new lower minimum work + vm.deal(user1, 10 ether); + vm.prank(user1); + uint256 shares = pool.deposit{value: 5 ether}(); + assertEq(shares, 5 ether); + + // Still cannot go below the new floor + vm.expectRevert(DepositPoolV2.BelowMinDepositFloor.selector); + pool.setMinDeposit(0.5 ether); + } + + // ========================================================================= + // EVENT DECLARATIONS + // ========================================================================= + + event MinDepositUpdated(uint256 newMinDeposit); + event MinDepositFloorUpdated(uint256 newFloor); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event ValidatorFunded(uint256 indexed validatorId, bytes pubkey, uint256 amount); + event WithdrawalReserveFunded(uint256 amount); } diff --git a/test/ValidatorManager.t.sol b/test/ValidatorManager.t.sol new file mode 100644 index 0000000..272cc08 --- /dev/null +++ b/test/ValidatorManager.t.sol @@ -0,0 +1,720 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../contracts/solidity/ValidatorManager.sol"; + +/** + * @title ValidatorManager Tests + * @notice Unit tests for validator lifecycle management + */ +contract ValidatorManagerTest is Test { + ValidatorManager public manager; + address public owner; + address public depositPool; + address public operator; + address public randomUser; + + // Dilithium pubkey is 2592 bytes + uint256 constant PUBKEY_LENGTH = 2592; + uint256 constant VALIDATOR_STAKE = 40_000 ether; + + // Events to test + event ValidatorRegistered(uint256 indexed validatorId, bytes pubkey, ValidatorManager.ValidatorStatus status); + event ValidatorActivated(uint256 indexed validatorId, uint256 activatedBlock); + event ValidatorExitRequested(uint256 indexed validatorId, uint256 requestBlock); + event ValidatorExited(uint256 indexed validatorId, uint256 exitedBlock); + event ValidatorSlashed(uint256 indexed validatorId, uint256 slashedBlock); + event DepositPoolSet(address indexed depositPool); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + function setUp() public { + owner = address(this); + depositPool = address(0x1); + operator = address(0x2); + randomUser = address(0x3); + + manager = new ValidatorManager(); + manager.setDepositPool(depositPool); + } + + // ========================================================================= + // HELPERS + // ========================================================================= + + function _generatePubkey(uint256 seed) internal pure returns (bytes memory) { + bytes memory pubkey = new bytes(PUBKEY_LENGTH); + for (uint256 i = 0; i < PUBKEY_LENGTH; i++) { + pubkey[i] = bytes1(uint8(uint256(keccak256(abi.encodePacked(seed, i))) % 256)); + } + return pubkey; + } + + function _registerValidator(uint256 seed) internal returns (uint256 validatorId, bytes memory pubkey) { + pubkey = _generatePubkey(seed); + vm.prank(depositPool); + validatorId = manager.registerValidator(pubkey); + } + + function _registerAndActivate(uint256 seed) internal returns (uint256 validatorId, bytes memory pubkey) { + (validatorId, pubkey) = _registerValidator(seed); + manager.activateValidator(validatorId); + } + + // ========================================================================= + // INITIALIZATION TESTS + // ========================================================================= + + function test_InitialState() public view { + assertEq(manager.owner(), owner); + assertEq(manager.depositPool(), depositPool); + assertEq(manager.totalValidators(), 0); + assertEq(manager.activeValidatorCount(), 0); + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.VALIDATOR_STAKE(), VALIDATOR_STAKE); + } + + function test_GetStats_Initial() public view { + (uint256 total, uint256 pending, uint256 active, uint256 totalStaked) = manager.getStats(); + assertEq(total, 0); + assertEq(pending, 0); + assertEq(active, 0); + assertEq(totalStaked, 0); + } + + // ========================================================================= + // VALIDATOR REGISTRATION TESTS + // ========================================================================= + + function test_RegisterValidator() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + assertEq(validatorId, 1); + assertEq(manager.totalValidators(), 1); + assertEq(manager.pendingValidatorCount(), 1); + assertEq(manager.activeValidatorCount(), 0); + + ( + bytes memory storedPubkey, + ValidatorManager.ValidatorStatus status, + uint256 activatedBlock, + uint256 exitedBlock + ) = manager.getValidator(validatorId); + + assertEq(storedPubkey, pubkey); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Pending)); + assertEq(activatedBlock, 0); + assertEq(exitedBlock, 0); + } + + function test_RegisterValidator_EmitsEvent() public { + bytes memory pubkey = _generatePubkey(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorRegistered(1, pubkey, ValidatorManager.ValidatorStatus.Pending); + + vm.prank(depositPool); + manager.registerValidator(pubkey); + } + + function test_RegisterValidator_ByOwner() public { + bytes memory pubkey = _generatePubkey(1); + uint256 validatorId = manager.registerValidator(pubkey); + assertEq(validatorId, 1); + } + + function test_RegisterValidator_NotAuthorized_Reverts() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotAuthorized.selector); + manager.registerValidator(pubkey); + } + + function test_RegisterValidator_InvalidPubkeyLength_Reverts() public { + bytes memory shortPubkey = new bytes(100); + + vm.prank(depositPool); + vm.expectRevert(ValidatorManager.InvalidPubkeyLength.selector); + manager.registerValidator(shortPubkey); + } + + function test_RegisterValidator_EmptyPubkey_Reverts() public { + bytes memory emptyPubkey = new bytes(0); + + vm.prank(depositPool); + vm.expectRevert(ValidatorManager.InvalidPubkeyLength.selector); + manager.registerValidator(emptyPubkey); + } + + function test_RegisterValidator_Duplicate_Reverts() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(depositPool); + manager.registerValidator(pubkey); + + vm.prank(depositPool); + vm.expectRevert(ValidatorManager.ValidatorAlreadyExists.selector); + manager.registerValidator(pubkey); + } + + function test_RegisterValidator_MultipleValidators() public { + for (uint256 i = 1; i <= 5; i++) { + bytes memory pubkey = _generatePubkey(i); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + assertEq(validatorId, i); + } + + assertEq(manager.totalValidators(), 5); + assertEq(manager.pendingValidatorCount(), 5); + } + + // ========================================================================= + // VALIDATOR ACTIVATION TESTS + // ========================================================================= + + function test_ActivateValidator() public { + (uint256 validatorId,) = _registerValidator(1); + + assertEq(manager.pendingValidatorCount(), 1); + assertEq(manager.activeValidatorCount(), 0); + + manager.activateValidator(validatorId); + + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.activeValidatorCount(), 1); + + (, ValidatorManager.ValidatorStatus status, uint256 activatedBlock,) = manager.getValidator(validatorId); + + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Active)); + assertEq(activatedBlock, block.number); + } + + function test_ActivateValidator_EmitsEvent() public { + (uint256 validatorId,) = _registerValidator(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorActivated(validatorId, block.number); + + manager.activateValidator(validatorId); + } + + function test_ActivateValidator_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.activateValidator(validatorId); + } + + function test_ActivateValidator_NotPending_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + // Already active, cannot activate again + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.activateValidator(validatorId); + } + + function test_ActivateValidator_NonExistent_Reverts() public { + // Validator 999 doesn't exist (status is None) + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.activateValidator(999); + } + + // ========================================================================= + // BATCH ACTIVATION TESTS + // ========================================================================= + + function test_BatchActivateValidators() public { + // Register 5 validators + uint256[] memory ids = new uint256[](5); + for (uint256 i = 0; i < 5; i++) { + (ids[i],) = _registerValidator(i + 1); + } + + assertEq(manager.pendingValidatorCount(), 5); + assertEq(manager.activeValidatorCount(), 0); + + manager.batchActivateValidators(ids); + + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.activeValidatorCount(), 5); + } + + function test_BatchActivateValidators_SkipsNonPending() public { + // Register 3 validators + (uint256 id1,) = _registerValidator(1); + (uint256 id2,) = _registerValidator(2); + (uint256 id3,) = _registerValidator(3); + + // Activate id2 individually first + manager.activateValidator(id2); + + uint256[] memory ids = new uint256[](3); + ids[0] = id1; + ids[1] = id2; // Already active, should be skipped + ids[2] = id3; + + manager.batchActivateValidators(ids); + + // All should be active now + assertEq(manager.pendingValidatorCount(), 0); + assertEq(manager.activeValidatorCount(), 3); + } + + function test_BatchActivateValidators_EmptyArray() public { + uint256[] memory ids = new uint256[](0); + manager.batchActivateValidators(ids); + // Should not revert, just do nothing + } + + function test_BatchActivateValidators_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + uint256[] memory ids = new uint256[](1); + ids[0] = validatorId; + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.batchActivateValidators(ids); + } + + // ========================================================================= + // EXIT REQUEST TESTS + // ========================================================================= + + function test_RequestValidatorExit() public { + (uint256 validatorId,) = _registerAndActivate(1); + + manager.requestValidatorExit(validatorId); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exiting)); + + // Counter should still show as active (exiting validators count as active until fully exited) + assertEq(manager.activeValidatorCount(), 1); + } + + function test_RequestValidatorExit_EmitsEvent() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorExitRequested(validatorId, block.number); + + manager.requestValidatorExit(validatorId); + } + + function test_RequestValidatorExit_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.requestValidatorExit(validatorId); + } + + function test_RequestValidatorExit_NotActive_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + + // Still pending, cannot request exit + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.requestValidatorExit(validatorId); + } + + // ========================================================================= + // MARK EXITED TESTS + // ========================================================================= + + function test_MarkValidatorExited() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + assertEq(manager.activeValidatorCount(), 1); + + manager.markValidatorExited(validatorId); + + (, ValidatorManager.ValidatorStatus status,, uint256 exitedBlock) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exited)); + assertEq(exitedBlock, block.number); + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorExited_EmitsEvent() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + vm.expectEmit(true, false, false, true); + emit ValidatorExited(validatorId, block.number); + + manager.markValidatorExited(validatorId); + } + + function test_MarkValidatorExited_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.markValidatorExited(validatorId); + } + + function test_MarkValidatorExited_NotExiting_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + // Still active, not exiting + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorExited(validatorId); + } + + // ========================================================================= + // SLASHING TESTS (M-1 FIX VERIFICATION) + // ========================================================================= + + function test_MarkValidatorSlashed_FromActive() public { + (uint256 validatorId,) = _registerAndActivate(1); + + assertEq(manager.activeValidatorCount(), 1); + + manager.markValidatorSlashed(validatorId); + + (, ValidatorManager.ValidatorStatus status,, uint256 exitedBlock) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Slashed)); + assertEq(exitedBlock, block.number); + + // M-1 FIX: Counter should decrement when slashing from Active + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorSlashed_FromExiting() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + + assertEq(manager.activeValidatorCount(), 1); + + manager.markValidatorSlashed(validatorId); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Slashed)); + + // Counter should decrement - Exiting validators still count as active + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorSlashed_MultipleActiveValidators() public { + // Register and activate 3 validators + (uint256 id1,) = _registerAndActivate(1); + (uint256 id2,) = _registerAndActivate(2); + (uint256 id3,) = _registerAndActivate(3); + + assertEq(manager.activeValidatorCount(), 3); + + // Slash the middle one + manager.markValidatorSlashed(id2); + + // M-1 FIX: Counter should be 2 now + assertEq(manager.activeValidatorCount(), 2); + + // Slash another + manager.markValidatorSlashed(id1); + assertEq(manager.activeValidatorCount(), 1); + + // Slash the last one + manager.markValidatorSlashed(id3); + assertEq(manager.activeValidatorCount(), 0); + } + + function test_MarkValidatorSlashed_EmitsEvent() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.expectEmit(true, false, false, true); + emit ValidatorSlashed(validatorId, block.number); + + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_NotOwner_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_FromPending_Reverts() public { + (uint256 validatorId,) = _registerValidator(1); + + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_FromExited_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.requestValidatorExit(validatorId); + manager.markValidatorExited(validatorId); + + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorSlashed(validatorId); + } + + function test_MarkValidatorSlashed_AlreadySlashed_Reverts() public { + (uint256 validatorId,) = _registerAndActivate(1); + manager.markValidatorSlashed(validatorId); + + vm.expectRevert(ValidatorManager.InvalidStatusTransition.selector); + manager.markValidatorSlashed(validatorId); + } + + // ========================================================================= + // VIEW FUNCTION TESTS + // ========================================================================= + + function test_GetValidatorIdByPubkey() public { + bytes memory pubkey = _generatePubkey(42); + + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + uint256 lookupId = manager.getValidatorIdByPubkey(pubkey); + assertEq(lookupId, validatorId); + } + + function test_GetValidatorIdByPubkey_NotFound() public view { + bytes memory unknownPubkey = _generatePubkey(999); + uint256 lookupId = manager.getValidatorIdByPubkey(unknownPubkey); + assertEq(lookupId, 0); + } + + function test_GetValidatorStatus() public { + bytes memory pubkey = _generatePubkey(1); + + vm.prank(depositPool); + manager.registerValidator(pubkey); + + ValidatorManager.ValidatorStatus status = manager.getValidatorStatus(pubkey); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Pending)); + } + + function test_GetValidatorStatus_NotFound() public view { + bytes memory unknownPubkey = _generatePubkey(999); + ValidatorManager.ValidatorStatus status = manager.getValidatorStatus(unknownPubkey); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.None)); + } + + function test_GetStats() public { + // Register 3 validators + _registerValidator(1); + _registerValidator(2); + (uint256 id3,) = _registerValidator(3); + + // Activate 1 + manager.activateValidator(id3); + + (uint256 total, uint256 pending, uint256 active, uint256 totalStaked) = manager.getStats(); + + assertEq(total, 3); + assertEq(pending, 2); + assertEq(active, 1); + assertEq(totalStaked, VALIDATOR_STAKE); + } + + function test_GetValidatorsByStatus() public { + // Register 5 validators + _registerValidator(1); + (uint256 id2,) = _registerValidator(2); + _registerValidator(3); + (uint256 id4,) = _registerValidator(4); + _registerValidator(5); + + // Activate some + manager.activateValidator(id2); + manager.activateValidator(id4); + + // Get pending validators + uint256[] memory pendingIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.Pending); + assertEq(pendingIds.length, 3); + + // Get active validators + uint256[] memory activeIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.Active); + assertEq(activeIds.length, 2); + assertEq(activeIds[0], id2); + assertEq(activeIds[1], id4); + + // Request exit for one + manager.requestValidatorExit(id2); + uint256[] memory exitingIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.Exiting); + assertEq(exitingIds.length, 1); + assertEq(exitingIds[0], id2); + } + + function test_GetValidatorsByStatus_None() public view { + uint256[] memory noneIds = manager.getValidatorsByStatus(ValidatorManager.ValidatorStatus.None); + assertEq(noneIds.length, 0); + } + + // ========================================================================= + // ADMIN FUNCTION TESTS + // ========================================================================= + + function test_SetDepositPool() public { + ValidatorManager newManager = new ValidatorManager(); + address newDepositPool = address(0x999); + + newManager.setDepositPool(newDepositPool); + + assertEq(newManager.depositPool(), newDepositPool); + } + + function test_SetDepositPool_EmitsEvent() public { + ValidatorManager newManager = new ValidatorManager(); + address newDepositPool = address(0x999); + + vm.expectEmit(true, false, false, false); + emit DepositPoolSet(newDepositPool); + + newManager.setDepositPool(newDepositPool); + } + + function test_SetDepositPool_NotOwner_Reverts() public { + ValidatorManager newManager = new ValidatorManager(); + + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + newManager.setDepositPool(address(0x999)); + } + + function test_SetDepositPool_ZeroAddress_Reverts() public { + ValidatorManager newManager = new ValidatorManager(); + + vm.expectRevert(ValidatorManager.ZeroAddress.selector); + newManager.setDepositPool(address(0)); + } + + function test_TransferOwnership() public { + address newOwner = address(0x888); + + manager.transferOwnership(newOwner); + + assertEq(manager.owner(), newOwner); + } + + function test_TransferOwnership_EmitsEvent() public { + address newOwner = address(0x888); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, newOwner); + + manager.transferOwnership(newOwner); + } + + function test_TransferOwnership_NotOwner_Reverts() public { + vm.prank(randomUser); + vm.expectRevert(ValidatorManager.NotOwner.selector); + manager.transferOwnership(address(0x888)); + } + + function test_TransferOwnership_ZeroAddress_Reverts() public { + vm.expectRevert(ValidatorManager.ZeroAddress.selector); + manager.transferOwnership(address(0)); + } + + function test_TransferOwnership_NewOwnerCanOperate() public { + address newOwner = address(0x888); + manager.transferOwnership(newOwner); + + (uint256 validatorId,) = _registerValidator(1); + + // New owner can activate + vm.prank(newOwner); + manager.activateValidator(validatorId); + + assertEq(manager.activeValidatorCount(), 1); + } + + // ========================================================================= + // FULL LIFECYCLE TEST + // ========================================================================= + + function test_FullValidatorLifecycle() public { + // 1. Register + bytes memory pubkey = _generatePubkey(1); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Pending)); + + // 2. Activate + manager.activateValidator(validatorId); + (, status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Active)); + + // 3. Request exit + manager.requestValidatorExit(validatorId); + (, status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exiting)); + + // 4. Mark exited + manager.markValidatorExited(validatorId); + (, status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Exited)); + } + + function test_FullValidatorLifecycle_WithSlashing() public { + // 1. Register + bytes memory pubkey = _generatePubkey(1); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + + // 2. Activate + manager.activateValidator(validatorId); + assertEq(manager.activeValidatorCount(), 1); + + // 3. Slashed while active + manager.markValidatorSlashed(validatorId); + + (, ValidatorManager.ValidatorStatus status,,) = manager.getValidator(validatorId); + assertEq(uint256(status), uint256(ValidatorManager.ValidatorStatus.Slashed)); + assertEq(manager.activeValidatorCount(), 0); + } + + // ========================================================================= + // FUZZ TESTS + // ========================================================================= + + function testFuzz_RegisterMultipleValidators(uint8 count) public { + vm.assume(count > 0 && count <= 50); + + for (uint256 i = 1; i <= count; i++) { + bytes memory pubkey = _generatePubkey(i); + vm.prank(depositPool); + uint256 validatorId = manager.registerValidator(pubkey); + assertEq(validatorId, i); + } + + assertEq(manager.totalValidators(), count); + assertEq(manager.pendingValidatorCount(), count); + } + + function testFuzz_SlashingCounterCorrectness(uint8 activeCount, uint8 slashCount) public { + vm.assume(activeCount > 0 && activeCount <= 20); + vm.assume(slashCount <= activeCount); + + // Register and activate validators + uint256[] memory ids = new uint256[](activeCount); + for (uint256 i = 0; i < activeCount; i++) { + (ids[i],) = _registerAndActivate(i + 1); + } + + assertEq(manager.activeValidatorCount(), activeCount); + + // Slash some validators + for (uint256 i = 0; i < slashCount; i++) { + manager.markValidatorSlashed(ids[i]); + } + + // Verify counter is correct (M-1 fix verification) + assertEq(manager.activeValidatorCount(), activeCount - slashCount); + } +} diff --git a/test/stQRL-v2.t.sol b/test/stQRL-v2.t.sol index 317a0fe..1f180b3 100644 --- a/test/stQRL-v2.t.sol +++ b/test/stQRL-v2.t.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; -import "../contracts/stQRL-v2.sol"; +import "../contracts/solidity/stQRL-v2.sol"; /** * @title stQRL v2 Tests - * @notice Unit tests for the rebasing stQRL token + * @notice Unit tests for the fixed-balance stQRL token */ contract stQRLv2Test is Test { stQRLv2 public token; @@ -16,7 +16,6 @@ contract stQRLv2Test is Test { address public user2; event Transfer(address indexed from, address indexed to, uint256 value); - event TransferShares(address indexed from, address indexed to, uint256 sharesValue); event SharesMinted(address indexed to, uint256 sharesAmount, uint256 qrlAmount); event SharesBurned(address indexed from, uint256 sharesAmount, uint256 qrlAmount); event TotalPooledQRLUpdated(uint256 previousAmount, uint256 newAmount); @@ -52,63 +51,72 @@ contract stQRLv2Test is Test { } // ========================================================================= - // REBASING MATH TESTS + // SHARE & VALUE MATH TESTS // ========================================================================= function test_FirstDeposit_OneToOneRatio() public { uint256 amount = 100 ether; + // Order matters with virtual shares: mint FIRST, then update pooled + // This matches how DepositPool.deposit() works vm.startPrank(depositPool); - token.updateTotalPooledQRL(amount); uint256 shares = token.mintShares(user1, amount); + token.updateTotalPooledQRL(amount); vm.stopPrank(); // First deposit should be 1:1 assertEq(shares, amount); - assertEq(token.balanceOf(user1), amount); + assertEq(token.balanceOf(user1), amount); // balanceOf returns shares assertEq(token.sharesOf(user1), amount); - assertEq(token.totalSupply(), amount); + assertEq(token.totalSupply(), amount); // totalSupply returns total shares + assertEq(token.getQRLValue(user1), amount); // QRL value equals shares at 1:1 } - function test_RewardsIncreaseBalance() public { + function test_RewardsIncreaseQRLValue() public { // Initial deposit of 100 QRL uint256 initialDeposit = 100 ether; + // Mint first, then update (matches DepositPool behavior) vm.startPrank(depositPool); - token.updateTotalPooledQRL(initialDeposit); token.mintShares(user1, initialDeposit); + token.updateTotalPooledQRL(initialDeposit); vm.stopPrank(); - assertEq(token.balanceOf(user1), 100 ether); + assertEq(token.balanceOf(user1), 100 ether); // shares + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); // QRL value (tiny precision diff from virtual shares) // Simulate 10 QRL rewards (10% increase) vm.prank(depositPool); token.updateTotalPooledQRL(110 ether); - // User's balance should now reflect rewards - assertEq(token.balanceOf(user1), 110 ether); - // But shares remain the same + // User's shares remain the same (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // But QRL value increases (use approx due to virtual shares precision) + assertApproxEqRel(token.getQRLValue(user1), 110 ether, 1e14); assertEq(token.sharesOf(user1), 100 ether); } - function test_SlashingDecreasesBalance() public { + function test_SlashingDecreasesQRLValue() public { // Initial deposit of 100 QRL uint256 initialDeposit = 100 ether; + // Mint first, then update (matches DepositPool behavior) vm.startPrank(depositPool); - token.updateTotalPooledQRL(initialDeposit); token.mintShares(user1, initialDeposit); + token.updateTotalPooledQRL(initialDeposit); vm.stopPrank(); - assertEq(token.balanceOf(user1), 100 ether); + assertEq(token.balanceOf(user1), 100 ether); // shares + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); // QRL value // Simulate 5% slashing (pool drops to 95 QRL) vm.prank(depositPool); token.updateTotalPooledQRL(95 ether); - // User's balance should reflect slashing - assertEq(token.balanceOf(user1), 95 ether); - // Shares remain the same + // User's shares remain the same (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + // But QRL value decreases (use approx due to virtual shares precision) + assertApproxEqRel(token.getQRLValue(user1), 95 ether, 1e14); assertEq(token.sharesOf(user1), 100 ether); } @@ -127,26 +135,34 @@ contract stQRLv2Test is Test { token.updateTotalPooledQRL(150 ether); vm.stopPrank(); - // Check balances before rewards + // Check shares (fixed-balance: balanceOf returns shares) assertEq(token.balanceOf(user1), 100 ether); assertEq(token.balanceOf(user2), 50 ether); + // Check QRL values before rewards (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); + assertApproxEqRel(token.getQRLValue(user2), 50 ether, 1e14); + // Add 30 QRL rewards (20% increase, total now 180 QRL) vm.prank(depositPool); token.updateTotalPooledQRL(180 ether); - // Rewards should be distributed proportionally + // Shares remain the same (fixed-balance) + assertEq(token.balanceOf(user1), 100 ether); + assertEq(token.balanceOf(user2), 50 ether); + + // QRL values should be distributed proportionally (approx due to virtual shares) // User1 has 100/150 = 66.67% of shares -> gets 66.67% of 180 = 120 QRL // User2 has 50/150 = 33.33% of shares -> gets 33.33% of 180 = 60 QRL - assertEq(token.balanceOf(user1), 120 ether); - assertEq(token.balanceOf(user2), 60 ether); + assertApproxEqRel(token.getQRLValue(user1), 120 ether, 1e14); + assertApproxEqRel(token.getQRLValue(user2), 60 ether, 1e14); } function test_ShareConversion_AfterRewards() public { - // Deposit 100 QRL + // Deposit 100 QRL - mint first, then update vm.startPrank(depositPool); - token.updateTotalPooledQRL(100 ether); token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); vm.stopPrank(); // Add 50 QRL rewards (now 150 QRL, still 100 shares) @@ -154,9 +170,9 @@ contract stQRLv2Test is Test { token.updateTotalPooledQRL(150 ether); // New deposit should get fewer shares - // 100 QRL should get 100 * 100 / 150 = 66.67 shares + // With virtual shares: 100 * (100e18 + 1000) / (150e18 + 1000) ≈ 66.67 shares uint256 expectedShares = token.getSharesByPooledQRL(100 ether); - // At rate of 1.5 QRL/share, 100 QRL = 66.67 shares + // At rate of 1.5 QRL/share, 100 QRL ≈ 66.67 shares assertApproxEqRel(expectedShares, 66.67 ether, 1e16); // 1% tolerance // And those shares should be worth 100 QRL @@ -177,49 +193,59 @@ contract stQRLv2Test is Test { } function test_ZeroPooled_ZeroTotalShares() public view { - // Before any deposits + // Before any deposits, with virtual shares the math is: + // getSharesByPooledQRL(100e18) = 100e18 * (0 + 1000) / (0 + 1000) = 100e18 assertEq(token.getSharesByPooledQRL(100 ether), 100 ether); - assertEq(token.getPooledQRLByShares(100 ether), 0); + // getPooledQRLByShares(100e18) = 100e18 * (0 + 1000) / (0 + 1000) = 100e18 + // Virtual shares ensure 1:1 ratio even with empty pool + assertEq(token.getPooledQRLByShares(100 ether), 100 ether); } function test_LargeNumbers() public { uint256 largeAmount = 1_000_000_000 ether; // 1 billion QRL + // Mint first, then update (matches DepositPool behavior) vm.startPrank(depositPool); - token.updateTotalPooledQRL(largeAmount); token.mintShares(user1, largeAmount); + token.updateTotalPooledQRL(largeAmount); vm.stopPrank(); - assertEq(token.balanceOf(user1), largeAmount); + assertEq(token.balanceOf(user1), largeAmount); // shares + assertApproxEqRel(token.getQRLValue(user1), largeAmount, 1e14); // QRL value (approx due to virtual shares) // Add 10% rewards uint256 newTotal = largeAmount + (largeAmount / 10); vm.prank(depositPool); token.updateTotalPooledQRL(newTotal); - assertEq(token.balanceOf(user1), newTotal); + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), largeAmount); + // QRL value reflects rewards (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), newTotal, 1e14); } function test_SmallNumbers() public { uint256 smallAmount = 1; // 1 wei + // Mint first, then update (matches DepositPool behavior) vm.startPrank(depositPool); - token.updateTotalPooledQRL(smallAmount); token.mintShares(user1, smallAmount); + token.updateTotalPooledQRL(smallAmount); vm.stopPrank(); assertEq(token.balanceOf(user1), smallAmount); assertEq(token.sharesOf(user1), smallAmount); } - function testFuzz_RebasingMath(uint256 deposit, uint256 rewardPercent) public { + function testFuzz_ExchangeRateMath(uint256 deposit, uint256 rewardPercent) public { // Bound inputs to reasonable ranges deposit = bound(deposit, 1 ether, 1_000_000_000 ether); rewardPercent = bound(rewardPercent, 0, 100); // 0-100% rewards + // Mint first, then update (matches DepositPool behavior) vm.startPrank(depositPool); - token.updateTotalPooledQRL(deposit); token.mintShares(user1, deposit); + token.updateTotalPooledQRL(deposit); vm.stopPrank(); uint256 rewards = (deposit * rewardPercent) / 100; @@ -228,8 +254,11 @@ contract stQRLv2Test is Test { vm.prank(depositPool); token.updateTotalPooledQRL(newTotal); - // Balance should equal new total (user owns all shares) - assertEq(token.balanceOf(user1), newTotal); + // Shares unchanged (fixed-balance) + assertEq(token.balanceOf(user1), deposit); + // QRL value should equal new total (user owns all shares) + // Use approx due to tiny precision difference from virtual shares + assertApproxEqRel(token.getQRLValue(user1), newTotal, 1e14); } // ========================================================================= @@ -237,13 +266,13 @@ contract stQRLv2Test is Test { // ========================================================================= function test_Transfer() public { - // Setup: user1 has 100 QRL + // Setup: user1 has 100 shares - mint first, then update vm.startPrank(depositPool); - token.updateTotalPooledQRL(100 ether); token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); vm.stopPrank(); - // Transfer 30 QRL worth to user2 + // Transfer 30 shares to user2 vm.prank(user1); token.transfer(user2, 30 ether); @@ -252,29 +281,36 @@ contract stQRLv2Test is Test { } function test_TransferAfterRewards() public { - // Setup: user1 has 100 QRL + // Setup: user1 has 100 shares - mint first, then update vm.startPrank(depositPool); - token.updateTotalPooledQRL(100 ether); token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); vm.stopPrank(); - // Add 50% rewards (user1 now has 150 QRL worth) + // Add 50% rewards (user1's shares now worth 150 QRL) vm.prank(depositPool); token.updateTotalPooledQRL(150 ether); - // Transfer 75 QRL (half) to user2 + assertEq(token.balanceOf(user1), 100 ether); // still 100 shares + assertApproxEqRel(token.getQRLValue(user1), 150 ether, 1e14); // worth 150 QRL (approx) + + // Transfer 50 shares (half) to user2 vm.prank(user1); - token.transfer(user2, 75 ether); + token.transfer(user2, 50 ether); - assertEq(token.balanceOf(user1), 75 ether); - assertEq(token.balanceOf(user2), 75 ether); + // Each user has 50 shares + assertEq(token.balanceOf(user1), 50 ether); + assertEq(token.balanceOf(user2), 50 ether); + // Each user's shares worth 75 QRL (half of 150 total) (approx due to virtual shares) + assertApproxEqRel(token.getQRLValue(user1), 75 ether, 1e14); + assertApproxEqRel(token.getQRLValue(user2), 75 ether, 1e14); } function test_TransferFrom() public { - // Setup: user1 has 100 QRL + // Setup: user1 has 100 shares - mint first, then update vm.startPrank(depositPool); - token.updateTotalPooledQRL(100 ether); token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); vm.stopPrank(); // user1 approves user2 @@ -350,10 +386,10 @@ contract stQRLv2Test is Test { } function test_UnpauseAllowsTransfers() public { - // Setup + // Setup - mint first, then update (correct order) vm.startPrank(depositPool); - token.updateTotalPooledQRL(100 ether); token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); vm.stopPrank(); // Pause then unpause @@ -365,4 +401,386 @@ contract stQRLv2Test is Test { token.transfer(user2, 50 ether); assertEq(token.balanceOf(user2), 50 ether); } + + // ========================================================================= + // APPROVE TESTS + // ========================================================================= + + function test_Approve() public { + // Setup + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + // Approve + vm.prank(user1); + bool success = token.approve(user2, 50 ether); + + assertTrue(success); + assertEq(token.allowance(user1, user2), 50 ether); + } + + function test_Approve_ZeroAddress_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.approve(address(0), 50 ether); + } + + function test_Approve_EmitsEvent() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Approval(user1, user2, 50 ether); + token.approve(user2, 50 ether); + } + + // ========================================================================= + // TRANSFER ERROR TESTS + // ========================================================================= + + function test_Transfer_ToZeroAddress_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.transfer(address(0), 50 ether); + } + + function test_Transfer_ZeroAmount_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.transfer(user2, 0); + } + + function test_Transfer_InsufficientBalance_Reverts() public { + vm.startPrank(depositPool); + token.updateTotalPooledQRL(100 ether); + token.mintShares(user1, 100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.InsufficientBalance.selector); + token.transfer(user2, 150 ether); + } + + function test_Transfer_EmitsEvent() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, user2, 50 ether); + token.transfer(user2, 50 ether); + } + + // ========================================================================= + // TRANSFERFROM ERROR TESTS + // ========================================================================= + + function test_TransferFrom_ZeroAmount_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + token.approve(user2, 50 ether); + + vm.prank(user2); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.transferFrom(user1, user2, 0); + } + + function test_TransferFrom_InsufficientAllowance_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + token.approve(user2, 30 ether); + + vm.prank(user2); + vm.expectRevert(stQRLv2.InsufficientAllowance.selector); + token.transferFrom(user1, user2, 50 ether); + } + + function test_TransferFrom_UnlimitedAllowance() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // Approve unlimited + vm.prank(user1); + token.approve(user2, type(uint256).max); + + // Transfer + vm.prank(user2); + token.transferFrom(user1, user2, 50 ether); + + // Allowance should remain unlimited + assertEq(token.allowance(user1, user2), type(uint256).max); + } + + function test_TransferFrom_WhenPaused_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(user1); + token.approve(user2, 50 ether); + + token.pause(); + + vm.prank(user2); + vm.expectRevert(stQRLv2.ContractPaused.selector); + token.transferFrom(user1, user2, 50 ether); + } + + // ========================================================================= + // MINT/BURN ERROR TESTS + // ========================================================================= + + function test_MintShares_ToZeroAddress_Reverts() public { + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.mintShares(address(0), 100 ether); + } + + function test_MintShares_ZeroAmount_Reverts() public { + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.mintShares(user1, 0); + } + + function test_MintShares_WhenPaused_Reverts() public { + token.pause(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ContractPaused.selector); + token.mintShares(user1, 100 ether); + } + + function test_MintShares_EmitsEvents() public { + // Mint first (correct order) - pool is empty so 1:1 ratio + vm.prank(depositPool); + vm.expectEmit(true, false, false, true); + emit SharesMinted(user1, 100 ether, 100 ether); + vm.expectEmit(true, true, false, true); + emit Transfer(address(0), user1, 100 ether); + token.mintShares(user1, 100 ether); + } + + function test_BurnShares_FromZeroAddress_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.burnShares(address(0), 50 ether); + } + + function test_BurnShares_ZeroAmount_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ZeroAmount.selector); + token.burnShares(user1, 0); + } + + function test_BurnShares_InsufficientBalance_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.InsufficientBalance.selector); + token.burnShares(user1, 150 ether); + } + + function test_BurnShares_WhenPaused_Reverts() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + token.pause(); + + vm.prank(depositPool); + vm.expectRevert(stQRLv2.ContractPaused.selector); + token.burnShares(user1, 50 ether); + } + + function test_BurnShares_EmitsEvents() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + // At 1:1 rate, 50 shares = 50 QRL (with tiny virtual shares diff) + uint256 expectedQRL = token.getPooledQRLByShares(50 ether); + + vm.prank(depositPool); + vm.expectEmit(true, false, false, true); + emit SharesBurned(user1, 50 ether, expectedQRL); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, address(0), 50 ether); + token.burnShares(user1, 50 ether); + } + + function test_BurnShares_ReturnsCorrectQRLAmount() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + // Add 50% rewards + token.updateTotalPooledQRL(150 ether); + vm.stopPrank(); + + vm.prank(depositPool); + uint256 qrlAmount = token.burnShares(user1, 50 ether); + + // 50 shares at ~1.5 QRL/share ≈ 75 QRL (approx due to virtual shares) + assertApproxEqRel(qrlAmount, 75 ether, 1e14); + } + + // ========================================================================= + // ADMIN FUNCTION TESTS + // ========================================================================= + + function test_SetDepositPool_ZeroAddress_Reverts() public { + // Deploy fresh token without depositPool set + stQRLv2 freshToken = new stQRLv2(); + + vm.expectRevert(stQRLv2.ZeroAddress.selector); + freshToken.setDepositPool(address(0)); + } + + function test_TransferOwnership() public { + address newOwner = address(0x999); + + token.transferOwnership(newOwner); + + assertEq(token.owner(), newOwner); + } + + function test_TransferOwnership_ZeroAddress_Reverts() public { + vm.expectRevert(stQRLv2.ZeroAddress.selector); + token.transferOwnership(address(0)); + } + + function test_TransferOwnership_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.transferOwnership(user1); + } + + function test_TransferOwnership_EmitsEvent() public { + address newOwner = address(0x999); + + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, newOwner); + token.transferOwnership(newOwner); + } + + function test_RenounceOwnership() public { + token.renounceOwnership(); + + assertEq(token.owner(), address(0)); + } + + function test_RenounceOwnership_NotOwner_Reverts() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.renounceOwnership(); + } + + function test_RenounceOwnership_EmitsEvent() public { + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(owner, address(0)); + token.renounceOwnership(); + } + + function test_OnlyOwnerCanPause() public { + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.pause(); + } + + function test_OnlyOwnerCanUnpause() public { + token.pause(); + + vm.prank(user1); + vm.expectRevert(stQRLv2.NotOwner.selector); + token.unpause(); + } + + // ========================================================================= + // GETQRLVALUE TESTS + // ========================================================================= + + function test_GetQRLValue_ReturnsCorrectValue() public { + // Mint first, then update (correct order) + vm.startPrank(depositPool); + token.mintShares(user1, 100 ether); + token.updateTotalPooledQRL(100 ether); + vm.stopPrank(); + + assertApproxEqRel(token.getQRLValue(user1), 100 ether, 1e14); + + // Add rewards + vm.prank(depositPool); + token.updateTotalPooledQRL(150 ether); + + assertApproxEqRel(token.getQRLValue(user1), 150 ether, 1e14); + } + + function test_GetQRLValue_ZeroShares() public view { + assertEq(token.getQRLValue(user1), 0); + } + + // ========================================================================= + // EVENT DECLARATIONS + // ========================================================================= + + event Approval(address indexed owner, address indexed spender, uint256 value); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); }