diff --git a/src/monad/README.md b/src/monad/README.md new file mode 100644 index 0000000..20ff541 --- /dev/null +++ b/src/monad/README.md @@ -0,0 +1,38 @@ +# 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 hybrid scan: + +- Full scan `0..255` for validators returned by `getDelegations(...)`. +- Full scan `0..255` for Gem Wallet's curated validators: + - `16` MonadVision + - `5` Alchemy + - `10` Stakin + - `9` Everstake +- Shallow scan `0..7` for 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. + +## 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` + +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..a2561b4 100644 --- a/src/monad/StakingLens.sol +++ b/src/monad/StakingLens.sol @@ -11,8 +11,9 @@ contract StakingLens { uint16 public constant MAX_DELEGATIONS = 128; uint8 public constant MAX_WITHDRAW_IDS = 8; + uint16 public constant FULL_SCAN_WITHDRAW_IDS = 256; uint32 public constant ACTIVE_VALIDATOR_SET = 200; - uint256 public constant MAX_POSITIONS = uint256(MAX_DELEGATIONS) * (2 + MAX_WITHDRAW_IDS); + uint8 internal constant CURATED_VALIDATOR_COUNT = 4; uint256 public constant MONAD_SCALE = 1e18; uint256 public constant MONAD_BLOCK_REWARD = 25 ether; @@ -87,7 +88,11 @@ contract StakingLens { } function getDelegations(address delegator) external returns (Delegation[] memory positions) { - positions = new Delegation[](MAX_POSITIONS); + (uint64[] memory activeValidatorIds, uint256 activeValidatorCount) = _collectActiveValidatorIds(delegator); + (uint64[] memory fullScanValidatorIds, uint256 fullScanValidatorCount) = + _buildFullScanValidatorIds(activeValidatorIds, activeValidatorCount); + + positions = new Delegation[](_maxPositions(fullScanValidatorCount)); uint256 positionCount = 0; uint16 validatorCount = 0; uint64[] memory processedValidatorIds = new uint64[](uint256(MAX_DELEGATIONS)); @@ -95,6 +100,52 @@ contract StakingLens { (uint64 currentEpoch,) = STAKING.getEpoch(); + for ( + uint256 i = 0; + i < fullScanValidatorCount && validatorCount < MAX_DELEGATIONS && positionCount < positions.length; + ++i + ) { + uint64 validatorId = fullScanValidatorIds[i]; + if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { + continue; + } + + positionCount = _processValidator( + delegator, validatorId, currentEpoch, positions, positionCount, FULL_SCAN_WITHDRAW_IDS + ); + processedValidatorIds[processedValidatorCount] = validatorId; + ++processedValidatorCount; + ++validatorCount; + } + + 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 < positions.length; ++i) { + uint64 validatorId = allValidatorIds[i]; + if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { + continue; + } + + positionCount = + _processValidator(delegator, validatorId, currentEpoch, positions, positionCount, MAX_WITHDRAW_IDS); + processedValidatorIds[processedValidatorCount] = validatorId; + ++processedValidatorCount; + ++validatorCount; + } + } + + assembly { + mstore(positions, positionCount) + } + } + + function _collectActiveValidatorIds(address delegator) + internal + returns (uint64[] memory validatorIds, uint256 validatorCount) + { + validatorIds = new uint64[](uint256(MAX_DELEGATIONS)); + bool isDone; uint64 nextValId; uint64[] memory valIds; @@ -105,45 +156,62 @@ contract StakingLens { 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; + validatorCount = _appendUniqueValidatorId(validatorIds, validatorCount, valIds[i]); } - if (isDone || validatorCount == MAX_DELEGATIONS || positionCount == MAX_POSITIONS) { + if (isDone || validatorCount == MAX_DELEGATIONS) { break; } (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, nextValId); } + } - if (validatorCount < MAX_DELEGATIONS && positionCount < MAX_POSITIONS) { - uint64[] memory allValidatorIds = _allValidatorIds(); - uint256 len = allValidatorIds.length; - for (uint256 i = 0; i < len && validatorCount < MAX_DELEGATIONS && positionCount < MAX_POSITIONS; ++i) { - uint64 validatorId = allValidatorIds[i]; - if (_containsValidator(processedValidatorIds, processedValidatorCount, validatorId)) { - continue; - } + function _buildFullScanValidatorIds(uint64[] memory activeValidatorIds, uint256 activeValidatorCount) + internal + pure + returns (uint64[] memory validatorIds, uint256 validatorCount) + { + validatorIds = new uint64[](activeValidatorCount + CURATED_VALIDATOR_COUNT); - positionCount = _processValidator(delegator, validatorId, currentEpoch, positions, positionCount); - processedValidatorIds[processedValidatorCount] = validatorId; - ++processedValidatorCount; - ++validatorCount; - } + uint64[CURATED_VALIDATOR_COUNT] memory curatedValidatorIds = _curatedValidatorIds(); + for (uint256 i = 0; i < CURATED_VALIDATOR_COUNT; ++i) { + validatorCount = _appendUniqueValidatorId(validatorIds, validatorCount, curatedValidatorIds[i]); } - assembly { - mstore(positions, positionCount) + 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] = 16; + validatorIds[1] = 5; + validatorIds[2] = 10; + validatorIds[3] = 9; + } + + function _maxPositions(uint256 fullScanValidatorCount) internal pure returns (uint256) { + uint256 cappedFullScanValidatorCount = + fullScanValidatorCount > MAX_DELEGATIONS ? MAX_DELEGATIONS : fullScanValidatorCount; + uint256 shallowScanValidatorCount = uint256(MAX_DELEGATIONS) - cappedFullScanValidatorCount; + return uint256(MAX_DELEGATIONS) * 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 +231,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 +257,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, @@ -216,19 +285,24 @@ contract StakingLens { uint64 validatorId, uint64 currentEpoch, Delegation[] memory positions, - uint256 positionCount + uint256 positionCount, + uint16 maxWithdrawIds ) internal returns (uint256 newPositionCount, uint8 lastWithdrawId, bool hasWithdrawals) { 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) { + // casting is safe because the loop bounds only pass 8 or 256, so withdrawId is always <= type(uint8).max + // 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 +313,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..f608cb5 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; @@ -62,35 +66,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]; + + 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); + } + } - 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); + assertTrue(foundWithdraw); + assertTrue(foundRewards); } function test_getDelegationsIncludesWithdrawalsWhenActiveDelegationsExist() public { @@ -112,18 +127,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 +165,142 @@ 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_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 +365,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;