From 3807751c84bf18523962824409190e08ed710304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hillerstr=C3=B6m?= <1827113+dhil@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:41:30 -0500 Subject: [PATCH 1/2] [monad] Reserve balance precompile `fallback` cost Monad Foundation has specified the cost of the `fallback` method on the reserve balance precompile should be a `100` (which is same as the `dippedIntoReserve` method). Previously, the cost was set to `40'000` (modelled after the staking contract's fallback method) as a placeholder value. --- .../reserve_balance_contract.cpp | 2 +- .../reserve_balance_contract_test.cpp | 84 ++++++++++++++++--- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/category/execution/monad/reserve_balance/reserve_balance_contract.cpp b/category/execution/monad/reserve_balance/reserve_balance_contract.cpp index 9b9dae910f..c7b7334f5c 100644 --- a/category/execution/monad/reserve_balance/reserve_balance_contract.cpp +++ b/category/execution/monad/reserve_balance/reserve_balance_contract.cpp @@ -46,7 +46,7 @@ static_assert(PrecompileSelector::DIPPED_INTO_RESERVE == 0x3a61584e); constexpr uint64_t DIPPED_INTO_RESERVE_OP_COST = 100; // warm sload coast -constexpr uint64_t FALLBACK_COST = 40'000; +constexpr uint64_t FALLBACK_COST = 100; MONAD_ANONYMOUS_NAMESPACE_END diff --git a/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp b/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp index 9e03b56313..c18dc7dbaa 100644 --- a/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp +++ b/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp @@ -364,10 +364,74 @@ EXPLICIT_MONAD_TRAITS(run_dipped_into_reserve_test); TEST_F(ReserveBalanceEvm, precompile_fallback) { - auto input = std::array{}; + { + auto input = std::array{}; + + auto const m = evmc_message{ + .gas = 100, + .recipient = RESERVE_BALANCE_CA, + .sender = account_a, + .input_data = input.data(), + .input_size = input.size(), + .code_address = RESERVE_BALANCE_CA, + }; + + init_reserve_balance_context>( + state, + Address{m.sender}, + empty_tx, + h.base_fee_per_gas_, + h.i_, + h.chain_ctx_); + + auto const result = h.call(m); + EXPECT_EQ(result.status_code, EVMC_REVERT); + EXPECT_EQ(result.gas_left, 0); + EXPECT_EQ(result.gas_refund, 0); + EXPECT_EQ(result.output_size, 20); + + auto const message = std::string_view{ + reinterpret_cast(result.output_data), 20}; + EXPECT_EQ(message, "method not supported"); + } + + // Not enough gas to execute fallback, should fail with OOG and not REVERT + { + auto input = std::array{}; + + auto const m = evmc_message{ + .gas = 99, + .recipient = RESERVE_BALANCE_CA, + .sender = account_a, + .input_data = input.data(), + .input_size = input.size(), + .code_address = RESERVE_BALANCE_CA, + }; + + init_reserve_balance_context>( + state, + Address{m.sender}, + empty_tx, + h.base_fee_per_gas_, + h.i_, + h.chain_ctx_); + + auto const result = h.call(m); + EXPECT_EQ(result.status_code, EVMC_OUT_OF_GAS); + EXPECT_EQ(result.gas_left, 0); + EXPECT_EQ(result.gas_refund, 0); + EXPECT_EQ(result.output_size, 0); + } +} + +TEST_F(ReserveBalanceEvm, precompile_dipped_into_reserve_present) +{ + u32_be selector = abi_encode_selector("dippedIntoReserve()"); + auto const *s = selector.bytes; + auto input = std::array{s[0], s[1], s[2], s[3]}; auto const m = evmc_message{ - .gas = 40'000, + .gas = 100, .recipient = RESERVE_BALANCE_CA, .sender = account_a, .input_data = input.data(), @@ -384,24 +448,20 @@ TEST_F(ReserveBalanceEvm, precompile_fallback) h.chain_ctx_); auto const result = h.call(m); - EXPECT_EQ(result.status_code, EVMC_REVERT); + EXPECT_EQ(result.status_code, EVMC_SUCCESS); EXPECT_EQ(result.gas_left, 0); EXPECT_EQ(result.gas_refund, 0); - EXPECT_EQ(result.output_size, 20); - - auto const message = std::string_view{ - reinterpret_cast(result.output_data), 20}; - EXPECT_EQ(message, "method not supported"); + EXPECT_EQ(result.output_size, 32); } -TEST_F(ReserveBalanceEvm, precompile_dipped_into_reserve_present) +TEST_F(ReserveBalanceEvm, precompile_dipped_into_reserve_oog) { u32_be selector = abi_encode_selector("dippedIntoReserve()"); auto const *s = selector.bytes; auto input = std::array{s[0], s[1], s[2], s[3]}; auto const m = evmc_message{ - .gas = 100, + .gas = 99, .recipient = RESERVE_BALANCE_CA, .sender = account_a, .input_data = input.data(), @@ -418,10 +478,10 @@ TEST_F(ReserveBalanceEvm, precompile_dipped_into_reserve_present) h.chain_ctx_); auto const result = h.call(m); - EXPECT_EQ(result.status_code, EVMC_SUCCESS); + EXPECT_EQ(result.status_code, EVMC_OUT_OF_GAS); EXPECT_EQ(result.gas_left, 0); EXPECT_EQ(result.gas_refund, 0); - EXPECT_EQ(result.output_size, 32); + EXPECT_EQ(result.output_size, 0); } TEST_F(ReserveBalanceEvm, precompile_dipped_into_reserve_with_argument) From 3b7d52ded2195d3c24b2a1d4f994534f07abc955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hillerstr=C3=B6m?= <1827113+dhil@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:42:02 -0500 Subject: [PATCH 2/2] [monad] Reserve balance contract wellformedness checks This patch adds tests, which checks that the order of wellformedness checks for calling the reserve contract is consistent with the order specified by the Monad Foundation. --- .../reserve_balance_contract_test.cpp | 297 +++++++++++++++++- 1 file changed, 296 insertions(+), 1 deletion(-) diff --git a/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp b/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp index c18dc7dbaa..53dca29ca0 100644 --- a/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp +++ b/category/execution/monad/reserve_balance/reserve_balance_contract_test.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -48,11 +49,12 @@ #include #include -#include +#include #include #include +#include #include using namespace monad; @@ -544,3 +546,296 @@ TYPED_TEST(MonadTraitsTest, reverttransaction_revert) run_dipped_into_reserve_test(15, 11, outcome); } + +template + requires is_monad_trait_v +void run_check_call_precompile_test( + State &state, evmc_message const &msg, evmc_status_code expected_status, + std::string_view expected_message = "") +{ + NoopCallTracer call_tracer; + auto const result = check_call_precompile(state, call_tracer, msg); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->status_code, expected_status); + EXPECT_EQ(result->gas_left, 0); + EXPECT_EQ(result->gas_refund, 0); + EXPECT_EQ(result->output_size, expected_message.size()); + + auto const message = std::string_view{ + reinterpret_cast(result->output_data), + expected_message.size()}; + EXPECT_EQ(message, expected_message); +} + +template +struct MonadPrecompileTest : public ::MonadTraitsTest +{ + static constexpr auto account_a = Address{0xdeadbeef}; + + OnDiskMachine machine; + vm::VM vm; + mpt::Db db{machine}; + TrieDb tdb{db}; + BlockState bs{tdb, vm}; + State state{bs, Incarnation{0, 0}}; + NoopCallTracer call_tracer; + + BlockHashBufferFinalized const block_hash_buffer; + Transaction const empty_tx{}; + + ankerl::unordered_dense::segmented_set
const + grandparent_senders_and_authorities{}; + ankerl::unordered_dense::segmented_set
const + parent_senders_and_authorities{}; + ankerl::unordered_dense::segmented_set
const + senders_and_authorities{}; + // The {}s are needed here to pass the 0 < senders.size() assertion checks + // in `dipped_into_reserve`. + std::vector
const senders{{}}; + std::vector>> const authorities{{}}; + ChainContext> const chain_ctx{ + grandparent_senders_and_authorities, + parent_senders_and_authorities, + senders_and_authorities, + senders, + authorities}; + + EvmcHost> h{ + call_tracer, + EMPTY_TX_CONTEXT, + block_hash_buffer, + state, + empty_tx, + 0, + 0, + chain_ctx}; +}; + +DEFINE_MONAD_TRAITS_FIXTURE(MonadPrecompileTest); + +TYPED_TEST( + MonadPrecompileTest, precompile_dipped_into_reserve_wellformedness_checks) +{ + u32_be const selector = abi_encode_selector("dippedIntoReserve()"); + byte_string const calldata = {selector.bytes, 4}; + // Generates a basic OK message + auto const make_msg = [this, &calldata]() -> evmc_message { + return evmc_message{ + .kind = EVMC_CALL, + .flags = 0, + .gas = 100, + .recipient = RESERVE_BALANCE_CA, + .sender = this->account_a, + .input_data = calldata.data(), + .input_size = calldata.size(), + .code_address = RESERVE_BALANCE_CA, + }; + }; + + if constexpr (TestFixture::Trait::monad_rev() < MONAD_NINE) { + // The precompile should be unavailable prior to MONAD_NINE. + NoopCallTracer call_tracer; + auto const result = check_call_precompile( + this->state, call_tracer, make_msg()); + EXPECT_FALSE(result.has_value()); + return; + } + + ASSERT_TRUE(is_precompile(RESERVE_BALANCE_CA)); + + // Wellformedness checking order is specified as: + // clang-format off + // 1. Invocation method is not `CALL`: Reject with message "" + // 2. gas < 100: OOG with message "" + // 3. len(calldata) < 4 => Reject with message "method not supported" + // 4. calldata[:4] != dippedIntoReserve.selector: Reject with message "method not supported" + // 5. calldata[:4] == dippedIntoReserve.selector && value > 0: Reject with message "value is nonzero" + // 6. calldata[:4] == dippedIntoReserve.selector && len(calldata) > 4: Reject with message "input is invalid" + // clang-format on + + uint8_t const *s = selector.bytes; + std::vector> calldata_variants = { + {s[0], s[1], s[2]}, // too short + {s[0], s[1], s[2], s[3]}, // correct selector + {s[0], s[1], s[2], s[3], 0x00}, // too long + {0xFF, 0xFF, 0xFF, 0xFF} // wrong selector + }; + + // 1. Invocation method is not `CALL`: Reject with message "" + { + for (auto const call_kind : + {EVMC_CALL, + EVMC_DELEGATECALL, + EVMC_CALLCODE, + EVMC_CREATE, + EVMC_CREATE2, + EVMC_EOFCREATE}) { + + evmc_message msg = make_msg(); + msg.kind = call_kind; + + for (int64_t const gas : std::initializer_list{99, 100}) { + msg.gas = gas; + for (uint8_t const flags : std::initializer_list{ + 0u, + static_cast(EVMC_STATIC), + static_cast(EVMC_DELEGATED), + static_cast(EVMC_STATIC) | + static_cast(EVMC_DELEGATED)}) { + if (call_kind == EVMC_CALL && flags == 0u) { + // This is the valid CALL case, which should be + // accepted, so skip it in this loop and test it in the + // loops below. + continue; + } + msg.flags = flags; + + for (evmc_uint256be const value : + std::initializer_list{ + 0x00_bytes32, 0x01_bytes32}) { + msg.value = value; + + for (auto const &calldata_variant : calldata_variants) { + msg.input_data = calldata_variant.data(); + msg.input_size = calldata_variant.size(); + + run_check_call_precompile_test< + typename TestFixture::Trait>( + this->state, msg, EVMC_REJECTED); + } + } + } + } + } + } + + // 2. gas < 100: OOG with message "" + { + evmc_message msg = make_msg(); + msg.gas = 99; + + for (evmc_uint256be const value : std::initializer_list{ + 0x00_bytes32, 0x01_bytes32}) { + msg.value = value; + + for (auto const &calldata_variant : calldata_variants) { + msg.input_data = calldata_variant.data(); + msg.input_size = calldata_variant.size(); + + run_check_call_precompile_test( + this->state, msg, EVMC_OUT_OF_GAS); + } + } + } + + // 3. len(calldata) < 4: Reject with message "method not supported" + { + evmc_message msg = make_msg(); + + std::array short3 = {s[0], s[1], s[2]}; + std::array short2 = {s[0], s[1]}; + std::array short1 = {s[0]}; + std::array short0 = {}; + std::vector> short_calldata_variants = { + {short3.data(), short3.size()}, + {short2.data(), short2.size()}, + {short1.data(), short1.size()}, + {short0.data(), short0.size()}, + {nullptr, 0}, + }; + for (auto const &[data, size] : short_calldata_variants) { + msg.input_data = data; + msg.input_size = size; + + for (evmc_uint256be const value : + std::initializer_list{ + 0x00_bytes32, 0x01_bytes32}) { + msg.value = value; + + run_check_call_precompile_test( + this->state, msg, EVMC_REVERT, "method not supported"); + } + } + } + + // Case 4. calldata[:4] != dippedIntoReserve.selector: Reject with message + // "method not supported" + { + evmc_message msg = make_msg(); + + std::array wrong_selector = {0xFF, 0xFF, 0xFF, 0xFF}; + std::array wrong_too_long = {s[0], s[1], s[2], 0xFF, 0x00}; + std::vector> wrong_calldata = { + {wrong_selector.data(), wrong_selector.size()}, + {wrong_too_long.data(), wrong_too_long.size()}, + }; + + for (evmc_uint256be const value : std::initializer_list{ + 0x00_bytes32, 0x01_bytes32}) { + msg.value = value; + + for (auto const &[data, size] : wrong_calldata) { + msg.input_data = data; + msg.input_size = size; + run_check_call_precompile_test( + this->state, msg, EVMC_REVERT, "method not supported"); + } + } + } + + // Case 5. calldata[:4] == dippedIntoReserve.selector && value > 0: Reject + // with message "value is nonzero" + { + evmc_message msg = make_msg(); + msg.value = 0x01_bytes32; + + std::array selector = {s[0], s[1], s[2], s[3]}; + std::array too_long = {s[0], s[1], s[2], s[3], 0x00}; + std::vector> wrong_calldata = { + {selector.data(), selector.size()}, + {too_long.data(), too_long.size()}, + }; + + for (auto const &[data, size] : wrong_calldata) { + msg.input_data = data; + msg.input_size = size; + run_check_call_precompile_test( + this->state, msg, EVMC_REVERT, "value is nonzero"); + } + } + + // Case 6. calldata[:4] == dippedIntoReserve.selector && len(calldata) > 4: + // Reject with message "input is invalid" + { + evmc_message msg = make_msg(); + std::array too_long = {s[0], s[1], s[2], s[3], 0x00}; + msg.input_data = too_long.data(); + msg.input_size = too_long.size(); + run_check_call_precompile_test( + this->state, msg, EVMC_REVERT, "input is invalid"); + } + + // Case 7: A well-formed call that should be accepted. + { + evmc_message msg = make_msg(); + + init_reserve_balance_context>( + this->state, + Address{msg.sender}, + this->empty_tx, + this->h.base_fee_per_gas_, + this->h.i_, + this->h.chain_ctx_); + + std::array expected_message{}; + + std::string_view expected_message_view{ + reinterpret_cast(expected_message.data()), + expected_message.size(), + }; + + run_check_call_precompile_test( + this->state, msg, EVMC_SUCCESS, expected_message_view); + } +}