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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ QuantaPool/
│ │ └── ValidatorManager.sol # Validator lifecycle tracking
│ ├── hyperion/ # Auto-synced Hyperion mirrors (.hyp)
│ │ └── README.md # Dialect rules and hypc workflow
│ └── test/ # Foundry test suite (178 tests)
│ └── test/ # Foundry test suite (200 tests)
│ ├── stQRL-v2.t.sol # 55 core token tests
│ ├── DepositPool-v2.t.sol # 68 deposit/withdrawal tests
│ ├── DepositPool-v2.t.sol # 90 deposit/withdrawal tests
│ ├── ValidatorManager.t.sol # 55 validator lifecycle tests
│ └── hyperion/ # Generated .t.hyp mirrors (reference only)
├── build/hyperion/ # hypc output (ABI, bin, manifest.json) — gitignored
Expand All @@ -95,7 +95,7 @@ QuantaPool/
| 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 |
| `DepositPool-v2.sol` | 885 | User entry point, deposits/withdrawals, trustless reward sync |
| `ValidatorManager.sol` | 349 | Validator lifecycle: Pending → Active → Exiting → Exited |

All on-chain code lives under `contracts/`. Solidity sources in `contracts/solidity/` are the canonical editing target; Hyperion mirrors in `contracts/hyperion/` are generated from them (never hand-edit). Foundry tests live in `contracts/test/` with a parallel `contracts/test/hyperion/` tree of reference `.t.hyp` mirrors. Compiled Hyperion artifacts land in `build/hyperion/` (gitignored).
Expand Down Expand Up @@ -178,7 +178,7 @@ GitHub Actions runs `forge fmt --check`, `forge build --sizes`, and `forge test

## Test Coverage

- **178 tests passing** (55 stQRL-v2 + 68 DepositPool-v2 + 55 ValidatorManager)
- **200 tests passing** (55 stQRL-v2 + 90 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)
Expand Down
148 changes: 125 additions & 23 deletions contracts/hyperion/DepositPool-v2.hyp
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,21 @@ pragma hyperion >=0.0;
* the difference to rewards (positive) or slashing (negative).
*
* Balance Accounting:
* contractBalance = totalPooledQRL + withdrawalReserve
* contractBalance + stakedQRL = totalPooledQRL + withdrawalReserve
*
* - totalPooledQRL: All QRL under pool management (buffered + rewards)
* - totalPooledQRL: All QRL under pool management (buffered + staked + rewards)
* This is what stQRL token tracks. Includes buffered deposits waiting
* to fund validators, plus any rewards that arrive via EIP-4895.
* to fund validators, principal staked off-contract, plus any rewards
* that arrive via EIP-4895.
* - stakedQRL: principal forwarded to the beacon deposit contract by the
* real fundValidator() path. It lives off-contract but is still pooled,
* so _syncRewards() adds it back when reconciling the balance.
* - 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.
* For MVP (testnet), fundValidatorMVP keeps QRL in this contract and stakedQRL
* stays zero. For production, fundValidator sends QRL to the beacon deposit
* contract (incrementing stakedQRL); it returns when validators exit, at which
* point the owner calls recordValidatorExit() to settle the accounting.
*/

interface IstQRL {
Expand Down Expand Up @@ -153,6 +158,17 @@ contract DepositPoolV2 {
/// @notice Total slashing losses (cumulative, for stats)
uint256 public totalSlashingLosses;

/// @notice QRL principal forwarded to the beacon deposit contract that is
/// staked off-contract (not in address(this).balance) but still under
/// protocol management.
/// @dev Only the real beacon path (fundValidator) moves QRL off-contract,
/// so only it increments this. fundValidatorMVP keeps QRL in the
/// contract and leaves stakedQRL untouched. _syncRewards() adds this
/// back when reconciling balance against totalPooledQRL, otherwise
/// funding a validator would look like a slashing event. The owner
/// decrements it via recordValidatorExit() when exit proceeds return.
uint256 public stakedQRL;

// =============================================================
// EVENTS
// =============================================================
Expand All @@ -169,6 +185,8 @@ contract DepositPoolV2 {

event ValidatorFunded(uint256 indexed validatorId, bytes pubkey, uint256 amount);

event ValidatorExitRecorded(uint256 amount, uint256 remainingStaked);

event WithdrawalReserveFunded(uint256 amount);
event WithdrawalCancelled(address indexed user, uint256 indexed requestId, uint256 shares);
event MinDepositUpdated(uint256 newMinDeposit);
Expand Down Expand Up @@ -205,6 +223,7 @@ contract DepositPoolV2 {
error StQRLAlreadySet();
error InvalidWithdrawalIndex();
error ExceedsRecoverableAmount();
error ExceedsStakedAmount();

// =============================================================
// MODIFIERS
Expand Down Expand Up @@ -304,8 +323,14 @@ contract DepositPoolV2 {
uint256 unlockedShares = stQRL.sharesOf(msg.sender) - stQRL.lockedSharesOf(msg.sender);
if (unlockedShares < shares) revert InsufficientShares();

// Sync rewards first
_syncRewards();
// Refresh the rate before snapshotting the withdrawal value — but only
// while permissionless sync is safe. With principal staked off-contract
// the rate is owner-controlled, and triggering a sync here would let a
// requester front-run validator-exit settlement and snapshot an inflated
// value (see {_permissionlessSyncAllowed}); use the last synced rate.
if (_permissionlessSyncAllowed()) {
_syncRewards();
}

// Calculate current QRL value
qrlAmount = stQRL.getPooledQRLByShares(shares);
Expand Down Expand Up @@ -353,8 +378,13 @@ contract DepositPoolV2 {
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();
// Refresh pooled accounting, but only while permissionless sync is safe.
// The payout uses the request-time snapshot (request.qrlAmount) either
// way; this keeps a claim from triggering a sync inside the validator-exit
// settlement window (see {_permissionlessSyncAllowed}).
if (_permissionlessSyncAllowed()) {
_syncRewards();
}

// Cache shares before state changes
uint256 sharesToBurn = request.shares;
Expand Down Expand Up @@ -472,36 +502,74 @@ contract DepositPoolV2 {

/**
* @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.
* @dev Trustless and permissionless while all principal is on-contract
* (stakedQRL == 0). While validator principal is staked off-contract
* (stakedQRL > 0) this is restricted to the owner — see
* {_permissionlessSyncAllowed} for why.
*/
function syncRewards() external nonReentrant {
if (!_permissionlessSyncAllowed() && msg.sender != owner) revert NotOwner();
_syncRewards();
}

/**
* @notice Whether reward sync may be triggered permissionlessly right now.
* @dev Sync infers rewards/slashing from balance deltas. That inference is
* only unambiguous while every QRL of principal sits in this contract
* (stakedQRL == 0): then `balance - withdrawalReserve` is exactly the
* pooled total.
*
* Once a real fundValidator() forwards principal to the beacon deposit
* contract (stakedQRL > 0), an exit sweep returning that principal via
* EIP-4895 lands in `address(this).balance` automatically, a block
* before the owner can call recordValidatorExit() to settle it. In
* that window `balance + stakedQRL` double-counts the principal, so a
* sync would book it as a large phantom reward and spike the exchange
* rate. A permissionless caller could front-run the settlement, lock
* that inflated rate into a withdrawal request (whose QRL value is
* snapshotted on request), and drain the pool when the rate corrects.
*
* So while principal is off-contract the exchange rate only moves
* under owner control: the operator sequences recordValidatorExit()
* and syncRewards() so settlement and reward recognition cannot be
* front-run. The MVP path (fundValidatorMVP keeps QRL in-contract,
* stakedQRL == 0) is unaffected and stays fully permissionless.
*/
function _permissionlessSyncAllowed() internal view returns (bool) {
return stakedQRL == 0;
}

/**
* @dev Internal reward sync logic
*
* Balance accounting:
* The contract holds: bufferedQRL + rewards/staked QRL + withdrawalReserve
* On-contract QRL = bufferedQRL + rewards + withdrawalReserve
* Off-contract QRL = stakedQRL (principal sent to the beacon deposit
* contract via fundValidator; still under protocol management)
* withdrawalReserve is earmarked for pending withdrawals (not pooled)
* actualTotalPooled = balance - withdrawalReserve
* actualTotalPooled = balance + stakedQRL - 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.
* Note: For MVP (fundValidatorMVP), staked QRL stays in the contract and
* stakedQRL is zero, so this reduces to balance - withdrawalReserve.
* For production (fundValidator), staked QRL leaves for the beacon deposit
* contract; adding stakedQRL back keeps the funding from registering as a
* slashing event. When exit proceeds return via EIP-4895, the owner calls
* recordValidatorExit() to move that principal back from stakedQRL into the
* on-contract balance so it is not double-counted as rewards.
*/
function _syncRewards() internal {
if (address(stQRL) == address(0)) return;

uint256 currentBalance = address(this).balance;
// Include off-contract staked principal so beacon deposits are not
// mistaken for slashing. bufferedQRL + rewards live in balance;
// stakedQRL lives at the beacon deposit contract.
uint256 currentBalance = address(this).balance + stakedQRL;

// Total pooled = everything except withdrawal reserve
// This includes: bufferedQRL + any rewards that arrived via EIP-4895
// This includes: bufferedQRL + stakedQRL + any rewards via EIP-4895
uint256 actualTotalPooled;
if (currentBalance > withdrawalReserve) {
actualTotalPooled = currentBalance - withdrawalReserve;
Expand Down Expand Up @@ -541,7 +609,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 + 11 zero bytes + address)
* @param withdrawal_credentials Must point to this contract (0x00 + 11 zero bytes + address)
* @param signature ML-DSA-87 signature (4627 bytes)
* @param deposit_data_root SSZ hash of deposit data
* @return validatorId The new validator's ID
Expand Down Expand Up @@ -574,6 +642,10 @@ contract DepositPoolV2 {
if (actualCredentials != expectedCredentials) revert InvalidWithdrawalCredentials();

bufferedQRL -= VALIDATOR_STAKE;
// Principal leaves the contract for the beacon deposit contract but
// stays under protocol management. Track it so _syncRewards() does not
// read the outgoing transfer as a slashing loss.
stakedQRL += VALIDATOR_STAKE;
validatorId = validatorCount++;

// Call beacon deposit contract
Expand Down Expand Up @@ -601,6 +673,32 @@ contract DepositPoolV2 {
return validatorId;
}

/**
* @notice Record that staked principal has returned from the beacon chain
* @dev Validator exit proceeds (principal) arrive via EIP-4895 and land in
* address(this).balance. Without this call, the next _syncRewards()
* would see both the higher balance AND the still-elevated stakedQRL,
* double-counting the principal as fresh rewards. The owner calls this
* to move `amount` from the off-contract stakedQRL accumulator back
* into on-contract accounting, leaving totalPooledQRL unchanged.
*
* Any beacon rewards earned on top of the principal are NOT recorded
* here — they remain on the balance and are correctly attributed as
* rewards by the next _syncRewards().
*
* MVP note: fundValidatorMVP never increments stakedQRL, so this is
* only relevant once the real beacon path (fundValidator) is in use.
* @param amount Principal returned from the beacon chain (<= stakedQRL)
*/
function recordValidatorExit(uint256 amount) external onlyOwner {
if (amount == 0) revert ZeroAmount();
if (amount > stakedQRL) revert ExceedsStakedAmount();

stakedQRL -= amount;

emit ValidatorExitRecorded(amount, stakedQRL);
}

/**
* @notice Move QRL from pooled accounting to withdrawal reserve
* @dev Called by owner to earmark pooled QRL for pending withdrawals.
Expand Down Expand Up @@ -751,8 +849,12 @@ contract DepositPoolV2 {
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;
// Calculate recoverable amount: balance - on-contract pooled funds - withdrawal reserve.
// totalPooledQRL includes stakedQRL, which lives off-contract at the beacon
// deposit contract, so it must be excluded when comparing against this balance.
uint256 pooled = address(stQRL) != address(0) ? stQRL.totalPooledQRL() : 0;
uint256 onContractPooled = pooled > stakedQRL ? pooled - stakedQRL : 0;
uint256 totalProtocolFunds = onContractPooled + withdrawalReserve;
uint256 currentBalance = address(this).balance;
uint256 recoverableAmount = currentBalance > totalProtocolFunds ? currentBalance - totalProtocolFunds : 0;

Expand Down
Loading