diff --git a/.env.example b/.env.example index 1cfa4b2..48b4560 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ BSC_RPC_URL= AVALANCHE_RPC_URL= POLYGON_RPC_URL= ARBITRUM_RPC_URL= +MONAD_RPC_URL= # Etherscan API Keys ETHEREUM_SCAN_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bfa379..cf620c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,11 @@ jobs: with: version: nightly + - name: Forge lint + run: | + forge lint + id: lint + - name: Forge build run: | forge --version diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ed07e64 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Core Solidity modules live in `src/`, split into `src/hub_reader` and `src/stargate` for BSC staking and cross-chain calls. +- Automation scripts reside in `script/` (Foundry) and `deploy/` (bash helpers like `deploy-stargate.sh`); compiled artifacts land in `out/`. +- Tests live in `test/` using `.t.sol` suffixes; dependencies stay in `lib/`; align configuration via root-level `foundry.toml` and `.env.example`. + +## Build, Test, and Development Commands +- `forge build` or `just build` compiles the workspace with default remappings. +- `forge test` and `forge test --rpc-url $BSC_RPC_URL` execute the suite locally or against a fork. +- `forge lint` then `forge fmt` keep Solidity style consistent; run them before sharing branches. +- `just deploy-hub-reader` and `just deploy-stargate optimism` broadcast deployments through the prewired RPCs. + +## Coding Style & Naming Conventions +- Use 4-space indentation, `pragma solidity ^0.8.x`, sorted imports, and SPDX identifiers. +- Name contracts, libraries, and interfaces in PascalCase; state variables in camelCase; constants in ALL_CAPS. +- Match test filenames to their targets (`StargateFeeReceiver.t.sol`) and prefix helper contracts with `Test`. +- Validate formatting with `forge fmt` or `forge fmt --check` before review. + +## Testing Guidelines +- Keep integration scenarios in dedicated contracts and isolate unit fixtures per module. +- Leverage `vm.expectRevert`, `vm.prank`, and explicit `assertEq` messages to clarify intent. +- When forking, pass the RPC with `--rpc-url` and note chain assumptions in header comments. +- Prioritize coverage of deposit, withdrawal, and fee flows; `forge coverage --report lcov` helps quantify readiness. + +## Commit & Pull Request Guidelines +- Follow the short imperative style seen in history (`add auto formatter`, `rename to StargateFeeReceiver`), keeping summaries under 65 characters. +- Reference tickets, flag deployment or configuration impacts, and list the tests you ran. +- For PRs, link on-chain transactions, attach explorer URLs or calldata, and note any environment variable or RPC updates. + +## Security & Configuration Tips +- Copy `.env.example` to `.env`, add RPC URLs and scan keys matching `foundry.toml`, and keep secrets untracked. +- Limit raw private keys to deployment contexts; favor hardware signing for `forge script --broadcast`. +- Before merging, confirm remappings, target chain IDs, and contract addresses to avoid cross-chain leaks. diff --git a/README.md b/README.md index 6483d13..ccea745 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,27 @@ # Smart Contracts -A collection of smart contracts for Gem Wallet. +Gem Wallet deployment helpers and read lenses. -- [src/hub_reader](src/hub_reader): A contract that simplify interacting with BSC Staking Hub -- [src/stargate](src/stargate): A contract that allow to do onchain calls on destination chain after Stargate Bridge +- `src/hub_reader`: BSC staking hub reader. +- `src/stargate`: post-bridge call handler for Stargate V2. +- `src/monad`: staking lens for Monad (precompile reader). ## Development -1. Install [Foundry](https://book.getfoundry.sh/) and you're good to go. -2. Configure `.env` using `.env.example` rpcs (if needed) and etherscan values, if you need to deploy the contract, you need to set `PRIVATE_KEY` as well. +1) Install [Foundry](https://book.getfoundry.sh/). +2) Copy `.env.example` to `.env` and fill RPCs (including `MONAD_RPC_URL`), scan keys, and `PRIVATE_KEY` for deploys. -## Usage +## Common Tasks -### Build +- Build: `forge build` +- Lint/format: `forge lint && forge fmt` +- Test: `forge test` (HubReader tests expect a live BSC RPC; the Monad lens tests are mocked) -```shell -forge build -``` - -### Test - -```shell -forge test --rpc-url -``` - -### Deploy - -```shell -# deploy hub_reader -just deploy-hub-reader -``` - -```shell -# deploy stargate to all supported chains -just deploy-stargate -``` - -```shell -# deploy stargate to specific chain -just deploy-stargate optimism -``` +## Deploy +- Hub Reader (BSC): `just deploy-hub-reader` +- Stargate fee receiver: `just deploy-stargate optimism` (or another supported chain) +- Monad staking lens: `just deploy-monad-staking` diff --git a/foundry.toml b/foundry.toml index 7e3b9b4..afa4140 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,7 @@ src = "src" out = "out" libs = ["lib"] +via_ir = true [rpc_endpoints] ethereum = "${ETHEREUM_RPC_URL}" @@ -11,6 +12,7 @@ bsc = "${BSC_RPC_URL}" avalanche = "${AVALANCHE_RPC_URL}" polygon = "${POLYGON_RPC_URL}" arbitrum = "${ARBITRUM_RPC_URL}" +monad = "${MONAD_RPC_URL}" [etherscan] ethereum = { key = "${ETHEREUM_SCAN_API_KEY}" } diff --git a/justfile b/justfile index aa400bd..81c0f3a 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,7 @@ set dotenv-load := true +list: + just --list build: forge build @@ -7,8 +9,14 @@ build: test: forge test +test-monad: + forge test --match-path test/monad/* + deploy-stargate CHAIN_NAME: bash ./deploy/deploy-stargate.sh {{CHAIN_NAME}} deploy-hub-reader: forge script script/hub_reader/HubReader.s.sol:HubReaderScript --rpc-url "$BSC_RPC_URL" --broadcast --verify -vvvv + +deploy-monad-staking: + forge script --force script/monad/StakingLens.s.sol:StakingLensScript --rpc-url "$MONAD_RPC_URL" --broadcast -vvvv diff --git a/script/hub_reader/HubReader.s.sol b/script/hub_reader/HubReader.s.sol index 06de822..6fb075c 100644 --- a/script/hub_reader/HubReader.s.sol +++ b/script/hub_reader/HubReader.s.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.13; import {Script, console} from "forge-std/Script.sol"; -import "../../src/hub_reader/HubReader.sol"; +import {HubReader} from "../../src/hub_reader/HubReader.sol"; contract HubReaderScript is Script { function run() public { diff --git a/script/monad/StakingLens.s.sol b/script/monad/StakingLens.s.sol new file mode 100644 index 0000000..75a68fc --- /dev/null +++ b/script/monad/StakingLens.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Script, console} from "forge-std/Script.sol"; +import {StakingLens} from "../../src/monad/StakingLens.sol"; + +contract StakingLensScript is Script { + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + StakingLens lens = new StakingLens(); + console.log("StakingLens deployed to:", address(lens)); + vm.stopBroadcast(); + } +} diff --git a/script/stargate/GemStargateDeployer.s.sol b/script/stargate/GemStargateDeployer.s.sol index 2d8717b..a9430ae 100644 --- a/script/stargate/GemStargateDeployer.s.sol +++ b/script/stargate/GemStargateDeployer.s.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.13; import {Script, console} from "forge-std/Script.sol"; -import "../../src/stargate/StargateFeeReceiver.sol"; +import {StargateFeeReceiver} from "../../src/stargate/StargateFeeReceiver.sol"; contract GemStargateDeployerScript is Script { struct NetworkConfig { @@ -31,7 +31,7 @@ contract GemStargateDeployerScript is Script { // Get values from environment address endpoint = vm.envAddress(endpointVar); - return NetworkConfig(endpoint); + return NetworkConfig({endpoint: endpoint}); } function run() public { diff --git a/src/hub_reader/HubReader.sol b/src/hub_reader/HubReader.sol index a99b6ec..dc027eb 100644 --- a/src/hub_reader/HubReader.sol +++ b/src/hub_reader/HubReader.sol @@ -43,32 +43,18 @@ contract HubReader { * * @return The validators */ - function getValidators( - uint16 offset, - uint16 limit - ) external view returns (Validator[] memory) { - (address[] memory operatorAddrs, , uint256 totalLength) = stakeHub - .getValidators(offset, limit); + function getValidators(uint16 offset, uint16 limit) external view returns (Validator[] memory) { + (address[] memory operatorAddrs,, uint256 totalLength) = stakeHub.getValidators(offset, limit); uint256 validatorCount = totalLength < limit ? totalLength : limit; Validator[] memory validators = new Validator[](validatorCount); for (uint256 i = 0; i < validatorCount; i++) { - (, bool jailed, ) = stakeHub.getValidatorBasicInfo( - operatorAddrs[i] - ); - string memory moniker = stakeHub - .getValidatorDescription(operatorAddrs[i]) - .moniker; - uint64 rate = stakeHub - .getValidatorCommission(operatorAddrs[i]) - .rate; + (, bool jailed,) = stakeHub.getValidatorBasicInfo(operatorAddrs[i]); + string memory moniker = stakeHub.getValidatorDescription(operatorAddrs[i]).moniker; + uint64 rate = stakeHub.getValidatorCommission(operatorAddrs[i]).rate; validators[i] = Validator({ - operatorAddress: operatorAddrs[i], - moniker: moniker, - commission: rate, - jailed: jailed, - apy: 0 + operatorAddress: operatorAddrs[i], moniker: moniker, commission: rate, jailed: jailed, apy: 0 }); } uint64[] memory apys = this.getAPYs(operatorAddrs, block.timestamp); @@ -86,16 +72,13 @@ contract HubReader { * * @return The delegations of the delegator */ - function getDelegations( - address delegator, - uint16 offset, - uint16 limit - ) external view returns (Delegation[] memory) { - ( - address[] memory operatorAddrs, - address[] memory creditAddrs, - uint256 totalLength - ) = stakeHub.getValidators(offset, limit); + function getDelegations(address delegator, uint16 offset, uint16 limit) + external + view + returns (Delegation[] memory) + { + (address[] memory operatorAddrs, address[] memory creditAddrs, uint256 totalLength) = + stakeHub.getValidators(offset, limit); uint256 validatorCount = totalLength < limit ? totalLength : limit; uint256 delegationCount = 0; Delegation[] memory delegations = new Delegation[](validatorCount); @@ -107,10 +90,7 @@ contract HubReader { if (amount > 0) { delegations[delegationCount] = Delegation({ - delegatorAddress: delegator, - validatorAddress: operatorAddrs[i], - shares: shares, - amount: amount + delegatorAddress: delegator, validatorAddress: operatorAddrs[i], shares: shares, amount: amount }); delegationCount++; } @@ -131,38 +111,29 @@ contract HubReader { * * @return The undelegations of the delegator */ - function getUndelegations( - address delegator, - uint16 offset, - uint16 limit - ) external view returns (Undelegation[] memory) { - ( - address[] memory operatorAddrs, - address[] memory creditAddrs, - uint256 totalLength - ) = stakeHub.getValidators(offset, limit); + function getUndelegations(address delegator, uint16 offset, uint16 limit) + external + view + returns (Undelegation[] memory) + { + (address[] memory operatorAddrs, address[] memory creditAddrs, uint256 totalLength) = + stakeHub.getValidators(offset, limit); uint256 validatorCount = totalLength < limit ? totalLength : limit; // first loop to get the number of unbond requests uint256 undelegationCount = 0; for (uint256 i = 0; i < validatorCount; i++) { - undelegationCount += IStakeCredit(creditAddrs[i]) - .pendingUnbondRequest(delegator); + undelegationCount += IStakeCredit(creditAddrs[i]).pendingUnbondRequest(delegator); } - Undelegation[] memory undelegations = new Undelegation[]( - undelegationCount - ); + Undelegation[] memory undelegations = new Undelegation[](undelegationCount); // resuse same local variable undelegationCount = 0; for (uint256 i = 0; i < validatorCount; i++) { - uint256 unbondCount = IStakeCredit(creditAddrs[i]) - .pendingUnbondRequest(delegator); + uint256 unbondCount = IStakeCredit(creditAddrs[i]).pendingUnbondRequest(delegator); for (uint256 j = 0; j < unbondCount; j++) { - IStakeCredit.UnbondRequest memory req = IStakeCredit( - creditAddrs[i] - ).unbondRequest(delegator, j); + IStakeCredit.UnbondRequest memory req = IStakeCredit(creditAddrs[i]).unbondRequest(delegator, j); undelegations[undelegationCount] = Undelegation({ delegatorAddress: delegator, validatorAddress: operatorAddrs[i], @@ -183,28 +154,22 @@ contract HubReader { * * @return The APYs of the validator in basis points, e.g. 195 is 1.95% */ - function getAPYs( - address[] memory operatorAddrs, - uint256 timestamp - ) external view returns (uint64[] memory) { + // forge-lint: disable-next-line(mixed-case-function) + function getAPYs(address[] memory operatorAddrs, uint256 timestamp) external view returns (uint64[] memory) { uint256 dayIndex = timestamp / stakeHub.BREATHE_BLOCK_INTERVAL(); uint256 length = operatorAddrs.length; uint64[] memory apys = new uint64[](length); for (uint256 i = 0; i < length; i++) { - uint256 total = stakeHub.getValidatorTotalPooledBNBRecord( - operatorAddrs[i], - dayIndex - ); + uint256 total = stakeHub.getValidatorTotalPooledBNBRecord(operatorAddrs[i], dayIndex); if (total == 0) { continue; } - uint256 reward = stakeHub.getValidatorRewardRecord( - operatorAddrs[i], - dayIndex - ); + uint256 reward = stakeHub.getValidatorRewardRecord(operatorAddrs[i], dayIndex); if (reward == 0) { continue; } + // casting to uint64 is safe because APY basis points from hub totals fit in 64 bits + // forge-lint: disable-next-line(unsafe-typecast) apys[i] = uint64((reward * 365 * 10000) / total); } return apys; diff --git a/src/hub_reader/interface/IStakeCredit.sol b/src/hub_reader/interface/IStakeCredit.sol index 99bce91..9f37b36 100644 --- a/src/hub_reader/interface/IStakeCredit.sol +++ b/src/hub_reader/interface/IStakeCredit.sol @@ -10,16 +10,10 @@ interface IStakeCredit { function balanceOf(address account) external view returns (uint256); - function getPooledBNBByShares( - uint256 shares - ) external view returns (uint256); + // forge-lint: disable-next-line(mixed-case-function) + function getPooledBNBByShares(uint256 shares) external view returns (uint256); - function pendingUnbondRequest( - address delegator - ) external view returns (uint256); + function pendingUnbondRequest(address delegator) external view returns (uint256); - function unbondRequest( - address delegator, - uint256 _index - ) external view returns (UnbondRequest memory); + function unbondRequest(address delegator, uint256 _index) external view returns (UnbondRequest memory); } diff --git a/src/hub_reader/interface/IStakeHub.sol b/src/hub_reader/interface/IStakeHub.sol index 45bf90d..ef13537 100644 --- a/src/hub_reader/interface/IStakeHub.sol +++ b/src/hub_reader/interface/IStakeHub.sol @@ -17,40 +17,22 @@ struct Commission { interface IStakeHub { function BREATHE_BLOCK_INTERVAL() external view returns (uint256); - function getValidators( - uint256 offset, - uint256 limit - ) + function getValidators(uint256 offset, uint256 limit) external view - returns ( - address[] memory operatorAddrs, - address[] memory creditAddrs, - uint256 totalLength - ); - - function getValidatorBasicInfo( - address operatorAddress - ) + returns (address[] memory operatorAddrs, address[] memory creditAddrs, uint256 totalLength); + + function getValidatorBasicInfo(address operatorAddress) external view returns (uint256 createdTime, bool jailed, uint256 jailUntil); - function getValidatorDescription( - address operatorAddress - ) external view returns (Description memory); + function getValidatorDescription(address operatorAddress) external view returns (Description memory); - function getValidatorCommission( - address operatorAddress - ) external view returns (Commission memory); + function getValidatorCommission(address operatorAddress) external view returns (Commission memory); - function getValidatorRewardRecord( - address operatorAddress, - uint256 index - ) external view returns (uint256); + function getValidatorRewardRecord(address operatorAddress, uint256 index) external view returns (uint256); - function getValidatorTotalPooledBNBRecord( - address operatorAddress, - uint256 index - ) external view returns (uint256); + // forge-lint: disable-next-line(mixed-case-function) + function getValidatorTotalPooledBNBRecord(address operatorAddress, uint256 index) external view returns (uint256); } diff --git a/src/monad/IStaking.sol b/src/monad/IStaking.sol new file mode 100644 index 0000000..17efcd3 --- /dev/null +++ b/src/monad/IStaking.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +interface IStaking { + function getConsensusValidatorSet(uint32 startIndex) + external + returns (bool isDone, uint32 nextIndex, uint64[] memory valIds); + + function getWithdrawalRequest(uint64 validatorId, address delegator, uint8 withdrawId) + external + returns (uint256 withdrawalAmount, uint256 accRewardPerToken, uint64 withdrawEpoch); + + function getEpoch() external returns (uint64 epoch, bool inEpochDelayPeriod); + + function getValidator(uint64 validatorId) + external + returns ( + address authAddress, + uint64 flags, + uint256 stake, + uint256 accRewardPerToken, + uint256 commission, + uint256 unclaimedRewards, + uint256 consensusStake, + uint256 consensusCommission, + uint256 snapshotStake, + uint256 snapshotCommission, + bytes memory secpPubkey, + bytes memory blsPubkey + ); + + function getDelegations(address delegator, uint64 startValId) + external + returns (bool isDone, uint64 nextValId, uint64[] memory valIds); + + function getDelegator(uint64 validatorId, address delegator) + external + returns ( + uint256 stake, + uint256 accRewardPerToken, + uint256 unclaimedRewards, + uint256 deltaStake, + uint256 nextDeltaStake, + uint64 deltaEpoch, + uint64 nextDeltaEpoch + ); +} diff --git a/src/monad/StakingLens.sol b/src/monad/StakingLens.sol new file mode 100644 index 0000000..09ecd0e --- /dev/null +++ b/src/monad/StakingLens.sol @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {IStaking} from "./IStaking.sol"; + +contract StakingLens { + using Strings for uint256; + + IStaking public constant STAKING = IStaking(0x0000000000000000000000000000000000001000); + + uint16 public constant MAX_DELEGATIONS = 128; + uint8 public constant MAX_WITHDRAW_IDS = 8; + uint32 public constant ACTIVE_VALIDATOR_SET = 200; + uint256 public constant MAX_POSITIONS = uint256(MAX_DELEGATIONS) * (2 + MAX_WITHDRAW_IDS); + + uint256 public constant MONAD_SCALE = 1e18; + uint256 public constant MONAD_BLOCK_REWARD = 25 ether; + uint256 public constant MONAD_BLOCKS_PER_YEAR = 78_840_000; + uint64 public constant APY_BPS_PRECISION = 10_000; + uint64 public constant MONAD_BOUNDARY_BLOCK_PERIOD = 50_000; + uint64 public constant MONAD_EPOCH_SECONDS = MONAD_BOUNDARY_BLOCK_PERIOD * 2 / 5; // 0.4s blocks + + enum DelegationState { + Active, + Activating, + Deactivating, + AwaitingWithdrawal + } + + struct Delegation { + uint64 validatorId; + uint8 withdrawId; + DelegationState state; + uint256 amount; + uint256 rewards; + uint64 withdrawEpoch; + uint64 completionTimestamp; + } + + struct DelegatorSnapshot { + uint256 stake; + uint256 pendingStake; + uint256 rewards; + } + + struct ValidatorInfo { + uint64 validatorId; + uint256 stake; + uint256 commission; + uint64 apyBps; + bool isActive; + } + + struct ValidatorData { + uint64 validatorId; + uint64 flags; + uint256 stake; + uint256 commission; + } + + function getBalance(address delegator) external returns (uint256 staked, uint256 pending, uint256 rewards) { + 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; ++i) { + (uint256 stake,, uint256 unclaimedRewards, uint256 deltaStake, uint256 nextDeltaStake,,) = + STAKING.getDelegator(valIds[i], delegator); + + staked += stake; + pending += deltaStake + nextDeltaStake; + rewards += unclaimedRewards; + } + + if (isDone) { + break; + } + + (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, nextValId); + } + } + + function getDelegations(address delegator) external returns (Delegation[] memory positions) { + positions = new Delegation[](MAX_POSITIONS); + uint256 positionCount = 0; + uint16 validatorCount = 0; + + (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]; + positionCount = _processValidator(delegator, validatorId, currentEpoch, positions, positionCount); + ++validatorCount; + } + + if (isDone || validatorCount == MAX_DELEGATIONS || positionCount == MAX_POSITIONS) { + break; + } + + (isDone, nextValId, valIds) = STAKING.getDelegations(delegator, nextValId); + } + + assembly { + mstore(positions, positionCount) + } + } + + function _processValidator( + address delegator, + uint64 validatorId, + uint64 currentEpoch, + Delegation[] memory positions, + uint256 positionCount + ) internal returns (uint256 newPositionCount) { + DelegatorSnapshot memory snap = _readDelegator(delegator, validatorId); + uint8 lastWithdrawId; + bool hasWithdrawals; + (positionCount, lastWithdrawId, hasWithdrawals) = + _appendWithdrawals(delegator, validatorId, currentEpoch, positions, positionCount); + + if (snap.stake == 0 && snap.pendingStake == 0 && !hasWithdrawals) { + return positionCount; + } + + if (snap.stake > 0 && positionCount < MAX_POSITIONS) { + positions[positionCount] = Delegation({ + validatorId: validatorId, + withdrawId: lastWithdrawId, + state: DelegationState.Active, + amount: snap.stake, + rewards: snap.rewards, + withdrawEpoch: 0, + completionTimestamp: 0 + }); + ++positionCount; + } + + if (snap.pendingStake > 0 && positionCount < MAX_POSITIONS) { + positions[positionCount] = Delegation({ + validatorId: validatorId, + withdrawId: lastWithdrawId, + state: DelegationState.Activating, + amount: snap.pendingStake, + rewards: 0, + withdrawEpoch: 0, + completionTimestamp: 0 + }); + ++positionCount; + } + + return positionCount; + } + + function _readDelegator(address delegator, uint64 validatorId) internal returns (DelegatorSnapshot memory snap) { + uint256 deltaStake; + uint256 nextDeltaStake; + (snap.stake,, snap.rewards, deltaStake, nextDeltaStake,,) = STAKING.getDelegator(validatorId, delegator); + snap.pendingStake = deltaStake + nextDeltaStake; + } + + function _appendWithdrawals( + address delegator, + uint64 validatorId, + uint64 currentEpoch, + Delegation[] memory positions, + uint256 positionCount + ) 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); + if (amount == 0) { + continue; + } + + positions[count] = Delegation({ + validatorId: validatorId, + withdrawId: withdrawId, + state: withdrawEpoch < currentEpoch ? DelegationState.AwaitingWithdrawal : DelegationState.Deactivating, + amount: amount, + rewards: 0, + withdrawEpoch: withdrawEpoch, + completionTimestamp: withdrawEpoch < currentEpoch + ? 0 + : _withdrawCompletionTimestamp(withdrawEpoch, currentEpoch) + }); + + ++count; + lastWithdrawId = withdrawId; + hasWithdrawals = true; + } + + return (count, lastWithdrawId, hasWithdrawals); + } + + function _withdrawCompletionTimestamp(uint64 withdrawEpoch, uint64 currentEpoch) internal view returns (uint64) { + if (withdrawEpoch < currentEpoch) { + return 0; + } + + uint64 remainingEpochs = withdrawEpoch - currentEpoch + 1; + uint256 completion = block.timestamp + uint256(remainingEpochs) * uint256(MONAD_EPOCH_SECONDS); + // casting to uint64 is safe because completion timestamps are bounded by the type max guard above + // forge-lint: disable-next-line(unsafe-typecast) + return completion > type(uint64).max ? type(uint64).max : uint64(completion); + } + + /** + * @notice Return validator stats plus APY for a set of validator ids. + * @param validatorIds If empty, uses the full Monad validator set. + */ + function getValidators(uint64[] calldata validatorIds) + external + returns (ValidatorInfo[] memory validators, uint64 networkApyBps) + { + uint64[] memory allValidatorIds = _allValidatorIds(); + uint64[] memory targetIds = validatorIds.length == 0 ? allValidatorIds : validatorIds; + + (ValidatorData[] memory data, uint256 totalStake) = _fetchValidators(allValidatorIds); + networkApyBps = _calculateNetworkApyBps(totalStake); + + validators = new ValidatorInfo[](targetIds.length); + for (uint256 i = 0; i < targetIds.length; ++i) { + (ValidatorData memory snapshot, bool found) = _findValidator(data, targetIds[i]); + if (!found) { + snapshot.validatorId = targetIds[i]; + } + + uint64 validatorApy = _validatorApyBps(snapshot.stake, totalStake, snapshot.commission, networkApyBps); + + validators[i] = ValidatorInfo({ + validatorId: snapshot.validatorId, + stake: snapshot.stake, + commission: snapshot.commission, + apyBps: validatorApy, + isActive: found && snapshot.flags == 0 && snapshot.stake > 0 + }); + } + } + + /** + * @notice Return APYs for a set of validator ids. Defaults to the full validator set when empty. + */ + // forge-lint: disable-next-line(mixed-case-function) + function getAPYs(uint64[] calldata validatorIds) external returns (uint64[] memory apysBps) { + uint64[] memory allValidatorIds = _allValidatorIds(); + uint64[] memory targetIds = validatorIds.length == 0 ? allValidatorIds : validatorIds; + + (ValidatorData[] memory data, uint256 totalStake) = _fetchValidators(allValidatorIds); + uint64 networkApyBps = _calculateNetworkApyBps(totalStake); + + apysBps = new uint64[](targetIds.length); + for (uint256 i = 0; i < targetIds.length; ++i) { + (ValidatorData memory snapshot, bool found) = _findValidator(data, targetIds[i]); + if (!found) { + continue; + } + + uint64 validatorApy = _validatorApyBps(snapshot.stake, totalStake, snapshot.commission, networkApyBps); + apysBps[i] = validatorApy; + } + } + + function _allValidatorIds() internal returns (uint64[] memory validatorIds) { + validatorIds = new uint64[](ACTIVE_VALIDATOR_SET); + + uint256 count = 0; + bool isDone; + uint32 nextIndex; + uint64[] memory page; + + (isDone, nextIndex, page) = STAKING.getConsensusValidatorSet(0); + while (true) { + uint256 len = page.length; + for (uint256 i = 0; i < len && count < ACTIVE_VALIDATOR_SET; ++i) { + validatorIds[count] = page[i]; + ++count; + } + + if (isDone || count == ACTIVE_VALIDATOR_SET) { + break; + } + + (isDone, nextIndex, page) = STAKING.getConsensusValidatorSet(nextIndex); + } + + if (count == 0) { + for (uint64 id = 1; id <= ACTIVE_VALIDATOR_SET; ++id) { + validatorIds[count] = id; + ++count; + } + } + + assembly { + mstore(validatorIds, count) + } + } + + function _fetchValidators(uint64[] memory validatorIds) + internal + returns (ValidatorData[] memory validators, uint256 totalStake) + { + uint256 len = validatorIds.length; + validators = new ValidatorData[](len); + + for (uint256 i = 0; i < len; ++i) { + (, uint64 flags, uint256 stake,, uint256 commission,,,,,,,) = STAKING.getValidator(validatorIds[i]); + + validators[i] = + ValidatorData({validatorId: validatorIds[i], flags: flags, stake: stake, commission: commission}); + + totalStake += stake; + } + } + + function _findValidator(ValidatorData[] memory validators, uint64 validatorId) + internal + pure + returns (ValidatorData memory validator, bool found) + { + uint256 len = validators.length; + for (uint256 i = 0; i < len; ++i) { + if (validators[i].validatorId == validatorId) { + return (validators[i], true); + } + } + + return (validator, false); + } + + function _calculateNetworkApyBps(uint256 totalStake) internal pure returns (uint64) { + if (totalStake == 0) { + return 0; + } + + uint256 annualRewards = MONAD_BLOCK_REWARD * MONAD_BLOCKS_PER_YEAR; + // casting to uint64 is safe because APY basis points derived from network totals fit in 64 bits + // forge-lint: disable-next-line(unsafe-typecast) + return uint64((annualRewards * APY_BPS_PRECISION) / totalStake); + } + + function _validatorApyBps(uint256 validatorStake, uint256 totalStake, uint256 commission, uint64 networkApyBps) + internal + pure + returns (uint64) + { + if (validatorStake == 0 || totalStake == 0) { + return networkApyBps; + } + + uint256 stakeWeight = (validatorStake * MONAD_SCALE) / totalStake; + uint256 expectedBlocks = (stakeWeight * MONAD_BLOCKS_PER_YEAR) / MONAD_SCALE; + uint256 grossRewards = expectedBlocks * MONAD_BLOCK_REWARD; + uint256 commissionCut = commission > MONAD_SCALE ? MONAD_SCALE : commission; + uint256 netRewards = (grossRewards * (MONAD_SCALE - commissionCut)) / MONAD_SCALE; + + uint256 apyBps = (netRewards * APY_BPS_PRECISION) / validatorStake; + // casting to uint64 is safe because APY basis points are capped by uint64 max guard above + // forge-lint: disable-next-line(unsafe-typecast) + return apyBps > type(uint64).max ? type(uint64).max : uint64(apyBps); + } +} diff --git a/src/stargate/StargateFeeReceiver.sol b/src/stargate/StargateFeeReceiver.sol index 39bf126..e921eb2 100644 --- a/src/stargate/StargateFeeReceiver.sol +++ b/src/stargate/StargateFeeReceiver.sol @@ -6,10 +6,10 @@ import {OFTComposeMsgCodec} from "@layerzerolabs/lz-evm-oapp-v2/contracts/oft/li import {MulticallHandler} from "../library/MulticallHandler.sol"; contract StargateFeeReceiver is ILayerZeroComposer, MulticallHandler { - address public immutable endpoint; + address public immutable ENDPOINT; constructor(address _endpoint) { - endpoint = _endpoint; + ENDPOINT = _endpoint; } function lzCompose( @@ -18,8 +18,12 @@ contract StargateFeeReceiver is ILayerZeroComposer, MulticallHandler { bytes calldata _message, address, // _executor bytes calldata // _extraData - ) external payable override { - require(msg.sender == endpoint, "!endpoint"); + ) + external + payable + override + { + require(msg.sender == ENDPOINT, "!endpoint"); // Decode message bytes memory composeMsg = OFTComposeMsgCodec.composeMsg(_message); diff --git a/test/hub_reader/HubReader.t.sol b/test/hub_reader/HubReader.t.sol index c97fc95..40f0d3f 100644 --- a/test/hub_reader/HubReader.t.sol +++ b/test/hub_reader/HubReader.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; -import {Test, console} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {HubReader, Validator, Delegation, Undelegation} from "../../src/hub_reader/HubReader.sol"; contract ValidatorsTest is Test { @@ -30,36 +30,22 @@ contract ValidatorsTest is Test { function test_getDelegations() public view { address delegator = 0xee448667ffc3D15ca023A6deEf2D0fAf084C0716; - Delegation[] memory delegations = reader.getDelegations( - delegator, - 0, - 10 - ); + Delegation[] memory delegations = reader.getDelegations(delegator, 0, 10); uint256 length = 2; assertEq(delegations.length, length); assertEq(delegations[length - 1].delegatorAddress, delegator); - assertEq( - delegations[length - 1].validatorAddress, - 0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A - ); + assertEq(delegations[length - 1].validatorAddress, 0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A); assertTrue(delegations[length - 1].amount > 0); assertTrue(delegations[length - 1].shares > 0); } function test_getUndelegations() public view { address delegator = 0xee448667ffc3D15ca023A6deEf2D0fAf084C0716; - Undelegation[] memory undelegations = reader.getUndelegations( - delegator, - 0, - 10 - ); + Undelegation[] memory undelegations = reader.getUndelegations(delegator, 0, 10); uint256 length = 1; assertEq(undelegations.length, length); assertEq(undelegations[length - 1].delegatorAddress, delegator); - assertEq( - undelegations[length - 1].validatorAddress, - 0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A - ); + assertEq(undelegations[length - 1].validatorAddress, 0x343dA7Ff0446247ca47AA41e2A25c5Bbb230ED0A); assertTrue(undelegations[length - 1].amount > 0); assertTrue(undelegations[length - 1].shares > 0); assertTrue(undelegations[length - 1].unlockTime > 0); diff --git a/test/monad/StakingLens.t.sol b/test/monad/StakingLens.t.sol new file mode 100644 index 0000000..89f432d --- /dev/null +++ b/test/monad/StakingLens.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {StakingLens} from "../../src/monad/StakingLens.sol"; +import {IStaking} from "../../src/monad/IStaking.sol"; + +contract StakingLensTest is Test { + StakingLens private lens; + address private constant STAKING_PRECOMPILE = address(0x0000000000000000000000000000000000001000); + + uint64[] private validatorIds; + uint256 private constant TOTAL_STAKE = 1e30; + uint256 private constant VALIDATOR_STAKE = TOTAL_STAKE / 2; + + function setUp() public { + lens = new StakingLens(); + + validatorIds = new uint64[](2); + validatorIds[0] = 1; + validatorIds[1] = 2; + + _mockConsensusSet(); + _mockValidator(validatorIds[0], VALIDATOR_STAKE, 0); + _mockValidator(validatorIds[1], VALIDATOR_STAKE, 0); + } + + function test_getAPYsUsesAllValidatorsWhenEmpty() public { + uint64[] memory apys = lens.getAPYs(new uint64[](0)); + + uint64 expected = _expectedNetworkApy(); + assertEq(apys.length, validatorIds.length); + assertEq(apys[0], expected); + assertEq(apys[1], expected); + } + + function test_getValidatorsReturnsNetworkApy() public { + (StakingLens.ValidatorInfo[] memory validators, uint64 networkApy) = lens.getValidators(new uint64[](0)); + + uint64 expected = _expectedNetworkApy(); + assertEq(networkApy, expected); + assertEq(validators.length, validatorIds.length); + assertEq(validators[0].validatorId, validatorIds[0]); + assertEq(validators[0].apyBps, expected); + assertEq(validators[1].validatorId, validatorIds[1]); + assertEq(validators[1].apyBps, expected); + } + + 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); + } + + function _mockValidator(uint64 validatorId, uint256 stake, uint256 commission) internal { + bytes memory data = abi.encodeCall(IStaking.getValidator, (validatorId)); + bytes memory result = abi.encode( + address(0), + uint64(0), + stake, + uint256(0), + commission, + uint256(0), + uint256(0), + uint256(0), + uint256(0), + uint256(0), + bytes(""), + bytes("") + ); + vm.mockCall(STAKING_PRECOMPILE, data, result); + } + + 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; + // casting to uint64 is safe because APY basis points are intentionally capped within uint64 range + // forge-lint: disable-next-line(unsafe-typecast) + return uint64(apy); + } +}