From 091fc0ad0d968befb672f6c9954e1f9a345b89c6 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:43:16 +0900 Subject: [PATCH] refine monad staking lens scan --- src/monad/README.md | 45 +++++ src/monad/StakingLens.sol | 274 ++++++++++++++++++++++------ test/monad/StakingLens.t.sol | 338 +++++++++++++++++++++++++++++++---- 3 files changed, 573 insertions(+), 84 deletions(-) create mode 100644 src/monad/README.md diff --git a/src/monad/README.md b/src/monad/README.md new file mode 100644 index 0000000..e3d25e9 --- /dev/null +++ b/src/monad/README.md @@ -0,0 +1,45 @@ +# Monad Staking Lens + +`StakingLens.sol` is a read-only helper around the Monad staking precompile. It is designed for Gem Wallet's RPC-only flow, so the main goal is to make `getDelegations(address)` useful without requiring an indexer or transaction history service. + +## Trade-off + +Monad exposes active delegations and validator lists, but withdrawals are only available through point lookups: + +- `getDelegations(delegator, startValId)` can enumerate validators with current delegation state. +- `getWithdrawalRequest(validatorId, delegator, withdrawId)` requires the caller to already know both the validator and the withdraw id. + +Because withdraw ids are scoped per `(validator, delegator)` and can use the full `0..255` range, a fully exact on-chain scan would mean checking up to 256 withdraw ids for every validator. That is too expensive for the default lens path. + +## Current policy + +`getDelegations(address)` uses a bounded hybrid scan: + +- Prioritize Gem Wallet's curated validators: + - `16` MonadVision + - `5` Alchemy + - `10` Stakin + - `9` Everstake +- Then prioritize validators returned by `getDelegations(...)`. +- Full scan `0..255` for up to `MAX_FULL_SCAN_VALIDATORS` prioritized validators. +- Shallow scan `0..7` for remaining active validators. +- Shallow scan `0..7` for up to `MAX_FALLBACK_SCAN_VALIDATORS` other validators discovered from the validator set fallback. + +Curated validators are processed first inside the full-scan set, so they are not squeezed out when the lens hits the `MAX_DELEGATIONS` cap. + +This keeps the common Gem Wallet path accurate while avoiding a worst-case `all validators x 256 withdraw ids` sweep on every call. + +`getBalance(address)` uses the same active, curated, and fallback validator sources, but processes active validators first because balance calculation has no withdrawal scan tier. That prevents curated discovery from crowding out active stake if the validator cap is ever reached. + +## Accepted blind spot + +The main case we still may miss is: + +- a user fully undelegated from an unknown validator +- the only remaining state is a withdrawal +- that withdrawal lives at `withdrawId > 7` +- the validator is outside the bounded fallback scan window + +The same can happen for an active non-curated validator after the full-scan validator cap is reached, but active stake and rewards are still returned because those do not depend on withdraw-id discovery. + +We accept that trade-off for now because this lens is optimized for our wallet and our supported validators. If we later need exact recovery for all unknown validators, we will need either a heavier RPC fallback or an off-chain indexer. diff --git a/src/monad/StakingLens.sol b/src/monad/StakingLens.sol index e66b1e1..04956ab 100644 --- a/src/monad/StakingLens.sol +++ b/src/monad/StakingLens.sol @@ -11,8 +11,18 @@ contract StakingLens { uint16 public constant MAX_DELEGATIONS = 128; uint8 public constant MAX_WITHDRAW_IDS = 8; + uint16 public constant FULL_SCAN_WITHDRAW_IDS = 256; + uint8 public constant MAX_FULL_SCAN_VALIDATORS = 8; + uint8 public constant MAX_FALLBACK_SCAN_VALIDATORS = 32; uint32 public constant ACTIVE_VALIDATOR_SET = 200; - uint256 public constant MAX_POSITIONS = uint256(MAX_DELEGATIONS) * (2 + MAX_WITHDRAW_IDS); + uint256 public constant MAX_POSITIONS = uint256(MAX_DELEGATIONS) * 2 + uint256(MAX_FULL_SCAN_VALIDATORS) + * uint256(FULL_SCAN_WITHDRAW_IDS) + (uint256(MAX_DELEGATIONS) - uint256(MAX_FULL_SCAN_VALIDATORS)) + * uint256(MAX_WITHDRAW_IDS); + uint8 internal constant CURATED_VALIDATOR_COUNT = 4; + uint64 internal constant MONADVISION_VALIDATOR_ID = 16; + uint64 internal constant ALCHEMY_VALIDATOR_ID = 5; + uint64 internal constant STAKIN_VALIDATOR_ID = 10; + uint64 internal constant EVERSTAKE_VALIDATOR_ID = 9; uint256 public constant MONAD_SCALE = 1e18; uint256 public constant MONAD_BLOCK_REWARD = 25 ether; @@ -60,34 +70,66 @@ contract StakingLens { } function getBalance(address delegator) external returns (uint256 staked, uint256 pending, uint256 rewards) { - bool isDone; - uint64 nextValId; - uint64[] memory valIds; + (uint64[] memory activeValidatorIds, uint256 activeValidatorCount) = _collectActiveValidatorIds(delegator); - (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, 0); + uint16 validatorCount = 0; + uint64[] memory processedValidatorIds = new uint64[](uint256(MAX_DELEGATIONS)); + uint256 processedValidatorCount = 0; - while (true) { - uint256 len = valIds.length; + // Balance has no withdrawal scan tier, so active stake wins over curated discovery if the cap is ever hit. + for (uint256 i = 0; i < activeValidatorCount && validatorCount < MAX_DELEGATIONS; ++i) { + uint64 validatorId = activeValidatorIds[i]; + (staked, pending, rewards) = _addDelegatorBalance(delegator, validatorId, staked, pending, rewards); + processedValidatorIds[processedValidatorCount] = validatorId; + ++processedValidatorCount; + ++validatorCount; + } - for (uint256 i = 0; i < len; ++i) { - (uint256 stake,, uint256 unclaimedRewards, uint256 deltaStake, uint256 nextDeltaStake,,) = - STAKING.getDelegator(valIds[i], delegator); + if (validatorCount < MAX_DELEGATIONS) { + uint64[CURATED_VALIDATOR_COUNT] memory curatedValidatorIds = _curatedValidatorIds(); + for (uint256 i = 0; i < CURATED_VALIDATOR_COUNT && validatorCount < MAX_DELEGATIONS; ++i) { + uint64 validatorId = curatedValidatorIds[i]; + if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { + continue; + } - staked += stake; - pending += deltaStake + nextDeltaStake; - rewards += unclaimedRewards; + (staked, pending, rewards) = _addDelegatorBalance(delegator, validatorId, staked, pending, rewards); + processedValidatorIds[processedValidatorCount] = validatorId; + ++processedValidatorCount; + ++validatorCount; } + } - if (isDone) { - break; - } + if (validatorCount < MAX_DELEGATIONS) { + uint64[] memory allValidatorIds = _allValidatorIds(); + uint256 len = allValidatorIds.length; + uint16 fallbackValidatorCount = 0; + for ( + uint256 i = 0; + i < len && validatorCount < MAX_DELEGATIONS && fallbackValidatorCount < MAX_FALLBACK_SCAN_VALIDATORS; + ++i + ) { + uint64 validatorId = allValidatorIds[i]; + if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { + continue; + } - (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, nextValId); + (staked, pending, rewards) = _addDelegatorBalance(delegator, validatorId, staked, pending, rewards); + processedValidatorIds[processedValidatorCount] = validatorId; + ++processedValidatorCount; + ++validatorCount; + ++fallbackValidatorCount; + } } } function getDelegations(address delegator) external returns (Delegation[] memory positions) { - positions = new Delegation[](MAX_POSITIONS); + (uint64[] memory activeValidatorIds, uint256 activeValidatorCount) = _collectActiveValidatorIds(delegator); + (uint64[] memory prioritizedValidatorIds, uint256 prioritizedValidatorCount) = + _buildPrioritizedValidatorIds(activeValidatorIds, activeValidatorCount); + uint256 fullScanValidatorCount = _fullScanValidatorCount(prioritizedValidatorCount); + + positions = new Delegation[](_maxPositions(prioritizedValidatorCount, fullScanValidatorCount)); uint256 positionCount = 0; uint16 validatorCount = 0; uint64[] memory processedValidatorIds = new uint64[](uint256(MAX_DELEGATIONS)); @@ -95,47 +137,62 @@ contract StakingLens { (uint64 currentEpoch,) = STAKING.getEpoch(); - bool isDone; - uint64 nextValId; - uint64[] memory valIds; - - (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, 0); - - while (true) { - uint256 len = valIds.length; - - for (uint256 i = 0; i < len && validatorCount < MAX_DELEGATIONS; ++i) { - uint64 validatorId = valIds[i]; - if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { - continue; - } - - positionCount = _processValidator(delegator, validatorId, currentEpoch, positions, positionCount); - processedValidatorIds[processedValidatorCount] = validatorId; - ++processedValidatorCount; - ++validatorCount; + for ( + uint256 i = 0; + i < fullScanValidatorCount && validatorCount < MAX_DELEGATIONS && positionCount < positions.length; + ++i + ) { + uint64 validatorId = prioritizedValidatorIds[i]; + if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { + continue; } - if (isDone || validatorCount == MAX_DELEGATIONS || positionCount == MAX_POSITIONS) { - break; + positionCount = _processValidator( + delegator, validatorId, currentEpoch, positions, positionCount, FULL_SCAN_WITHDRAW_IDS + ); + processedValidatorIds[processedValidatorCount] = validatorId; + ++processedValidatorCount; + ++validatorCount; + } + + for ( + uint256 i = fullScanValidatorCount; + i < prioritizedValidatorCount && validatorCount < MAX_DELEGATIONS && positionCount < positions.length; + ++i + ) { + uint64 validatorId = prioritizedValidatorIds[i]; + if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { + continue; } - (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, nextValId); + positionCount = + _processValidator(delegator, validatorId, currentEpoch, positions, positionCount, MAX_WITHDRAW_IDS); + processedValidatorIds[processedValidatorCount] = validatorId; + ++processedValidatorCount; + ++validatorCount; } - if (validatorCount < MAX_DELEGATIONS && positionCount < MAX_POSITIONS) { + if (validatorCount < MAX_DELEGATIONS && positionCount < positions.length) { uint64[] memory allValidatorIds = _allValidatorIds(); uint256 len = allValidatorIds.length; - for (uint256 i = 0; i < len && validatorCount < MAX_DELEGATIONS && positionCount < MAX_POSITIONS; ++i) { + uint16 fallbackValidatorCount = 0; + for ( + uint256 i = 0; + i < len && validatorCount < MAX_DELEGATIONS && positionCount < positions.length + && fallbackValidatorCount < MAX_FALLBACK_SCAN_VALIDATORS; + ++i + ) { uint64 validatorId = allValidatorIds[i]; if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { continue; } - positionCount = _processValidator(delegator, validatorId, currentEpoch, positions, positionCount); + positionCount = + _processValidator(delegator, validatorId, currentEpoch, positions, positionCount, MAX_WITHDRAW_IDS); processedValidatorIds[processedValidatorCount] = validatorId; ++processedValidatorCount; ++validatorCount; + ++fallbackValidatorCount; } } @@ -144,6 +201,101 @@ contract StakingLens { } } + function _collectActiveValidatorIds(address delegator) + internal + returns (uint64[] memory validatorIds, uint256 validatorCount) + { + validatorIds = new uint64[](uint256(MAX_DELEGATIONS)); + + bool isDone; + uint64 nextValId; + uint64[] memory valIds; + + (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, 0); + + while (true) { + uint256 len = valIds.length; + + for (uint256 i = 0; i < len && validatorCount < MAX_DELEGATIONS; ++i) { + validatorCount = _appendUniqueValidatorId(validatorIds, validatorCount, valIds[i]); + } + + if (isDone || validatorCount == MAX_DELEGATIONS) { + break; + } + + (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, nextValId); + } + } + + function _buildPrioritizedValidatorIds(uint64[] memory activeValidatorIds, uint256 activeValidatorCount) + internal + pure + returns (uint64[] memory validatorIds, uint256 validatorCount) + { + validatorIds = new uint64[](activeValidatorCount + CURATED_VALIDATOR_COUNT); + + uint64[CURATED_VALIDATOR_COUNT] memory curatedValidatorIds = _curatedValidatorIds(); + for (uint256 i = 0; i < CURATED_VALIDATOR_COUNT; ++i) { + validatorCount = _appendUniqueValidatorId(validatorIds, validatorCount, curatedValidatorIds[i]); + } + + for (uint256 i = 0; i < activeValidatorCount; ++i) { + validatorCount = _appendUniqueValidatorId(validatorIds, validatorCount, activeValidatorIds[i]); + } + } + + function _curatedValidatorIds() internal pure returns (uint64[CURATED_VALIDATOR_COUNT] memory validatorIds) { + validatorIds[0] = MONADVISION_VALIDATOR_ID; + validatorIds[1] = ALCHEMY_VALIDATOR_ID; + validatorIds[2] = STAKIN_VALIDATOR_ID; + validatorIds[3] = EVERSTAKE_VALIDATOR_ID; + } + + function _fullScanValidatorCount(uint256 prioritizedValidatorCount) internal pure returns (uint256) { + if (prioritizedValidatorCount < MAX_FULL_SCAN_VALIDATORS) { + return prioritizedValidatorCount; + } + + return MAX_FULL_SCAN_VALIDATORS; + } + + function _maxPositions(uint256 prioritizedValidatorCount, uint256 fullScanValidatorCount) + internal + pure + returns (uint256) + { + uint256 cappedPrioritizedValidatorCount = + prioritizedValidatorCount > MAX_DELEGATIONS ? MAX_DELEGATIONS : prioritizedValidatorCount; + uint256 cappedFullScanValidatorCount = fullScanValidatorCount > cappedPrioritizedValidatorCount + ? cappedPrioritizedValidatorCount + : fullScanValidatorCount; + uint256 fallbackValidatorCount = uint256(MAX_DELEGATIONS) - cappedPrioritizedValidatorCount; + if (fallbackValidatorCount > MAX_FALLBACK_SCAN_VALIDATORS) { + fallbackValidatorCount = MAX_FALLBACK_SCAN_VALIDATORS; + } + uint256 shallowScanValidatorCount = + cappedPrioritizedValidatorCount - cappedFullScanValidatorCount + fallbackValidatorCount; + uint256 totalValidatorCount = cappedPrioritizedValidatorCount + fallbackValidatorCount; + + // Two non-withdrawal slots per validator (Active, Activating), plus withdrawal slots per scan tier. + return totalValidatorCount * 2 + cappedFullScanValidatorCount * FULL_SCAN_WITHDRAW_IDS + + shallowScanValidatorCount * MAX_WITHDRAW_IDS; + } + + function _appendUniqueValidatorId(uint64[] memory validatorIds, uint256 count, uint64 validatorId) + internal + pure + returns (uint256 newCount) + { + if (_containsValidator(validatorIds, count, validatorId)) { + return count; + } + + validatorIds[count] = validatorId; + return count + 1; + } + function _containsValidator(uint64[] memory validatorIds, uint256 count, uint64 validatorId) internal pure @@ -163,19 +315,20 @@ contract StakingLens { uint64 validatorId, uint64 currentEpoch, Delegation[] memory positions, - uint256 positionCount + uint256 positionCount, + uint16 maxWithdrawIds ) internal returns (uint256 newPositionCount) { DelegatorSnapshot memory snap = _readDelegator(delegator, validatorId); uint8 lastWithdrawId; bool hasWithdrawals; (positionCount, lastWithdrawId, hasWithdrawals) = - _appendWithdrawals(delegator, validatorId, currentEpoch, positions, positionCount); + _appendWithdrawals(delegator, validatorId, currentEpoch, positions, positionCount, maxWithdrawIds); if (snap.stake == 0 && snap.pendingStake == 0 && snap.rewards == 0 && !hasWithdrawals) { return positionCount; } - if ((snap.stake > 0 || snap.rewards > 0) && positionCount < MAX_POSITIONS) { + if ((snap.stake > 0 || snap.rewards > 0) && positionCount < positions.length) { positions[positionCount] = Delegation({ validatorId: validatorId, withdrawId: lastWithdrawId, @@ -188,7 +341,7 @@ contract StakingLens { ++positionCount; } - if (snap.pendingStake > 0 && positionCount < MAX_POSITIONS) { + if (snap.pendingStake > 0 && positionCount < positions.length) { positions[positionCount] = Delegation({ validatorId: validatorId, withdrawId: lastWithdrawId, @@ -211,24 +364,41 @@ contract StakingLens { snap.pendingStake = deltaStake + nextDeltaStake; } + function _addDelegatorBalance( + address delegator, + uint64 validatorId, + uint256 currentStaked, + uint256 currentPending, + uint256 currentRewards + ) internal returns (uint256 staked, uint256 pending, uint256 rewards) { + DelegatorSnapshot memory snap = _readDelegator(delegator, validatorId); + return (currentStaked + snap.stake, currentPending + snap.pendingStake, currentRewards + snap.rewards); + } + function _appendWithdrawals( address delegator, uint64 validatorId, uint64 currentEpoch, Delegation[] memory positions, - uint256 positionCount + uint256 positionCount, + uint16 maxWithdrawIds ) internal returns (uint256 newPositionCount, uint8 lastWithdrawId, bool hasWithdrawals) { + assert(maxWithdrawIds <= uint16(type(uint8).max) + 1); uint256 count = positionCount; - for (uint8 withdrawId = 0; withdrawId < MAX_WITHDRAW_IDS && count < MAX_POSITIONS; ++withdrawId) { - (uint256 amount,, uint64 withdrawEpoch) = STAKING.getWithdrawalRequest(validatorId, delegator, withdrawId); + for (uint16 withdrawId = 0; withdrawId < maxWithdrawIds && count < positions.length; ++withdrawId) { + // The maxWithdrawIds assertion keeps withdrawId within the uint8 request-id range. + // forge-lint: disable-next-line(unsafe-typecast) + uint8 requestWithdrawId = uint8(withdrawId); + (uint256 amount,, uint64 withdrawEpoch) = + STAKING.getWithdrawalRequest(validatorId, delegator, requestWithdrawId); if (amount == 0) { continue; } positions[count] = Delegation({ validatorId: validatorId, - withdrawId: withdrawId, + withdrawId: requestWithdrawId, state: withdrawEpoch < currentEpoch ? DelegationState.AwaitingWithdrawal : DelegationState.Deactivating, amount: amount, rewards: 0, @@ -239,7 +409,7 @@ contract StakingLens { }); ++count; - lastWithdrawId = withdrawId; + lastWithdrawId = requestWithdrawId; hasWithdrawals = true; } diff --git a/test/monad/StakingLens.t.sol b/test/monad/StakingLens.t.sol index c9e20a6..6a23270 100644 --- a/test/monad/StakingLens.t.sol +++ b/test/monad/StakingLens.t.sol @@ -8,6 +8,10 @@ import {IStaking} from "../../src/monad/IStaking.sol"; contract StakingLensTest is Test { StakingLens private lens; address private constant STAKING_PRECOMPILE = address(0x0000000000000000000000000000000000001000); + uint64 private constant MONADVISION_VALIDATOR_ID = 16; + uint64 private constant ALCHEMY_VALIDATOR_ID = 5; + uint64 private constant STAKIN_VALIDATOR_ID = 10; + uint64 private constant EVERSTAKE_VALIDATOR_ID = 9; uint64[] private validatorIds; uint256 private constant TOTAL_STAKE = 1e30; @@ -46,6 +50,36 @@ contract StakingLensTest is Test { assertEq(validators[1].apyBps, expected); } + function test_getBalanceIncludesCuratedAndConsensusPositionsWithoutDoubleCountingActiveDelegations() public { + address delegator = address(0xc08A759F868Ab179F1259b2A7b1B81b0B968710E); + uint64 activeValidatorId = 42; + uint64 consensusRewardsValidatorId = 77; + uint256 activeStake = 5 ether; + uint256 pendingStake = 2 ether; + uint256 curatedRewardAmount = 1 ether; + uint256 consensusRewardAmount = 3 ether; + + uint64[] memory validators = new uint64[](2); + validators[0] = activeValidatorId; + validators[1] = consensusRewardsValidatorId; + _mockConsensusSet(validators); + + uint64[] memory activeDelegations = new uint64[](1); + activeDelegations[0] = activeValidatorId; + + _mockDelegations(delegator, activeDelegations); + _mockKnownValidators(delegator); + _mockDelegator(delegator, activeValidatorId, activeStake, 0, pendingStake, 0); + _mockDelegator(delegator, MONADVISION_VALIDATOR_ID, 0, curatedRewardAmount, 0, 0); + _mockDelegator(delegator, consensusRewardsValidatorId, 0, consensusRewardAmount, 0, 0); + + (uint256 staked, uint256 pending, uint256 rewards) = lens.getBalance(delegator); + + assertEq(staked, activeStake); + assertEq(pending, pendingStake); + assertEq(rewards, curatedRewardAmount + consensusRewardAmount); + } + function test_getDelegationsIncludesPositionsWithoutActiveDelegations() public { address delegator = address(0xc08A759F868Ab179F1259b2A7b1B81b0B968710E); uint64 withdrawValidatorId = 7; @@ -62,35 +96,46 @@ contract StakingLensTest is Test { _mockEpoch(currentEpoch); _mockDelegations(delegator, new uint64[](0)); + _mockKnownValidators(delegator); _mockDelegator(delegator, withdrawValidatorId, 0, 0, 0, 0); _mockDelegator(delegator, rewardsValidatorId, 0, rewardAmount, 0, 0); - _mockWithdrawalRequest(withdrawValidatorId, delegator, 0, withdrawAmount, withdrawEpoch); - for (uint8 withdrawId = 1; withdrawId < lens.MAX_WITHDRAW_IDS(); ++withdrawId) { - _mockWithdrawalRequest(withdrawValidatorId, delegator, withdrawId, 0, 0); - } - for (uint8 withdrawId = 0; withdrawId < lens.MAX_WITHDRAW_IDS(); ++withdrawId) { - _mockWithdrawalRequest(rewardsValidatorId, delegator, withdrawId, 0, 0); - } + _mockWithdrawalRequests( + withdrawValidatorId, delegator, 0, withdrawAmount, withdrawEpoch, lens.MAX_WITHDRAW_IDS() + ); StakingLens.Delegation[] memory positions = lens.getDelegations(delegator); assertEq(positions.length, 2); - assertEq(positions[0].validatorId, withdrawValidatorId); - assertEq(positions[0].withdrawId, 0); - assertEq(uint8(positions[0].state), uint8(StakingLens.DelegationState.Deactivating)); - assertEq(positions[0].amount, withdrawAmount); - assertEq(positions[0].rewards, 0); - assertEq(positions[0].withdrawEpoch, withdrawEpoch); - assertGt(positions[0].completionTimestamp, 0); + bool foundWithdraw; + bool foundRewards; + for (uint256 i = 0; i < positions.length; ++i) { + StakingLens.Delegation memory position = positions[i]; - assertEq(positions[1].validatorId, rewardsValidatorId); - assertEq(uint8(positions[1].state), uint8(StakingLens.DelegationState.Active)); - assertEq(positions[1].amount, 0); - assertEq(positions[1].rewards, rewardAmount); - assertEq(positions[1].withdrawEpoch, 0); - assertEq(positions[1].completionTimestamp, 0); + if ( + position.validatorId == withdrawValidatorId + && position.state == StakingLens.DelegationState.Deactivating + ) { + foundWithdraw = true; + assertEq(position.withdrawId, 0); + assertEq(position.amount, withdrawAmount); + assertEq(position.rewards, 0); + assertEq(position.withdrawEpoch, withdrawEpoch); + assertGt(position.completionTimestamp, 0); + } + + if (position.validatorId == rewardsValidatorId && position.state == StakingLens.DelegationState.Active) { + foundRewards = true; + assertEq(position.amount, 0); + assertEq(position.rewards, rewardAmount); + assertEq(position.withdrawEpoch, 0); + assertEq(position.completionTimestamp, 0); + } + } + + assertTrue(foundWithdraw); + assertTrue(foundRewards); } function test_getDelegationsIncludesWithdrawalsWhenActiveDelegationsExist() public { @@ -112,18 +157,13 @@ contract StakingLensTest is Test { _mockEpoch(currentEpoch); _mockDelegations(delegator, activeDelegations); + _mockKnownValidators(delegator); _mockDelegator(delegator, activeValidatorId, activeStake, 0, 0, 0); _mockDelegator(delegator, withdrawValidatorId, 0, 0, 0, 0); - for (uint8 withdrawId = 0; withdrawId < lens.MAX_WITHDRAW_IDS(); ++withdrawId) { - _mockWithdrawalRequest(activeValidatorId, delegator, withdrawId, 0, 0); - } - _mockWithdrawalRequest(withdrawValidatorId, delegator, 1, withdrawAmount, withdrawEpoch); - for (uint8 withdrawId = 0; withdrawId < lens.MAX_WITHDRAW_IDS(); ++withdrawId) { - if (withdrawId != 1) { - _mockWithdrawalRequest(withdrawValidatorId, delegator, withdrawId, 0, 0); - } - } + _mockWithdrawalRequests( + withdrawValidatorId, delegator, 1, withdrawAmount, withdrawEpoch, lens.MAX_WITHDRAW_IDS() + ); StakingLens.Delegation[] memory positions = lens.getDelegations(delegator); @@ -155,10 +195,196 @@ contract StakingLensTest is Test { assertTrue(foundWithdraw); } + function test_getDelegationsFullScansActiveValidatorsBeyondShallowScanRange() public { + address delegator = address(0xc08A759F868Ab179F1259b2A7b1B81b0B968710E); + uint64 activeValidatorId = 42; + uint64 currentEpoch = 3; + uint64 withdrawEpoch = 4; + uint8 highWithdrawId = 42; + uint256 activeStake = 5 ether; + uint256 withdrawAmount = 2 ether; + + uint64[] memory validators = new uint64[](1); + validators[0] = activeValidatorId; + _mockConsensusSet(validators); + + uint64[] memory activeDelegations = new uint64[](1); + activeDelegations[0] = activeValidatorId; + + _mockEpoch(currentEpoch); + _mockDelegations(delegator, activeDelegations); + _mockKnownValidators(delegator); + _mockDelegator(delegator, activeValidatorId, activeStake, 0, 0, 0); + _mockWithdrawalRequests( + activeValidatorId, delegator, highWithdrawId, withdrawAmount, withdrawEpoch, lens.FULL_SCAN_WITHDRAW_IDS() + ); + + StakingLens.Delegation[] memory positions = lens.getDelegations(delegator); + + assertEq(positions.length, 2); + + bool foundActive; + bool foundWithdraw; + for (uint256 i = 0; i < positions.length; ++i) { + StakingLens.Delegation memory position = positions[i]; + + if (position.validatorId == activeValidatorId && position.state == StakingLens.DelegationState.Active) { + foundActive = true; + assertEq(position.amount, activeStake); + } + + if ( + position.validatorId == activeValidatorId && position.state == StakingLens.DelegationState.Deactivating + && position.withdrawId == highWithdrawId + ) { + foundWithdraw = true; + assertEq(position.amount, withdrawAmount); + assertEq(position.withdrawEpoch, withdrawEpoch); + } + } + + assertTrue(foundActive); + assertTrue(foundWithdraw); + } + + function test_getDelegationsShallowScansActiveValidatorsPastFullScanCap() public { + address delegator = address(0xc08A759F868Ab179F1259b2A7b1B81b0B968710E); + uint64 currentEpoch = 3; + uint64 withdrawEpoch = 4; + uint8 shallowWithdrawId = 1; + uint8 highWithdrawId = 42; + uint256 activeStake = 5 ether; + uint256 withdrawAmount = 2 ether; + uint64[] memory activeDelegations = _sequentialValidatorIds(100, lens.MAX_FULL_SCAN_VALIDATORS()); + uint256 firstShallowActiveIndex = uint256(lens.MAX_FULL_SCAN_VALIDATORS()) - _curatedValidatorIds().length; + uint64 shallowValidatorId = activeDelegations[firstShallowActiveIndex]; + + _mockConsensusSet(activeDelegations); + _mockEpoch(currentEpoch); + _mockDelegations(delegator, activeDelegations); + _mockKnownValidators(delegator); + _mockEmptyValidators(delegator, activeDelegations, lens.FULL_SCAN_WITHDRAW_IDS()); + _mockDelegator(delegator, shallowValidatorId, activeStake, 0, 0, 0); + _mockWithdrawalRequest(shallowValidatorId, delegator, shallowWithdrawId, withdrawAmount, withdrawEpoch); + _mockWithdrawalRequest(shallowValidatorId, delegator, highWithdrawId, withdrawAmount, withdrawEpoch); + + StakingLens.Delegation[] memory positions = lens.getDelegations(delegator); + + bool foundActive; + bool foundShallowWithdraw; + bool foundHighWithdraw; + for (uint256 i = 0; i < positions.length; ++i) { + StakingLens.Delegation memory position = positions[i]; + + if (position.validatorId != shallowValidatorId) { + continue; + } + + if (position.state == StakingLens.DelegationState.Active) { + foundActive = true; + assertEq(position.amount, activeStake); + } + + if (position.state == StakingLens.DelegationState.Deactivating && position.withdrawId == shallowWithdrawId) + { + foundShallowWithdraw = true; + assertEq(position.amount, withdrawAmount); + } + + if (position.state == StakingLens.DelegationState.Deactivating && position.withdrawId == highWithdrawId) { + foundHighWithdraw = true; + } + } + + assertTrue(foundActive); + assertTrue(foundShallowWithdraw); + assertFalse(foundHighWithdraw); + } + + function test_getDelegationsFullScansCuratedValidatorsBeyondShallowScanRange() public { + address delegator = address(0xc08A759F868Ab179F1259b2A7b1B81b0B968710E); + uint64 currentEpoch = 3; + uint64 withdrawEpoch = 4; + uint8 highWithdrawId = 42; + uint256 withdrawAmount = 2 ether; + uint64[] memory curatedValidatorIds = _curatedValidatorIds(); + + _mockConsensusSet(curatedValidatorIds); + + _mockEpoch(currentEpoch); + _mockDelegations(delegator, new uint64[](0)); + _mockKnownValidators(delegator); + + for (uint256 i = 0; i < curatedValidatorIds.length; ++i) { + _mockWithdrawalRequests( + curatedValidatorIds[i], + delegator, + highWithdrawId, + withdrawAmount, + withdrawEpoch, + lens.FULL_SCAN_WITHDRAW_IDS() + ); + } + + StakingLens.Delegation[] memory positions = lens.getDelegations(delegator); + + assertEq(positions.length, curatedValidatorIds.length); + + for (uint256 i = 0; i < curatedValidatorIds.length; ++i) { + bool foundValidator; + + for (uint256 j = 0; j < positions.length; ++j) { + StakingLens.Delegation memory position = positions[j]; + if (position.validatorId != curatedValidatorIds[i]) { + continue; + } + + foundValidator = true; + assertEq(position.withdrawId, highWithdrawId); + assertEq(uint8(position.state), uint8(StakingLens.DelegationState.Deactivating)); + assertEq(position.amount, withdrawAmount); + assertEq(position.withdrawEpoch, withdrawEpoch); + break; + } + + assertTrue(foundValidator); + } + } + + function test_getDelegationsPrioritizesCuratedValidatorsBeforeActiveDelegationCap() public { + address delegator = address(0xc08A759F868Ab179F1259b2A7b1B81b0B968710E); + uint64 currentEpoch = 3; + uint64 withdrawEpoch = 4; + uint8 highWithdrawId = 42; + uint256 withdrawAmount = 2 ether; + uint64[] memory activeDelegations = _sequentialValidatorIds(100, lens.MAX_DELEGATIONS()); + + _mockConsensusSet(activeDelegations); + _mockEpoch(currentEpoch); + _mockDelegations(delegator, activeDelegations); + _mockKnownValidators(delegator); + _mockEmptyValidators(delegator, activeDelegations, lens.FULL_SCAN_WITHDRAW_IDS()); + _mockWithdrawalRequests( + EVERSTAKE_VALIDATOR_ID, + delegator, + highWithdrawId, + withdrawAmount, + withdrawEpoch, + lens.FULL_SCAN_WITHDRAW_IDS() + ); + + StakingLens.Delegation[] memory positions = lens.getDelegations(delegator); + + assertEq(positions.length, 1); + assertEq(positions[0].validatorId, EVERSTAKE_VALIDATOR_ID); + assertEq(positions[0].withdrawId, highWithdrawId); + assertEq(uint8(positions[0].state), uint8(StakingLens.DelegationState.Deactivating)); + assertEq(positions[0].amount, withdrawAmount); + assertEq(positions[0].withdrawEpoch, withdrawEpoch); + } + function _mockConsensusSet() internal { - bytes memory data = abi.encodeCall(IStaking.getConsensusValidatorSet, (0)); - bytes memory result = abi.encode(true, uint32(0), validatorIds); - vm.mockCall(STAKING_PRECOMPILE, data, result); + _mockConsensusSet(validatorIds); } function _mockConsensusSet(uint64[] memory ids) internal { @@ -223,6 +449,54 @@ contract StakingLensTest is Test { vm.mockCall(STAKING_PRECOMPILE, data, result); } + function _mockWithdrawalRequests( + uint64 validatorId, + address delegator, + uint8 nonZeroWithdrawId, + uint256 amount, + uint64 withdrawEpoch, + uint16 scanLimit + ) internal { + for (uint16 withdrawId = 0; withdrawId < scanLimit; ++withdrawId) { + uint256 requestAmount = withdrawId == nonZeroWithdrawId ? amount : 0; + uint64 requestEpoch = withdrawId == nonZeroWithdrawId ? withdrawEpoch : 0; + // casting is safe because the test only passes scan limits within the uint8 withdraw id range + // forge-lint: disable-next-line(unsafe-typecast) + _mockWithdrawalRequest(validatorId, delegator, uint8(withdrawId), requestAmount, requestEpoch); + } + } + + function _mockKnownValidators(address delegator) internal { + _mockEmptyValidators(delegator, _curatedValidatorIds(), lens.FULL_SCAN_WITHDRAW_IDS()); + } + + function _mockEmptyValidators(address delegator, uint64[] memory validatorIdsToMock, uint16 scanLimit) internal { + for (uint256 i = 0; i < validatorIdsToMock.length; ++i) { + _mockDelegator(delegator, validatorIdsToMock[i], 0, 0, 0, 0); + _mockWithdrawalRequests(validatorIdsToMock[i], delegator, 0, 0, 0, scanLimit); + } + } + + function _curatedValidatorIds() internal pure returns (uint64[] memory ids) { + ids = new uint64[](4); + ids[0] = MONADVISION_VALIDATOR_ID; + ids[1] = ALCHEMY_VALIDATOR_ID; + ids[2] = STAKIN_VALIDATOR_ID; + ids[3] = EVERSTAKE_VALIDATOR_ID; + } + + function _sequentialValidatorIds(uint64 startValidatorId, uint16 count) + internal + pure + returns (uint64[] memory ids) + { + ids = new uint64[](uint256(count)); + + for (uint16 i = 0; i < count; ++i) { + ids[i] = startValidatorId + uint64(i); + } + } + function _expectedNetworkApy() internal view returns (uint64) { uint256 annualRewards = lens.MONAD_BLOCK_REWARD() * lens.MONAD_BLOCKS_PER_YEAR(); uint256 apy = (annualRewards * lens.APY_BPS_PRECISION()) / TOTAL_STAKE;