diff --git a/Cargo.lock b/Cargo.lock index 7ec9c3256a4e7..d878b3cd3818c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,8 +341,7 @@ dependencies = [ [[package]] name = "alloy-monad-evm" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "305f5409aa88a59e783a3faeee54b9a6c8ed942aad4ba473bcc682df94b5bfff" +source = "git+https://github.com/category-labs/alloy-monad-evm?branch=feat%2Fstaking-write-functions#8313384bd58a030566cdcb6e88c4427ae17ff2a0" dependencies = [ "alloy-evm", "alloy-primitives", @@ -2890,7 +2889,7 @@ dependencies = [ "terminfo", "thiserror 2.0.18", "which", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3038,7 +3037,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3687,7 +3686,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3992,7 +3991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5802,7 +5801,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -6179,7 +6178,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6770,8 +6769,7 @@ dependencies = [ [[package]] name = "monad-revm" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1dc0273ca267946fb161d5eed8251655ff1a45cf444a2440dd9835e608af556" +source = "git+https://github.com/category-labs/monad-revm?branch=feat%2Fstaking-write-functions#f5e1099704fec327e680d0310422460f4e7deff1" dependencies = [ "alloy-sol-types", "auto_impl", @@ -6898,7 +6896,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7912,7 +7910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.114", @@ -7925,7 +7923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.114", @@ -8048,7 +8046,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -8085,9 +8083,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -8804,7 +8802,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9588,7 +9586,7 @@ dependencies = [ "derive_more", "dunce", "inturn", - "itertools 0.14.0", + "itertools 0.12.1", "itoa", "normalize-path", "once_map", @@ -9600,7 +9598,7 @@ dependencies = [ "solar-config", "solar-data-structures", "solar-macros", - "thiserror 2.0.18", + "thiserror 1.0.69", "tracing", "unicode-width 0.2.0", ] @@ -9623,7 +9621,7 @@ dependencies = [ "alloy-primitives", "bitflags 2.10.0", "bumpalo", - "itertools 0.14.0", + "itertools 0.12.1", "memchr", "num-bigint", "num-rational", @@ -9932,7 +9930,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "thiserror 2.0.18", + "thiserror 1.0.69", "url", "zip", ] @@ -10046,7 +10044,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10066,7 +10064,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11358,7 +11356,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1d261a1ae84d0..0883a9276e863 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -444,12 +444,12 @@ rexpect = { git = "https://github.com/rust-cli/rexpect", rev = "2ed0b1898d7edaf6 ## alloy-evm # alloy-evm = { git = "https://github.com/haythemsellami/evm", branch = "main" } # alloy-op-evm = { git = "https://github.com/haythemsellami/evm", branch = "main" } -# alloy-monad-evm = { git = "https://github.com/category-labs/alloy-monad-evm", branch = "main" } +alloy-monad-evm = { git = "https://github.com/category-labs/alloy-monad-evm", branch = "feat/staking-write-functions" } ## revm # revm = { git = "https://github.com/haythemsellami/revm", branch = "main" } # op-revm = { git = "https://github.com/haythemsellami/revm", branch = "main" } -# monad-revm = { git = "https://github.com/category-labs/monad-revm", branch = "main" } +monad-revm = { git = "https://github.com/category-labs/monad-revm", branch = "feat/staking-write-functions" } revm-inspectors = { git = "https://github.com/haythemsellami/revm-inspectors", branch = "main" } ## foundry diff --git a/README.md b/README.md index 1f020cb97f5bf..770ab944d1700 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,22 @@ Monad is a Layer-1 blockchain delivering high performance, true decentralization - Monad-specific [opcode and precompile gas costs](https://docs.monad.xyz/developer-essentials/opcode-pricing), no gas refunds, increased bytecode limits (128KB code, 256KB initcode), and no EIP-4844 blob transactions. See [Monad EVM differences](https://docs.monad.xyz/developer-essentials/differences) for full details. ### Staking Precompile (address `0x1000`) -- Full support for all staking [view functions](https://docs.monad.xyz/developer-essentials/staking/staking-precompile): `getEpoch`, `getProposerValId`, `getValidator`, `getDelegator`, `getWithdrawalRequest`, `getConsensusValidatorSet`, `getSnapshotValidatorSet`, `getExecutionValidatorSet`, `getDelegations`, `getDelegators`. +- Full support for Monad staking precompile execution in tests/scripts via the Monad EVM stack. +- Support for staking view functions (`getEpoch`, `getProposerValId`, `getValidator`, `getDelegator`, `getWithdrawalRequest`, `getConsensusValidatorSet`, `getSnapshotValidatorSet`, `getExecutionValidatorSet`, `getDelegations`, `getDelegators`) and state-changing functions (`addValidator`, `delegate`, `undelegate`, `withdraw`, `compound`, `claimRewards`, `changeCommission`, `externalReward`). +- Full staking behavior is implemented in [`monad-revm`](https://github.com/category-labs/monad-revm) and consumed through [`alloy-monad-evm`](https://github.com/category-labs/alloy-monad-evm). See the monad-revm README for design/lifecycle details. - Human-readable ABI decoding in `forge test -vvvv` traces for all staking functions and events. - Address `0x1000` labeled as "Staking" in trace output. +### Monad Staking Cheatcodes +- Monad staking cheatcodes are exposed from a separate cheatcode address: + - `0xc0FFeeCD43A10e1C2b0De63c6CDCFe5B7d0e0CEA` +- Current implemented cheatcodes: + - Direct state controls: `setEpoch(uint64,bool)`, `setProposer(uint64)`, `setAccumulator(uint64,uint256)` + - Syscall wrappers: `blockReward(address,uint256)`, `epochSnapshot()`, `epochChange(uint64)`, `epochBoundary(uint64)` +- These cheatcodes are helper controls around lifecycle/state setup. Core staking operations (delegate/undelegate/claim/withdraw/etc.) still execute through the real staking precompile at `0x1000`. +- Solidity interface path in this repository: `testdata/utils/MonadVm.sol` +- End-to-end tests for current coverage: `testdata/default/cheats/MonadStaking.t.sol` + ### Forge - `forge test` and `forge script` execute with Monad EVM by default. - `forge verify-contract` uses Monad-specific compilation settings. diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index fcaa2e571f2ac..9b30bb5fc189d 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -761,6 +761,41 @@ impl Cheatcodes { }; } + if call.target_address == crate::monad::MONAD_CHEATCODE_ADDRESS { + let input = call.input.bytes(ecx); + let result = crate::monad::apply_monad_cheatcode( + &mut CheatsCtxt { + state: self, + ecx, + gas_limit: call.gas_limit, + caller: call.caller, + }, + &input, + ); + return match result { + Ok(retdata) => Some(CallOutcome { + result: InterpreterResult { + result: InstructionResult::Return, + output: retdata.into(), + gas, + }, + memory_offset: call.return_memory_offset.clone(), + was_precompile_called: true, + precompile_call_logs: vec![], + }), + Err(err) => Some(CallOutcome { + result: InterpreterResult { + result: InstructionResult::Revert, + output: err.abi_encode().into(), + gas, + }, + memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + }), + }; + } + if call.target_address == HARDHAT_CONSOLE_ADDRESS { return None; } diff --git a/crates/cheatcodes/src/lib.rs b/crates/cheatcodes/src/lib.rs index a4fd2ed99d365..2cf8075956b35 100644 --- a/crates/cheatcodes/src/lib.rs +++ b/crates/cheatcodes/src/lib.rs @@ -63,6 +63,8 @@ mod toml; mod utils; +pub mod monad; + /// Cheatcode implementation. pub(crate) trait Cheatcode: CheatcodeDef + DynCheatcode { /// Applies this cheatcode to the given state. diff --git a/crates/cheatcodes/src/monad.rs b/crates/cheatcodes/src/monad.rs new file mode 100644 index 0000000000000..15959865119da --- /dev/null +++ b/crates/cheatcodes/src/monad.rs @@ -0,0 +1,278 @@ +//! Monad staking cheatcodes — separate address, separate dispatch. +//! +//! These cheatcodes live at [`MONAD_CHEATCODE_ADDRESS`] (distinct from the standard Foundry +//! `CHEATCODE_ADDRESS`). They provide two categories of functionality: +//! +//! 1. **Direct storage manipulation**: `setEpoch`, `setProposer`, `setAccumulator` — write directly +//! to the staking precompile's storage at `0x1000`. +//! +//! 2. **Syscall wrappers**: `blockReward`, `epochSnapshot`, `epochChange`, `epochBoundary` — +//! delegate to the real monad-revm syscall handlers via the [`StakingStorage`] adapter, giving +//! production-equivalent behavior with zero logic duplication. +//! +//! State-mutating staking functions (delegate, undelegate, addValidator, etc.) are +//! handled by the staking precompile directly. + +use crate::{CheatsCtxt, Result}; +use alloy_primitives::{Address, U256, address}; +use alloy_sol_types::SolInterface; +use foundry_evm_core::ContextExt; +use monad_revm::{ + api::block::{ + syscall_on_epoch_change_calldata, syscall_reward_calldata, syscall_snapshot_calldata, + }, + staking::{ + StorageReader, + constants::SYSTEM_ADDRESS, + storage::{STAKING_ADDRESS, global_slots, validator_key, validator_offsets}, + write::{ + StakingStorage, handle_syscall_on_epoch_change, handle_syscall_reward, + handle_syscall_snapshot, + }, + }, +}; +use revm::{precompile::PrecompileError, primitives::Log}; + +// --------------------------------------------------------------------------- +// Address & ABI +// --------------------------------------------------------------------------- + +/// Monad cheatcode address: `keccak256("monad cheatcode")[12..]`. +pub const MONAD_CHEATCODE_ADDRESS: Address = address!("0xc0FFeeCD43A10e1C2b0De63c6CDCFe5B7d0e0CEA"); + +alloy_sol_types::sol! { + /// Monad-specific cheatcodes. Accessible via `MonadVm(MONAD_CHEATCODE_ADDRESS)`. + /// + /// State-mutating staking functions (delegate, undelegate, addValidator, etc.) + /// are handled by the staking precompile directly. These cheatcodes cover + /// syscall simulation and direct state control for testing. + interface MonadVm { + /// Sets the current epoch and delay period for the staking precompile. + function setEpoch(uint64 epoch, bool inDelayPeriod) external; + + /// Sets the current block proposer validator ID. + function setProposer(uint64 valId) external; + + /// Directly sets a validator's accumulated reward per token. + function setAccumulator(uint64 valId, uint256 value) external; + + /// Distribute block reward via the real syscallReward handler. + /// Mints `reward` to staking address and distributes via accumulator math + /// using consensus/snapshot view stake (production-equivalent behavior). + function blockReward(address author, uint256 reward) external; + + /// Execute syscallSnapshot: copies consensus→snapshot view, rebuilds + /// consensus set from execution set sorted by stake. Sets in_boundary = true. + function epochSnapshot() external; + + /// Execute syscallOnEpochChange: increments epoch, clears in_boundary. + /// `newEpoch` must be strictly greater than the current epoch. + function epochChange(uint64 newEpoch) external; + + /// Convenience: epochSnapshot() then epochChange(newEpoch). + function epochBoundary(uint64 newEpoch) external; + } +} + +// --------------------------------------------------------------------------- +// Dispatch entry point (called from inspector.rs) +// --------------------------------------------------------------------------- + +/// Decode calldata and dispatch to the appropriate monad cheatcode handler. +pub fn apply_monad_cheatcode(ccx: &mut CheatsCtxt, input: &[u8]) -> Result { + let decoded = MonadVm::MonadVmCalls::abi_decode(input).map_err(|e| { + if let alloy_sol_types::Error::UnknownSelector { selector, .. } = e { + let msg = format!( + "unknown monad cheatcode with selector {selector}; \ + check that your Monad.sol interface matches this forge version" + ); + return alloy_sol_types::Error::Other(std::borrow::Cow::Owned(msg)); + } + e + })?; + + match decoded { + MonadVm::MonadVmCalls::setEpoch(call) => apply_set_epoch(ccx, call), + MonadVm::MonadVmCalls::setProposer(call) => apply_set_proposer(ccx, call), + MonadVm::MonadVmCalls::setAccumulator(call) => apply_set_accumulator(ccx, call), + MonadVm::MonadVmCalls::blockReward(call) => apply_block_reward(ccx, call), + MonadVm::MonadVmCalls::epochSnapshot(call) => apply_epoch_snapshot(ccx, call), + MonadVm::MonadVmCalls::epochChange(call) => apply_epoch_change(ccx, call), + MonadVm::MonadVmCalls::epochBoundary(call) => apply_epoch_boundary(ccx, call), + } +} + +// --------------------------------------------------------------------------- +// Encoding helpers +// --------------------------------------------------------------------------- + +/// Encode a u64 left-aligned in a 32-byte slot (big-endian in first 8 bytes). +fn u64_left_aligned(v: u64) -> U256 { + let mut bytes = [0u8; 32]; + bytes[0..8].copy_from_slice(&v.to_be_bytes()); + U256::from_be_bytes(bytes) +} + +// --------------------------------------------------------------------------- +// Storage access helpers (bypass precompile check) +// --------------------------------------------------------------------------- + +fn sstore_staking(ccx: &mut CheatsCtxt, key: U256, value: U256) -> Result<()> { + let (db, journal, _) = ccx.ecx.as_db_env_and_journal(); + journal.load_account(db, STAKING_ADDRESS)?; + journal.touch(STAKING_ADDRESS); + journal + .sstore(db, STAKING_ADDRESS, key, value, false) + .map_err(|e| fmt_err!("staking sstore failed: {:?}", e))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// StakingStorage adapter for CheatsCtxt +// --------------------------------------------------------------------------- + +/// Bridges Foundry's [`CheatsCtxt`] to monad-revm's [`StakingStorage`] trait, +/// enabling syscall handlers to read/write staking storage via the journal. +struct CheatsCtxtStorage<'a, 'cheats, 'evm, 'db, 'db2> { + ccx: &'a mut CheatsCtxt<'cheats, 'evm, 'db, 'db2>, +} + +impl StorageReader for CheatsCtxtStorage<'_, '_, '_, '_, '_> { + fn sload(&mut self, key: U256) -> core::result::Result { + let (db, journal, _) = self.ccx.ecx.as_db_env_and_journal(); + journal + .load_account(db, STAKING_ADDRESS) + .map_err(|e| PrecompileError::Other(format!("load_account failed: {e:?}").into()))?; + journal + .sload(db, STAKING_ADDRESS, key, false) + .map(|r| r.data) + .map_err(|e| PrecompileError::Other(format!("sload failed: {e:?}").into())) + } +} + +impl StakingStorage for CheatsCtxtStorage<'_, '_, '_, '_, '_> { + fn sstore(&mut self, key: U256, value: U256) -> core::result::Result<(), PrecompileError> { + let (db, journal, _) = self.ccx.ecx.as_db_env_and_journal(); + journal + .load_account(db, STAKING_ADDRESS) + .map_err(|e| PrecompileError::Other(format!("load_account failed: {e:?}").into()))?; + journal.touch(STAKING_ADDRESS); + journal + .sstore(db, STAKING_ADDRESS, key, value, false) + .map(|_| ()) + .map_err(|e| PrecompileError::Other(format!("sstore failed: {e:?}").into())) + } + + fn transfer( + &mut self, + from: Address, + to: Address, + amount: U256, + ) -> core::result::Result<(), PrecompileError> { + if amount.is_zero() { + return Ok(()); + } + let (db, journal, _) = self.ccx.ecx.as_db_env_and_journal(); + journal + .transfer(db, from, to, amount) + .map_err(|e| PrecompileError::Other(format!("transfer error: {e:?}").into()))? + .map_or(Ok(()), |te| { + Err(PrecompileError::Other(format!("transfer failed: {te:?}").into())) + }) + } + + fn emit_log(&mut self, log: Log) -> core::result::Result<(), PrecompileError> { + let (_, journal, _) = self.ccx.ecx.as_db_env_and_journal(); + journal.log(log); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Direct storage cheatcode handlers +// --------------------------------------------------------------------------- + +fn apply_set_epoch(ccx: &mut CheatsCtxt, call: MonadVm::setEpochCall) -> Result { + let MonadVm::setEpochCall { epoch, inDelayPeriod } = call; + sstore_staking(ccx, global_slots::EPOCH, u64_left_aligned(epoch))?; + // IN_BOUNDARY is a left-aligned bool (byte 0 = 1 for true, 0 for false) + let boundary_val = if inDelayPeriod { + let mut bytes = [0u8; 32]; + bytes[0] = 1; + U256::from_be_bytes(bytes) + } else { + U256::ZERO + }; + sstore_staking(ccx, global_slots::IN_BOUNDARY, boundary_val)?; + Ok(Default::default()) +} + +fn apply_set_proposer(ccx: &mut CheatsCtxt, call: MonadVm::setProposerCall) -> Result { + let MonadVm::setProposerCall { valId } = call; + sstore_staking(ccx, global_slots::PROPOSER_VAL_ID, u64_left_aligned(valId))?; + Ok(Default::default()) +} + +fn apply_set_accumulator(ccx: &mut CheatsCtxt, call: MonadVm::setAccumulatorCall) -> Result { + let MonadVm::setAccumulatorCall { valId, value } = call; + sstore_staking( + ccx, + validator_key(valId, validator_offsets::ACCUMULATED_REWARD_PER_TOKEN), + value, + )?; + Ok(Default::default()) +} + +// --------------------------------------------------------------------------- +// Syscall cheatcode handlers +// --------------------------------------------------------------------------- + +fn apply_block_reward(ccx: &mut CheatsCtxt, call: MonadVm::blockRewardCall) -> Result { + let MonadVm::blockRewardCall { author, reward } = call; + + // Build extended calldata (68 bytes: selector + author + reward) + let calldata = syscall_reward_calldata(author, reward); + + // Run syscall first — it will revert for unknown authors or zero-stake validators. + // Mint only after success to avoid crediting tokens on revert. + let mut storage = CheatsCtxtStorage { ccx }; + handle_syscall_reward(&mut storage, &calldata, u64::MAX, &SYSTEM_ADDRESS, U256::ZERO) + .map_err(|e| fmt_err!("blockReward failed: {e}"))?; + + // Mint reward to STAKING_ADDRESS balance (token minting — not a transfer) + { + let (db, journal, _) = storage.ccx.ecx.as_db_env_and_journal(); + journal.load_account(db, STAKING_ADDRESS)?; + journal.touch(STAKING_ADDRESS); + let account = journal.state.get_mut(&STAKING_ADDRESS).expect("staking account loaded"); + account.info.balance = account.info.balance.saturating_add(reward); + } + + Ok(Default::default()) +} + +fn apply_epoch_snapshot(ccx: &mut CheatsCtxt, _call: MonadVm::epochSnapshotCall) -> Result { + let calldata = syscall_snapshot_calldata(); + + let mut storage = CheatsCtxtStorage { ccx }; + handle_syscall_snapshot(&mut storage, &calldata, u64::MAX, &SYSTEM_ADDRESS) + .map_err(|e| fmt_err!("epochSnapshot failed: {e}"))?; + + Ok(Default::default()) +} + +fn apply_epoch_change(ccx: &mut CheatsCtxt, call: MonadVm::epochChangeCall) -> Result { + let calldata = syscall_on_epoch_change_calldata(call.newEpoch); + + let mut storage = CheatsCtxtStorage { ccx }; + handle_syscall_on_epoch_change(&mut storage, &calldata, u64::MAX, &SYSTEM_ADDRESS) + .map_err(|e| fmt_err!("epochChange failed: {e}"))?; + + Ok(Default::default()) +} + +fn apply_epoch_boundary(ccx: &mut CheatsCtxt, call: MonadVm::epochBoundaryCall) -> Result { + apply_epoch_snapshot(ccx, MonadVm::epochSnapshotCall {})?; + apply_epoch_change(ccx, MonadVm::epochChangeCall { newEpoch: call.newEpoch })?; + Ok(Default::default()) +} diff --git a/crates/evm/core/src/fork/multi.rs b/crates/evm/core/src/fork/multi.rs index 186183b9a8d2a..b7da629564de9 100644 --- a/crates/evm/core/src/fork/multi.rs +++ b/crates/evm/core/src/fork/multi.rs @@ -276,7 +276,6 @@ impl MultiForkHandler { } /// Returns the list of additional senders of a matching task for the given id, if any. - #[expect(irrefutable_let_patterns)] fn find_in_progress_task(&mut self, id: &ForkId) -> Option<&mut Vec> { for task in &mut self.pending_tasks { if let ForkTask::Create(_, in_progress, _, additional) = task diff --git a/crates/evm/evm/src/executors/mod.rs b/crates/evm/evm/src/executors/mod.rs index 6d44c1a9a5a3d..4ba918dc365c3 100644 --- a/crates/evm/evm/src/executors/mod.rs +++ b/crates/evm/evm/src/executors/mod.rs @@ -134,6 +134,15 @@ impl Executor { ..Default::default() }, ); + // Same for the Monad cheatcode address. + backend.insert_account_info( + foundry_cheatcodes::monad::MONAD_CHEATCODE_ADDRESS, + revm::state::AccountInfo { + code: Some(Bytecode::new_raw(Bytes::from_static(&[0]))), + code_hash: CHEATCODE_CONTRACT_HASH, + ..Default::default() + }, + ); Self { backend, env, inspector, gas_limit, legacy_assertions } } diff --git a/deny.toml b/deny.toml index 376198b9cb2d0..30165e15fb87d 100644 --- a/deny.toml +++ b/deny.toml @@ -108,4 +108,5 @@ allow-git = [ "https://github.com/haythemsellami/foundry-fork-db", "https://github.com/haythemsellami/revm-inspectors", "https://github.com/category-labs/monad-revm", + "https://github.com/category-labs/alloy-monad-evm", ] diff --git a/testdata/default/cheats/MonadStaking.t.sol b/testdata/default/cheats/MonadStaking.t.sol new file mode 100644 index 0000000000000..9dd2f6dbe59ab --- /dev/null +++ b/testdata/default/cheats/MonadStaking.t.sol @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.18; + +import "utils/Test.sol"; +import "utils/MonadVm.sol"; + +/// @dev Minimal staking precompile interface for verification. +interface IMonadStaking { + function getEpoch() external returns (uint64 epoch, bool inEpochDelayPeriod); + function getProposerValId() external returns (uint64 val_id); + 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 getDelegator(uint64 validatorId, address delegator) + external + returns ( + uint256 stake, + uint256 accRewardPerToken, + uint256 unclaimedRewards, + uint256 deltaStake, + uint256 nextDeltaStake, + uint64 deltaEpoch, + uint64 nextDeltaEpoch + ); + function getWithdrawalRequest(uint64 validatorId, address delegator, uint8 withdrawId) + external + returns (uint256 withdrawalAmount, uint256 accRewardPerToken, uint64 withdrawEpoch); + function getConsensusValidatorSet(uint32 startIndex) + external + returns (bool isDone, uint32 nextIndex, uint64[] memory valIds); + function getSnapshotValidatorSet(uint32 startIndex) + external + returns (bool isDone, uint32 nextIndex, uint64[] memory valIds); + function getExecutionValidatorSet(uint32 startIndex) + external + returns (bool isDone, uint32 nextIndex, uint64[] memory valIds); + function addValidator(bytes calldata payload, bytes calldata signedSecp, bytes calldata signedBls) + external + payable + returns (uint64 validatorId); + function claimRewards(uint64 validatorId) external returns (uint256 rewards); +} + +contract MonadStakingTest is Test { + IMonadStaking constant STAKING = IMonadStaking(address(0x1000)); + MonadVm constant monad = MonadVm(0xc0FFeeCD43A10e1C2b0De63c6CDCFe5B7d0e0CEA); + + /// @dev Build addValidator payload. + /// Layout: secp_pubkey(33) + bls_pubkey(48) + auth_address(20) + stake(32) + commission(32) = 165 bytes. + function _buildPayload(address auth, uint256 stake, uint256 commission) internal pure returns (bytes memory) { + bytes memory secp = new bytes(33); + bytes memory bls = new bytes(48); + // Use auth address bytes in BLS pubkey to ensure uniqueness per validator. + for (uint256 i = 0; i < 20; i++) { + bls[i] = bytes20(auth)[i]; + } + return abi.encodePacked(secp, bls, auth, stake, commission); + } + + /// @dev Create a validator via the real addValidator precompile function. + /// Deals balance and returns the assigned validator ID. + function _createValidator(address auth, uint256 stake, uint256 commission) internal returns (uint64) { + bytes memory payload = _buildPayload(auth, stake, commission); + bytes memory dummySig64 = new bytes(64); + bytes memory dummySig96 = new bytes(96); + vm.deal(address(this), stake); + return STAKING.addValidator{value: stake}(payload, dummySig64, dummySig96); + } + + /// @dev Helper to call getValidator and decode only the first 6 fields. + /// Avoids stack-too-deep from destructuring all 12 return values. + /// Uses `call` instead of `staticcall` because the staking precompile rejects STATICCALL. + function _getValidatorCore(uint64 valId) + internal + returns ( + address authAddress, + uint64 flags, + uint256 stake, + uint256 accRewardPerToken, + uint256 commission, + uint256 unclaimedRewards + ) + { + (bool ok, bytes memory ret) = address(STAKING) + .call(abi.encodeWithSelector(IMonadStaking.getValidator.selector, valId)); + require(ok, "getValidator call failed"); + // Decode only the first 6 fixed fields (skip dynamic bytes at the end) + (authAddress, flags, stake, accRewardPerToken, commission, unclaimedRewards) = + abi.decode(ret, (address, uint64, uint256, uint256, uint256, uint256)); + } + + /// @dev Helper to get consensus and snapshot view fields. + /// Uses `call` instead of `staticcall` because the staking precompile rejects STATICCALL. + function _getValidatorViews(uint64 valId) + internal + returns (uint256 consensusStake, uint256 consensusCommission, uint256 snapshotStake, uint256 snapshotCommission) + { + (bool ok, bytes memory ret) = + address(STAKING).call(abi.encodeWithSelector(IMonadStaking.getValidator.selector, valId)); + require(ok, "getValidator call failed"); + // Skip first 6 fields (6 * 32 = 192 bytes), then read next 4 + assembly { + consensusStake := mload(add(ret, 224)) // offset 192 + 32 (length prefix) + consensusCommission := mload(add(ret, 256)) + snapshotStake := mload(add(ret, 288)) + snapshotCommission := mload(add(ret, 320)) + } + } + + function _contains(bytes memory haystack, bytes memory needle) internal pure returns (bool) { + if (needle.length == 0) return true; + if (needle.length > haystack.length) return false; + for (uint256 i = 0; i <= haystack.length - needle.length; i++) { + bool ok = true; + for (uint256 j = 0; j < needle.length; j++) { + if (haystack[i + j] != needle[j]) { + ok = false; + break; + } + } + if (ok) return true; + } + return false; + } + + // ===================================================================== + // Direct State Control Tests (kept from previous version) + // ===================================================================== + + function testSetEpoch() public { + monad.setEpoch(42, false); + (uint64 epoch, bool inDelay) = STAKING.getEpoch(); + assertEq(epoch, 42, "epoch mismatch"); + assertTrue(!inDelay, "should not be in delay"); + + monad.setEpoch(100, true); + (epoch, inDelay) = STAKING.getEpoch(); + assertEq(epoch, 100, "epoch mismatch after update"); + assertTrue(inDelay, "should be in delay"); + } + + function testSetProposer() public { + monad.setProposer(42); + uint64 proposer = STAKING.getProposerValId(); + assertEq(proposer, 42, "proposer mismatch"); + } + + function testSetAccumulator() public { + uint64 valId = _createValidator(address(this), 10_000_000 ether, 0); + uint256 accValue = 123456789e18; + monad.setAccumulator(valId, accValue); + + (,,, uint256 gotAcc,,) = _getValidatorCore(valId); + assertEq(gotAcc, accValue, "accumulator mismatch"); + } + + // ===================================================================== + // Syscall Cheatcode Tests + // ===================================================================== + + /// @dev epochSnapshot rebuilds consensus set from execution set and populates views. + function testEpochSnapshot() public { + monad.setEpoch(1, false); + + // Create two validators with different stakes (>= 10M MON for ACTIVE_VALIDATOR_STAKE threshold) + address auth1 = address(0xA1); + address auth2 = address(0xA2); + uint64 valId1 = _createValidator(auth1, 20_000_000 ether, 0.1e18); + uint64 valId2 = _createValidator(auth2, 10_000_000 ether, 0.05e18); + + // Before snapshot: execution set has both, consensus/snapshot empty + (bool isDone,, uint64[] memory execSet) = STAKING.getExecutionValidatorSet(0); + assertTrue(isDone, "exec set should be complete"); + assertEq(execSet.length, 2, "should have 2 validators in exec set"); + + // Run epoch snapshot + monad.epochSnapshot(); + + // After snapshot: consensus set should be rebuilt from execution set (sorted by stake desc) + uint64[] memory consSet; + (isDone,, consSet) = STAKING.getConsensusValidatorSet(0); + assertTrue(isDone, "consensus set should be complete"); + assertEq(consSet.length, 2, "should have 2 validators in consensus set"); + // Sorted by stake descending: valId1 (200k) first, valId2 (100k) second + assertEq(consSet[0], valId1, "highest stake validator first"); + assertEq(consSet[1], valId2, "lower stake validator second"); + + // Consensus views should be populated with live stake/commission + (uint256 consStake1, uint256 consComm1,,) = _getValidatorViews(valId1); + assertEq(consStake1, 20_000_000 ether, "consensus stake for val1"); + assertEq(consComm1, 0.1e18, "consensus commission for val1"); + + (uint256 consStake2, uint256 consComm2,,) = _getValidatorViews(valId2); + assertEq(consStake2, 10_000_000 ether, "consensus stake for val2"); + assertEq(consComm2, 0.05e18, "consensus commission for val2"); + + // in_boundary should now be true + (, bool inDelay) = STAKING.getEpoch(); + assertTrue(inDelay, "should be in delay period after snapshot"); + } + + /// @dev epochChange increments epoch and clears in_boundary. + function testEpochChange() public { + monad.setEpoch(5, false); + + // Need to do snapshot first (epochChange requires in_boundary check indirectly) + // Create a validator so snapshot has something to work with + _createValidator(address(0xB1), 10_000_000 ether, 0); + monad.epochSnapshot(); + + // Verify in_boundary is true after snapshot + (, bool inDelay) = STAKING.getEpoch(); + assertTrue(inDelay, "should be in delay after snapshot"); + + // Run epoch change + monad.epochChange(6); + + // Verify epoch incremented and in_boundary cleared + (uint64 epoch, bool inDelayAfter) = STAKING.getEpoch(); + assertEq(epoch, 6, "epoch should be 6"); + assertTrue(!inDelayAfter, "in_boundary should be cleared"); + } + + /// @dev epochBoundary is a convenience: snapshot + change. + function testEpochBoundary() public { + monad.setEpoch(10, false); + + address auth = address(0xC1); + uint64 valId = _createValidator(auth, 15_000_000 ether, 0.1e18); + + monad.epochBoundary(11); + + // Epoch should be 11 and not in delay + (uint64 epoch, bool inDelay) = STAKING.getEpoch(); + assertEq(epoch, 11, "epoch should be 11"); + assertTrue(!inDelay, "should not be in delay after full boundary"); + + // Consensus set should have been rebuilt + (bool isDone,, uint64[] memory consSet) = STAKING.getConsensusValidatorSet(0); + assertTrue(isDone); + assertEq(consSet.length, 1, "should have 1 validator"); + assertEq(consSet[0], valId); + + // Consensus views should be populated + (uint256 consStake, uint256 consComm,,) = _getValidatorViews(valId); + assertEq(consStake, 15_000_000 ether, "consensus stake should match"); + assertEq(consComm, 0.1e18, "consensus commission should match"); + } + + /// @dev blockReward distributes reward via production-equivalent syscallReward handler. + function testBlockReward() public { + monad.setEpoch(1, false); + + address auth = address(this); + uint64 valId = _createValidator(auth, 10_000_000 ether, 0.1e18); + + // Must run epoch lifecycle to populate consensus views before blockReward + monad.epochBoundary(2); + + // Distribute 10 MON block reward + monad.blockReward(auth, 10 ether); + + (,,, uint256 accReward,, uint256 unclaimed) = _getValidatorCore(valId); + + // commission = 10% => del_reward = 9 MON goes to unclaimed. + assertEq(unclaimed, 9 ether, "unclaimed should track delegator reward only"); + // Accumulator should reflect delegator share + assertTrue(accReward > 0, "accumulator should be positive"); + } + + /// @dev blockReward with zero commission — all delegator reward goes to accumulator. + function testBlockRewardZeroCommission() public { + monad.setEpoch(1, false); + + address auth = address(this); + uint64 valId = _createValidator(auth, 10_000_000 ether, 0); + + monad.epochBoundary(2); + monad.blockReward(auth, 10 ether); + + (,,, uint256 accReward,, uint256 unclaimed) = _getValidatorCore(valId); + assertEq(unclaimed, 10 ether, "unclaimed should be full reward"); + assertTrue(accReward > 0, "full reward should go to accumulator"); + } + + /// @dev Multiple blockReward calls accumulate correctly. + function testBlockRewardMultiple() public { + monad.setEpoch(1, false); + + address auth = address(this); + _createValidator(auth, 10_000_000 ether, 0.1e18); + + monad.epochBoundary(2); + monad.blockReward(auth, 10 ether); + monad.blockReward(auth, 20 ether); + + (,,,,, uint256 unclaimed) = _getValidatorCore(1); + // del_reward = 9 + 18 = 27 MON with 10% commission. + assertEq(unclaimed, 27 ether, "accumulated unclaimed mismatch"); + } + + /// @dev blockReward with unknown author reverts. + function testBlockRewardUnknownAuthor() public { + monad.setEpoch(1, false); + + _createValidator(address(this), 10_000_000 ether, 0); + monad.epochBoundary(2); + + (bool ok, bytes memory ret) = + address(monad).call(abi.encodeWithSelector(MonadVm.blockReward.selector, address(0xDEAD), 10 ether)); + assertTrue(!ok, "unknown author should revert"); + assertTrue(_contains(ret, bytes("blockReward failed: not in validator set")), "revert reason mismatch"); + } + + /// @dev Full E2E: create validator, epoch lifecycle, block reward, verify accumulator math. + function testRewardCalculationE2E() public { + monad.setEpoch(1, false); + + address auth = address(this); + uint64 valId = _createValidator(auth, 10_000_000 ether, 0.1e18); + + // Run full epoch lifecycle + monad.epochBoundary(2); + + // Distribute 10 MON block reward + monad.blockReward(auth, 10 ether); + + (,,, uint256 valAcc,, uint256 unclaimed) = _getValidatorCore(valId); + assertEq(unclaimed, 9 ether, "delegator reward goes to unclaimed"); + assertTrue(valAcc > 0, "accumulator should be positive"); + + // Verify accumulator math: + // commission = 10 * 0.1 = 1 MON + // del_reward = 10 - 1 = 9 MON + // acc_delta = 9e18 * 1e36 / 10_000_000e18 + // pending_rewards = stake * acc_delta / 1e36 = 10_000_000e18 * acc_delta / 1e36 = 9e18 + uint256 pendingRewards = valAcc * 10_000_000 ether / 1e36; + assertEq(pendingRewards, 9 ether, "delegator should earn 9 MON (all delegator reward)"); + } + + /// @dev Snapshot builds views from live execution set, used by blockReward. + function testSnapshotThenReward() public { + monad.setEpoch(1, false); + + // Create two validators + address auth1 = address(0xD1); + address auth2 = address(0xD2); + uint64 valId1 = _createValidator(auth1, 20_000_000 ether, 0.1e18); + uint64 valId2 = _createValidator(auth2, 10_000_000 ether, 0.05e18); + + // Run epoch lifecycle + monad.epochBoundary(2); + + // Reward both validators + monad.blockReward(auth1, 20 ether); + monad.blockReward(auth2, 10 ether); + + // Verify both got rewards + (,,,,, uint256 unclaimed1) = _getValidatorCore(valId1); + (,,,,, uint256 unclaimed2) = _getValidatorCore(valId2); + assertEq(unclaimed1, 18 ether, "val1 unclaimed"); + assertEq(unclaimed2, 9.5 ether, "val2 unclaimed"); + } +} diff --git a/testdata/utils/MonadVm.sol b/testdata/utils/MonadVm.sol new file mode 100644 index 0000000000000..78248e378498c --- /dev/null +++ b/testdata/utils/MonadVm.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.6.2 <0.9.0; +pragma experimental ABIEncoderV2; + +/// @title Monad Cheatcodes Interface +/// @notice Cheatcodes for staking lifecycle control in Monad Foundry tests. +/// @dev These cheatcodes live at a separate address from the standard Foundry +/// `CHEATCODE_ADDRESS`: 0xc0FFeeCD43A10e1C2b0De63c6CDCFe5B7d0e0CEA +/// +/// State-mutating staking functions (delegate, undelegate, addValidator, etc.) +/// are handled by the staking precompile directly. These cheatcodes provide: +/// 1. Direct state control: setEpoch, setProposer, setAccumulator +/// 2. Syscall wrappers: blockReward, epochSnapshot, epochChange, epochBoundary +interface MonadVm { + /// Sets the current epoch and delay period for the staking precompile. + function setEpoch(uint64 epoch, bool inDelayPeriod) external; + + /// Sets the current block proposer validator ID. + function setProposer(uint64 valId) external; + + /// Directly sets a validator's accumulated reward per token. + function setAccumulator(uint64 valId, uint256 value) external; + + /// Distribute block reward via the real syscallReward handler. + /// Mints `reward` to staking address and distributes via accumulator math + /// using consensus/snapshot view stake (production-equivalent behavior). + function blockReward(address author, uint256 reward) external; + + /// Execute syscallSnapshot: copies consensus→snapshot view, rebuilds + /// consensus set from execution set sorted by stake. Sets in_boundary = true. + function epochSnapshot() external; + + /// Execute syscallOnEpochChange: increments epoch, clears in_boundary. + /// `newEpoch` must equal `currentEpoch + 1`. + function epochChange(uint64 newEpoch) external; + + /// Convenience: epochSnapshot() then epochChange(newEpoch). + function epochBoundary(uint64 newEpoch) external; +}