diff --git a/cmake/dependencies.boost.cmake b/cmake/dependencies.boost.cmake index a3888b17a3..74786a789b 100644 --- a/cmake/dependencies.boost.cmake +++ b/cmake/dependencies.boost.cmake @@ -34,6 +34,7 @@ set(BOOST_COMPONENTS system thread unit_test_framework + url ) foreach (COMPONENT ${BOOST_COMPONENTS}) find_package(boost_${COMPONENT} ${BOOST_VERSION} EXACT CONFIG REQUIRED) diff --git a/libraries/custom_appbase/include/sysio/chain/app.hpp b/libraries/custom_appbase/include/sysio/chain/app.hpp index 01e35146e0..3e9ca68780 100644 --- a/libraries/custom_appbase/include/sysio/chain/app.hpp +++ b/libraries/custom_appbase/include/sysio/chain/app.hpp @@ -77,7 +77,8 @@ void log_non_default_options(const std::vector>& options for (const auto& op : options) { bool mask = false; if (op.string_key == "peer-private-key"s - || op.string_key == "p2p-auto-bp-peer"s) { + || op.string_key == "p2p-auto-bp-peer"s + || op.string_key == "beacon-chain-api-key"s) { mask = true; } std::string v; diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_abi.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_abi.hpp index ad81db2a85..736102600a 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_abi.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_abi.hpp @@ -17,7 +17,7 @@ namespace fc::network::ethereum { namespace abi { -enum class invoke_target_type { function, constructor, event, error }; +enum class invoke_target_type { function, constructor, event, error, receive }; enum class data_type : int64_t { boolean, @@ -196,7 +196,8 @@ using contract_invoke_data_items = std::vector; * Encode a contract call * @return hex string of encoded call `data` field in RLP format */ -std::string contract_encode_data(const abi::contract& contract, const contract_invoke_data_items& params); +std::string contract_encode_data(const abi::contract& contract, const contract_invoke_data_items& params, + bool add_hex_prefix = false); template concept not_abi_data_params_t = !std::is_same_v, contract_invoke_data_items>; @@ -297,7 +298,7 @@ struct get_typename { }; }; // namespace fc -FC_REFLECT_ENUM(fc::network::ethereum::abi::invoke_target_type, (function)(constructor)(event)(error)); +FC_REFLECT_ENUM(fc::network::ethereum::abi::invoke_target_type, (function)(constructor)(event)(error)(receive)); FC_REFLECT(fc::network::ethereum::abi::component_type::list_config_type, (is_list)(size)); FC_REFLECT(fc::network::ethereum::abi::component_type, (name)(type)(list_config)(components)(internal_type)); diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp index e3ccf0c48e..28fa078b12 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp @@ -406,6 +406,13 @@ class ethereum_client : public std::enable_shared_from_this { */ std::string send_raw_transaction(const std::string& raw_tx_data); + /** + * @brief Retrieves the transaction receipt and extracts the block number. + * @param tx_hash The transaction hash + * @return The block number if the receipt is available, or std::nullopt if not yet mined + */ + std::optional get_block_for_transaction(const std::string& tx_hash); + /** * @brief Submit a raw transaction and await inclusion + N confirmations. * diff --git a/libraries/libfc/src/io/json.cpp b/libraries/libfc/src/io/json.cpp index 268703d4b6..31000ee16e 100644 --- a/libraries/libfc/src/io/json.cpp +++ b/libraries/libfc/src/io/json.cpp @@ -1,6 +1,8 @@ #include //#include //#include +#include +#include #include //#include #include @@ -28,6 +30,19 @@ namespace fc #include +namespace +{ + + // Max |value| decimal strings used to pick the smallest variant bucket that fits a token. + // Signed entries use the magnitude of *_MIN (one greater than *_MAX); unsigned entries use *_MAX. + constexpr std::string_view int64_max_magnitude_str = "9223372036854775808"; // | INT64_MIN| + constexpr std::string_view uint64_max_magnitude_str = "18446744073709551615"; // |UINT64_MIN| + constexpr std::string_view int256_max_str = + "57896044618658097711785492504343953926634992332820282019728792003956564819968"; + constexpr std::string_view uint256_max_str = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; +} + namespace fc { namespace { @@ -307,6 +322,7 @@ namespace fc s += in.get(); } bool done = false; + variant ret; try { @@ -337,7 +353,8 @@ namespace fc if( isalnum( c ) ) { s += string_from_token( in ); - return s; + ret = std::move(s); + return ret; } done = true; break; @@ -350,14 +367,76 @@ namespace fc catch (const std::ios_base::failure&) { } - const std::string& str = s; - if (str == "-." || str == "." || str == "-") // check the obviously wrong things we could have encountered - FC_THROW_EXCEPTION(parse_error_exception, "Can't parse token \"{}\" as a JSON numeric constant", str); - if( dot ) - return parser_type == json::parse_type::legacy_parser_with_string_doubles ? variant(str) : variant(to_double(str)); - if( neg ) - return to_int64(str); - return to_uint64(str); + + const auto no_neg_start = neg ? 1 : 0; + const auto start = s.find_first_not_of('0', no_neg_start); + const auto str = (start != std::string::npos) + ? std::string_view(s).substr(start) + : std::string_view{}; + + // if the string is empty and we had actual digits after the sign + if (str.empty() && s.size() > no_neg_start) { + ret = 0u; + return ret; + } + + // check for s== ".", "-","-.", since "[-]0*" is checked above + if (str == "." || str.empty()) // check the obviously wrong things we could have encountered + FC_THROW_EXCEPTION(parse_error_exception, "Can't parse token \"{}\" as a JSON numeric constant", str); + + if( dot ) { + ret = parser_type == json::parse_type::legacy_parser_with_string_doubles + ? variant(std::move(s)) + : variant(to_double(s)); + return ret; + } + + if( neg ) { + // Common case: fits in int64 + if (str.length() < int64_max_magnitude_str.length() || + (str.length() == int64_max_magnitude_str.length() && str <= int64_max_magnitude_str)) { + ret = to_int64(s); + return ret; + } + + if (str.length() > int256_max_str.length() || + (str.length() == int256_max_str.length() && str > int256_max_str)) { + FC_THROW_EXCEPTION(parse_error_exception, + "Negative numeric token \"{}\" exceeds int256 range", s); + } + + if (str == int256_max_str) { + const auto val = std::numeric_limits::min(); + ret = val; + return ret; + } + + // using the string with no leading 0s, to avoid the string being assumed to be in octal, + // since a leading 0 with only digits between 0 and 7 are assumed to be octal + fc::int256 val256(str); + val256 = -val256; + ret = std::move(val256); + return ret; + } + + // Common case: fits in uint64 + if (str.length() < uint64_max_magnitude_str.length() || + (str.length() == uint64_max_magnitude_str.length() && str <= uint64_max_magnitude_str)) { + ret = to_uint64(s); + return ret; + } + + if (str.length() > uint256_max_str.length() || + (str.length() == uint256_max_str.length() && str > uint256_max_str)) { + FC_THROW_EXCEPTION(parse_error_exception, + "Numeric token \"{}\" exceeds uint256 range", s); + } + + // using the string with no leading 0s, to avoid the string being assumed to be in octal, + // since a leading 0 with only digits between 0 and 7 are assumed to be octal + fc::uint256 val256(str); + ret = std::move(val256); + return ret; } template diff --git a/libraries/libfc/src/network/ethereum/ethereum_abi.cpp b/libraries/libfc/src/network/ethereum/ethereum_abi.cpp index d8e4fab25d..5a4b667bdc 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_abi.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_abi.cpp @@ -846,7 +846,8 @@ abi::contract abi::parse_contract(const fc::variant& v) { * @return Hex string of encoded call data (selector + encoded parameters) * @throws fc::exception if parameter count mismatches or encoding fails */ -std::string contract_encode_data(const abi::contract& contract, const std::vector& params) { +std::string contract_encode_data(const abi::contract& contract, const std::vector& params, + bool add_hex_prefix) { const auto& inputs = contract.inputs; FC_ASSERT_FMT(inputs.size() == params.size(), "Parameter count mismatch (expected={}, provided={})", inputs.size(), params.size()); @@ -890,7 +891,7 @@ std::string contract_encode_data(const abi::contract& contract, const std::vecto out.insert(out.end(), tail.begin(), tail.end()); } - return fc::to_hex(out); + return fc::to_hex(out, add_hex_prefix); } @@ -1058,7 +1059,12 @@ void fc::from_variant(const fc::variant& var, fc::network::ethereum::abi::contra FC_ASSERT(var.is_object(), "Variant must be an object to deserialize ABI contract"); auto& obj = var.get_object(); - vo.name = obj["name"].as_string(); + const auto name_itr = obj.find("name"); + const bool no_name = name_itr == obj.end(); + if (!no_name) { + vo.name = name_itr->value().as_string(); + } + auto type_str = obj["type"].as_string(); vo.type = fc::reflector::from_string(type_str.c_str()); @@ -1079,4 +1085,10 @@ void fc::from_variant(const fc::variant& var, fc::network::ethereum::abi::contra parse_components(vo.inputs, "inputs"); parse_components(vo.outputs, "outputs"); + if(no_name) { + // we expect ABI contracts that have the legacy payment ethereum interface + const bool valid = type_str == "receive" && + obj["stateMutability"].as_string() == "payable"; + FC_ASSERT(valid, "Variant Object must have a `name` key to deserialize ABI contract"); + } } diff --git a/libraries/libfc/src/network/ethereum/ethereum_client.cpp b/libraries/libfc/src/network/ethereum/ethereum_client.cpp index 5de017f576..fb432e346d 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_client.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_client.cpp @@ -86,7 +86,7 @@ fc::variant ethereum_client::execute(const std::string& method, const fc::varian fc::variant ethereum_client::execute_contract_view_fn(const address& contract_address, const abi::contract& abi, const std::string& block_tag, const contract_invoke_data_items& params) { - auto abi_call_encoded = contract_encode_data(abi, params); + auto abi_call_encoded = contract_encode_data(abi, params, true); auto to_data_mvo = fc::mutable_variant_object("to", to_hex(contract_address, true))("data", abi_call_encoded); fc::variants rpc_params = {to_data_mvo, fc::variant(block_tag)}; return execute("eth_call", rpc_params); @@ -120,7 +120,7 @@ fc::variant ethereum_client::execute_contract_tx_fn(const eip1559_tx& source_tx, tx_encoded = rlp::encode_eip1559_signed_typed(tx); } - return send_raw_transaction(to_hex(tx_encoded)); + return send_raw_transaction(to_hex(tx_encoded, true)); } @@ -478,6 +478,16 @@ std::string ethereum_client::send_raw_transaction(const std::string& raw_tx_data return resp.as_string(); } +std::optional ethereum_client::get_block_for_transaction(const std::string& tx_hash) { + auto receipt = get_transaction_receipt(tx_hash); + if (receipt.is_null()) + return std::nullopt; + const auto& obj = receipt.get_object(); + if (!obj.contains("blockNumber") || obj["blockNumber"].is_null()) + return std::nullopt; + return static_cast(to_uint256(obj["blockNumber"])); +} + std::string ethereum_client::wait_for_confirmation(const std::string& tx_hash, const ethereum_confirm_options& opts) { // Phase 1: wait for the receipt to exist. A null result means the tx diff --git a/libraries/libfc/test/io/test_json_variant.cpp b/libraries/libfc/test/io/test_json_variant.cpp index 0c1b049f0e..8ef0a99702 100644 --- a/libraries/libfc/test/io/test_json_variant.cpp +++ b/libraries/libfc/test/io/test_json_variant.cpp @@ -1,9 +1,13 @@ #include #include +#include +#include #include #include +#include + using namespace fc; BOOST_AUTO_TEST_SUITE(json_variant_test_suite) @@ -142,4 +146,194 @@ BOOST_AUTO_TEST_CASE(variant_numeric_conversions) { BOOST_CHECK_CLOSE(obj["d"].as_double(), 3.14, 0.001); } -BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file +// --------------------------------------------------------------------------- +// number_from_stream - negative integer type boundaries +// +// The parser strips the minus sign and leading zeros, leaving the absolute +// value string (str). Routing is binary (no int128 variant): +// str.size() < 19 OR (size==19 AND str <= "9223372036854775808") -> int64 +// str.size() > 78 OR (size==78 AND str > int256_max_str) -> throws +// (exceeds int256 magnitude) +// otherwise -> int256 +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_small) { + variant v = json::from_string("-1"); + BOOST_CHECK(v.is_int64()); + BOOST_CHECK_EQUAL(v.as_int64(), -1LL); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_int64_max) { + // -INT64_MAX (abs one less than the threshold) -> int64 + variant v = json::from_string("-9223372036854775807"); + BOOST_CHECK(v.is_int64()); + BOOST_CHECK_EQUAL(v.as_int64(), -9223372036854775807LL); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_int64_min) { + // INT64_MIN = -9223372036854775808 (abs exactly equal to threshold) -> int64 + variant v = json::from_string("-9223372036854775808"); + BOOST_CHECK(v.is_int64()); + BOOST_CHECK_EQUAL(v.as_int64(), std::numeric_limits::min()); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_int64_min_minus_one) { + // INT64_MIN - 1 = -9223372036854775809 (abs one past threshold) -> int256 + variant v = json::from_string("-9223372036854775809"); + BOOST_CHECK(v.is_int256()); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_near_int256_max) { + // 39-digit value well within int256 magnitude -> int256 + variant v = json::from_string("-170141183460469231731687303715884105727"); + BOOST_CHECK(v.is_int256()); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_int256_min) { + // INT256_MIN magnitude = 2^255 = 77 digits -> int256 + variant v = json::from_string("-57896044618658097711785492504343953926634992332820282019728792003956564819968"); + BOOST_CHECK(v.is_int256()); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_exceeds_int256_throws) { + // 78 digits, first digit >= 6 -> magnitude > INT256_MIN magnitude -> throws + BOOST_CHECK_THROW(json::from_string("-60000000000000000000000000000000000000000000000000000000000000000000000000000"), + fc::parse_error_exception); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_length_ceiling_throws) { + // 79-digit magnitude exceeds any int256 value -> throws + BOOST_CHECK_THROW(json::from_string("-1000000000000000000000000000000000000000000000000000000000000000000000000000000"), + fc::parse_error_exception); +} + +// --------------------------------------------------------------------------- +// number_from_stream - positive integer type boundaries +// +// Routing is binary (no uint128 variant): +// str.size() < 20 OR (size==20 AND str <= "18446744073709551615") -> uint64 +// str.size() > 78 OR (size==78 AND str > uint256_max_str) -> throws +// (exceeds uint256) +// otherwise -> uint256 +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(number_from_stream_positive_small) { + variant v = json::from_string("1"); + BOOST_CHECK(v.is_uint64()); + BOOST_CHECK_EQUAL(v.as_uint64(), 1ull); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_positive_uint64_max) { + // UINT64_MAX = 18446744073709551615 (exactly at threshold) → uint64 + const auto max = std::numeric_limits::max(); + const auto str = std::to_string(max); + variant v = json::from_string(str); + BOOST_CHECK(v.is_uint64()); + BOOST_CHECK_EQUAL(v.as_uint64(), max); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_positive_uint64_max_plus_one) { + // UINT64_MAX + 1 = 18446744073709551616 (one past threshold) -> uint256 + fc::uint256 max_plus = fc::uint256(std::numeric_limits::max()) + 1; + variant v = json::from_string(max_plus.str()); + BOOST_CHECK(v.is_uint256()); + BOOST_CHECK_EQUAL(v.as_uint256(), max_plus); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_positive_far_past_uint64) { + // 39-digit value well within uint256 -> uint256 + const auto max = std::numeric_limits::max(); + variant v = json::from_string(fc::to_string(max)); + BOOST_CHECK(v.is_uint256()); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_positive_uint256_max) { + // UINT256_MAX = 2^256 - 1 = 78 digits, exactly at the throwing boundary + variant v = json::from_string("115792089237316195423570985008687907853269984665640564039457584007913129639935"); + BOOST_CHECK(v.is_uint256()); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_positive_uint256_max_plus_one_throws) { + // UINT256_MAX + 1 = 2^256 -> throws, same length but lexicographically greater than max + BOOST_CHECK_THROW(json::from_string("115792089237316195423570985008687907853269984665640564039457584007913129639936"), + fc::parse_error_exception); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_positive_length_ceiling_throws) { + // 79-digit input -> throws regardless of value + BOOST_CHECK_THROW(json::from_string("9999999999999999999999999999999999999999999999999999999999999999999999999999999"), + fc::parse_error_exception); +} + +// --------------------------------------------------------------------------- +// number_from_stream - zero-handling / leading-zero edge cases +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(number_from_stream_plain_zero) { + variant v = json::from_string("0"); + BOOST_CHECK(v.is_uint64()); + BOOST_CHECK_EQUAL(v.as_uint64(), 0ull); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_double_zero) { + variant v = json::from_string("00"); + BOOST_CHECK(v.is_uint64()); + BOOST_CHECK_EQUAL(v.as_uint64(), 0ull); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_zero) { + variant v = json::from_string("-0"); + BOOST_CHECK(v.is_uint64()); + BOOST_CHECK_EQUAL(v.as_uint64(), 0ull); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_many_zeros) { + // 30 zeros: all digits stripped, should parse as uint64(0), not promote to int256. + variant v = json::from_string("000000000000000000000000000000"); + BOOST_CHECK(v.is_uint64()); + BOOST_CHECK_EQUAL(v.as_uint64(), 0ull); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_leading_zeros_fit_uint64) { + // Leading zeros stripped before length check -> should route to uint64 + variant v = json::from_string("0000000000000000000000042"); + BOOST_CHECK(v.is_uint64()); + BOOST_CHECK_EQUAL(v.as_uint64(), 42ull); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_leading_zeros_fit_int64) { + // Leading zeros stripped before length check -> should route to uint64 + variant v = json::from_string("-0000000000000000000000042"); + BOOST_CHECK(v.is_int64()); + BOOST_CHECK_EQUAL(v.as_int64(), -42ll); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_dot_integer_fit_double) { + variant v = json::from_string("-.1"); + BOOST_CHECK(v.is_double()); + BOOST_CHECK_EQUAL(v.as_double(), -0.1); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_dot_integer_fit_double) { + variant v = json::from_string(".1"); + BOOST_CHECK(v.is_double()); + BOOST_CHECK_EQUAL(v.as_double(), 0.1); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_bare_minus_throws) { + BOOST_CHECK_THROW(json::from_string("-"), fc::parse_error_exception); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_bare_minus_in_array_throws) { + BOOST_CHECK_THROW(json::from_string("[-,1]"), fc::parse_error_exception); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_negative_dot_throws) { + BOOST_CHECK_THROW(json::from_string("-."), fc::parse_error_exception); +} + +BOOST_AUTO_TEST_CASE(number_from_stream_dot_throws) { + BOOST_CHECK_THROW(json::from_string("."), fc::parse_error_exception); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index ed8ed45535..c45f14e8b3 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -23,3 +23,4 @@ add_subdirectory(outpost_solana_client_plugin) add_subdirectory(batch_operator_plugin) add_subdirectory(external_debugging_plugin) add_subdirectory(underwriter_plugin) +add_subdirectory(wire_eth_maintenance_plugin) diff --git a/plugins/cron_plugin/CRON_PARSER_USAGE.md b/plugins/cron_plugin/CRON_PARSER_USAGE.md new file mode 100644 index 0000000000..0e50fafd27 --- /dev/null +++ b/plugins/cron_plugin/CRON_PARSER_USAGE.md @@ -0,0 +1,193 @@ +# Cron Schedule Parser Usage + +The cron parser converts standard cron expression strings into `job_schedule` objects for use with the cron service. + +## Include + +```cpp +#include +``` + +## API Functions + +### `parse_cron_schedule()` +```cpp +std::optional parse_cron_schedule(std::string_view cron_expr); +``` +Returns `std::nullopt` on parse error. + +### `parse_cron_schedule_or_throw()` +```cpp +cron_service::job_schedule parse_cron_schedule_or_throw(std::string_view cron_expr); +``` +Throws `fc::exception` on parse error. + +## Supported Formats + +### Standard 5-field format +``` +minute hour day-of-month month day-of-week +``` + +### Extended 6-field format (with milliseconds) +``` +milliseconds minute hour day-of-month month day-of-week +``` + +## Field Ranges + +| Field | Range | Special | +|----------------|-------------|---------| +| milliseconds | 0-59999 | Optional (6-field format only) | +| minute | 0-59 | Required | +| hour | 0-23 | Required | +| day-of-month | 1-31 | Required | +| month | 1-12 | Required | +| day-of-week | 0-7 | Required (0 and 7 = Sunday) | + +## Syntax Elements + +| Syntax | Example | Description | +|--------|---------|-------------| +| `*` | `* * * * *` | Wildcard - matches all values | +| Exact value | `30 9 * * *` | Matches exactly that value | +| Range | `9-17` | Matches all values in range (inclusive) | +| Step | `*/5` | Every N units (e.g., every 5 minutes) | +| Range+Step | `10-50/10` | Step within range (10,20,30,40,50) | +| List | `1,3,5,7` | Comma-separated list of values | + +## Usage Examples + +### Basic Usage + +```cpp +#include +#include + +using namespace sysio::services; + +// Parse a cron expression +auto sched_opt = parse_cron_schedule("*/5 * * * *"); +if (sched_opt) { + // Add job to cron service + auto& cron = app().get_plugin(); + cron.add_job(*sched_opt, []() { + ilog("Job runs every 5 minutes"); + }); +} +``` + +### With Error Handling + +```cpp +try { + auto sched = parse_cron_schedule_or_throw("0 9-17 * * 1-5"); + + auto& cron = app().get_plugin(); + cron.add_job(sched, []() { + ilog("Business hours job (9 AM - 5 PM, weekdays)"); + }, cron_service::job_metadata_t{ + .tags = {"business-hours"}, + .label = "hourly_weekday_job" + }); +} catch (const fc::exception& e) { + elog("Failed to parse cron schedule: {}", e.to_detail_string()); +} +``` + +### Common Schedule Examples + +```cpp +// Every minute +auto every_minute = parse_cron_schedule("* * * * *"); + +// Every hour at minute 0 +auto hourly = parse_cron_schedule("0 * * * *"); + +// Every day at midnight +auto daily = parse_cron_schedule("0 0 * * *"); + +// Every 15 minutes +auto every_15_min = parse_cron_schedule("*/15 * * * *"); + +// At 0, 15, 30, and 45 minutes past every hour +auto quarterly = parse_cron_schedule("0,15,30,45 * * * *"); + +// Business hours: 9 AM - 5 PM, Monday-Friday +auto business_hours = parse_cron_schedule("0 9-17 * * 1-5"); + +// First day of every month at midnight +auto monthly = parse_cron_schedule("0 0 1 * *"); + +// Every Sunday at 2 AM +auto weekly = parse_cron_schedule("0 2 * * 0"); + +// Every 5 seconds (extended format with milliseconds) +auto every_5_seconds = parse_cron_schedule("*/5000 * * * * *"); + +// At exactly 5.5 seconds past every minute (extended format) +auto precise_timing = parse_cron_schedule("5500 * * * * *"); +``` + +### Complex Schedules + +```cpp +// Every 10 minutes between 8 AM and 8 PM on weekdays +auto complex1 = parse_cron_schedule("*/10 8-20 * * 1-5"); + +// At 9:30 AM on the 1st and 15th of every month +auto complex2 = parse_cron_schedule("30 9 1,15 * *"); + +// Every 2 hours during business hours +auto complex3 = parse_cron_schedule("0 9-17/2 * * *"); +// This gives: 9 AM, 11 AM, 1 PM, 3 PM, 5 PM + +// Every 30 seconds (extended format) +auto frequent = parse_cron_schedule("*/30000 * * * * *"); +``` + +## Integration with Beacon Chain Update Plugin + +```cpp +void wire_eth_maintenance_plugin::plugin_startup() { + // Parse schedule from config string + std::string schedule_str = "0 */6 * * *"; // Every 6 hours + + try { + auto sched = parse_cron_schedule_or_throw(schedule_str); + + auto& cron = app().get_plugin(); + auto job_id = cron.add_job(sched, [this]() { + // Update beacon chain data + update_beacon_chain_data(); + }, cron_service::job_metadata_t{ + .one_at_a_time = true, + .tags = {"beacon-chain", "update"}, + .label = "beacon_chain_update" + }); + + ilog("Scheduled beacon chain update job: {}", job_id); + } catch (const fc::exception& e) { + elog("Failed to schedule beacon chain update: {}", e.to_detail_string()); + } +} +``` + +## Validation + +The parser automatically validates: +- Field count (must be 5 or 6) +- Value ranges for each field +- Range ordering (start <= end) +- Step values (must be > 0) +- Numeric parsing + +Invalid expressions return `std::nullopt` or throw `fc::exception`. + +## Notes + +- Empty sets (from `*`) mean "match all values" - this is evaluated by the cron scheduler +- The parser does not validate calendar logic (e.g., February 31st will parse but never trigger) +- Day of week: both 0 and 7 represent Sunday +- Milliseconds field enables sub-minute precision (6-field format only) +- Multiple spaces between fields are allowed and ignored diff --git a/plugins/cron_plugin/include/sysio/services/cron_parser.hpp b/plugins/cron_plugin/include/sysio/services/cron_parser.hpp new file mode 100644 index 0000000000..002a8c3e0a --- /dev/null +++ b/plugins/cron_plugin/include/sysio/services/cron_parser.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +namespace sysio::services { + +/** + * @brief Parses cron-style schedule strings into job_schedule objects + * + * Supports two formats: + * 1. Standard 5-field cron format: "minute hour day-of-month month day-of-week" + * 2. Extended 6-field format: "milliseconds minute hour day-of-month month day-of-week" + * + * Field syntax: + * @code + * Wildcard: * matches all values + * Exact value: 5 matches exactly 5 + * Range: 1-5 matches 1,2,3,4,5 + * Step: * /5 every 5 units + * Range with step: 10-50/5 10,15,20,25,30,35,40,45,50 + * List: 1,3,5,7 matches 1,3,5,7 + * @endcode + * + * Examples: + * @code + * "* * * * *" every minute + * "0 * * * *" every hour at minute 0 + * "0 9-17 * * 1-5" weekdays, 9 AM to 5 PM, on the hour + * "* /5 * * * *" every 5 minutes + * "0 0 1 * *" first day of every month at midnight + * "0,15,30,45 * * * *" every 15 minutes (at 0,15,30,45) + * "5000 * * * * *" every minute at 5 seconds (extended format) + * @endcode + * + * @param cron_expr Cron expression string + * @return job_schedule on success, std::nullopt on parse error + */ +std::optional parse_cron_schedule(std::string_view cron_expr); + +/** + * @brief Parses a cron schedule string, throwing on error + * + * Same as parse_cron_schedule but throws fc::exception on parse errors + * instead of returning std::nullopt. + * + * @param cron_expr Cron expression string + * @return job_schedule + * @throws fc::exception if parse fails + */ +cron_service::job_schedule parse_cron_schedule_or_throw(std::string_view cron_expr); + +} // namespace sysio::services diff --git a/plugins/cron_plugin/include/sysio/services/cron_service.hpp b/plugins/cron_plugin/include/sysio/services/cron_service.hpp index d30e066349..fedaab5911 100644 --- a/plugins/cron_plugin/include/sysio/services/cron_service.hpp +++ b/plugins/cron_plugin/include/sysio/services/cron_service.hpp @@ -3,16 +3,20 @@ #include #include #include +#include #include +#include #include +#include #include +#include #include -#include #include #include +#include #include +#include #include -#include #include namespace sysio::services { diff --git a/plugins/cron_plugin/src/services/cron_parser.cpp b/plugins/cron_plugin/src/services/cron_parser.cpp new file mode 100644 index 0000000000..0dc565a0e1 --- /dev/null +++ b/plugins/cron_plugin/src/services/cron_parser.cpp @@ -0,0 +1,236 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sysio::services { + +namespace { + +using schedule_value = cron_service::job_schedule::schedule_value; +using exact_value = cron_service::job_schedule::exact_value; +using step_value = cron_service::job_schedule::step_value; +using range_value = cron_service::job_schedule::range_value; + +// Trim ASCII whitespace (space/tab/CR/LF) from both ends. Returns a +// std::string_view into the original buffer — no allocation. +std::string_view trim(std::string_view s) { + return boost::algorithm::trim_copy_if(s, boost::algorithm::is_any_of(" \t\r\n")); +} + +// Split string by a single delimiter, preserving empty tokens +// (",foo," -> ["", "foo", ""]). Tokens are views into the original +// buffer — no allocation per token. +std::vector split(std::string_view s, char delim) { + std::vector result; + boost::algorithm::split(result, s, [delim](char c) { return c == delim; }); + return result; +} + +// Parse uint64_t from string_view +std::optional parse_uint(std::string_view s) { + uint64_t value {0}; + auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value); + if (ec == std::errc() && ptr == s.data() + s.size()) { + return value; + } + return std::nullopt; +} + +// Validate value is within range +bool validate_range(uint64_t value, uint64_t min_val, uint64_t max_val) { + return value >= min_val && value <= max_val; +} + +// Parse a single cron field (handles *, exact, range, step, list) +std::optional> parse_field(std::string_view field, + uint64_t min_val, + uint64_t max_val) { + field = trim(field); + + if (field.empty()) { + return std::nullopt; + } + + std::set result; + + // Wildcard - empty set means "all" + if (field == "*") { + return result; + } + + // Handle comma-separated list (e.g., "1,3,5,7") + auto parts = split(field, ','); + + for (auto part : parts) { + part = trim(part); + + // Check for step syntax (*/N or start-end/step) + auto slash_pos = part.find('/'); + if (slash_pos != std::string_view::npos) { + auto base = part.substr(0, slash_pos); + auto step_str = part.substr(slash_pos + 1); + + auto step_opt = parse_uint(step_str); + if (!step_opt || *step_opt == 0) { + return std::nullopt; // Invalid step + } + + if (base == "*") { + // */N - step across entire range + result.insert(step_value{*step_opt}); + } else { + // start-end/step - step within range + auto dash_pos = base.find('-'); + if (dash_pos == std::string_view::npos) { + return std::nullopt; // Invalid syntax + } + + auto start_str = base.substr(0, dash_pos); + auto end_str = base.substr(dash_pos + 1); + + auto start_opt = parse_uint(start_str); + auto end_opt = parse_uint(end_str); + + if (!start_opt || !end_opt) { + return std::nullopt; + } + + if (!validate_range(*start_opt, min_val, max_val) || + !validate_range(*end_opt, min_val, max_val) || + *start_opt > *end_opt) { + return std::nullopt; + } + + // Expand range with step into exact values + for (uint64_t i = *start_opt; i <= *end_opt; i += *step_opt) { + result.insert(exact_value{i}); + } + } + } + // Check for range syntax (e.g., "1-5") + else if (auto dash_pos = part.find('-'); dash_pos != std::string_view::npos) { + auto start_str = part.substr(0, dash_pos); + auto end_str = part.substr(dash_pos + 1); + + auto start_opt = parse_uint(start_str); + auto end_opt = parse_uint(end_str); + + if (!start_opt || !end_opt) { + return std::nullopt; + } + + if (!validate_range(*start_opt, min_val, max_val) || + !validate_range(*end_opt, min_val, max_val) || + *start_opt > *end_opt) { + return std::nullopt; + } + + result.insert(range_value{*start_opt, *end_opt}); + } + // Exact value (e.g., "5") + else { + auto value_opt = parse_uint(part); + if (!value_opt) { + return std::nullopt; + } + + if (!validate_range(*value_opt, min_val, max_val)) { + return std::nullopt; + } + + result.insert(exact_value{*value_opt}); + } + } + + return result; +} + +} // anonymous namespace + +std::optional parse_cron_schedule(std::string_view cron_expr) { + cron_expr = trim(cron_expr); + + if (cron_expr.empty()) { + return std::nullopt; + } + + // Split by whitespace + auto fields = split(cron_expr, ' '); + + // Remove empty fields (multiple spaces) + fields.erase( + std::remove_if(fields.begin(), fields.end(), + [](std::string_view s) { return trim(s).empty(); }), + fields.end() + ); + + // Must be either 5 fields (standard cron) or 6 fields (with milliseconds) + if (fields.size() != 5 && fields.size() != 6) { + return std::nullopt; + } + + cron_service::job_schedule sched; + size_t field_idx = 0; + + // If 6 fields, first is milliseconds + if (fields.size() == 6) { + auto ms_field = parse_field(fields[field_idx++], 0, 59999); + if (!ms_field) { + return std::nullopt; + } + sched.milliseconds = std::move(*ms_field); + } + + // Parse standard 5 cron fields + // Field ranges: minute (0-59), hour (0-23), day_of_month (1-31), month (1-12), day_of_week (0-7) + + // Minutes + auto minutes_field = parse_field(fields[field_idx++], 0, 59); + if (!minutes_field) { + return std::nullopt; + } + sched.minutes = std::move(*minutes_field); + + // Hours + auto hours_field = parse_field(fields[field_idx++], 0, 23); + if (!hours_field) { + return std::nullopt; + } + sched.hours = std::move(*hours_field); + + // Day of month + auto dom_field = parse_field(fields[field_idx++], 1, 31); + if (!dom_field) { + return std::nullopt; + } + sched.day_of_month = std::move(*dom_field); + + // Month + auto month_field = parse_field(fields[field_idx++], 1, 12); + if (!month_field) { + return std::nullopt; + } + sched.month = std::move(*month_field); + + // Day of week (0 and 7 both mean Sunday) + auto dow_field = parse_field(fields[field_idx++], 0, 7); + if (!dow_field) { + return std::nullopt; + } + sched.day_of_week = std::move(*dow_field); + + return sched; +} + +cron_service::job_schedule parse_cron_schedule_or_throw(std::string_view cron_expr) { + auto result = parse_cron_schedule(cron_expr); + FC_ASSERT(result.has_value(), "Failed to parse cron schedule: '{}'", cron_expr); + return std::move(*result); +} + +} // namespace sysio::services diff --git a/plugins/cron_plugin/test/test_cron_parser.cpp b/plugins/cron_plugin/test/test_cron_parser.cpp new file mode 100644 index 0000000000..ce000039a8 --- /dev/null +++ b/plugins/cron_plugin/test/test_cron_parser.cpp @@ -0,0 +1,276 @@ +#include + +#include +#include +#include + +using namespace sysio::services; +using svc = cron_service; + +BOOST_AUTO_TEST_SUITE(cron_parser_tests) + +// ----------------------------------------------------------------------- +// Basic parsing tests +// ----------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(parse_wildcard_all_fields) try { + auto sched_opt = parse_cron_schedule("* * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + // All fields should be empty (wildcard) + BOOST_CHECK(sched.milliseconds.empty()); + BOOST_CHECK(sched.minutes.empty()); + BOOST_CHECK(sched.hours.empty()); + BOOST_CHECK(sched.day_of_month.empty()); + BOOST_CHECK(sched.month.empty()); + BOOST_CHECK(sched.day_of_week.empty()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_exact_values) try { + auto sched_opt = parse_cron_schedule("30 9 15 6 1"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + BOOST_CHECK_EQUAL(sched.minutes.size(), 1); + BOOST_CHECK_EQUAL(sched.hours.size(), 1); + BOOST_CHECK_EQUAL(sched.day_of_month.size(), 1); + BOOST_CHECK_EQUAL(sched.month.size(), 1); + BOOST_CHECK_EQUAL(sched.day_of_week.size(), 1); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_range_values) try { + auto sched_opt = parse_cron_schedule("0 9-17 * * 1-5"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + // Minute 0 (exact) + BOOST_CHECK_EQUAL(sched.minutes.size(), 1); + // Hours 9-17 (range) + BOOST_CHECK_EQUAL(sched.hours.size(), 1); + // Day of week 1-5 (range) + BOOST_CHECK_EQUAL(sched.day_of_week.size(), 1); + + // Verify it's a range_value + auto hour_val = *sched.hours.begin(); + BOOST_CHECK(std::holds_alternative(hour_val)); + auto range = std::get(hour_val); + BOOST_CHECK_EQUAL(range.from, 9); + BOOST_CHECK_EQUAL(range.to, 17); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_step_values) try { + auto sched_opt = parse_cron_schedule("*/5 * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + // Minutes every 5 (step) + BOOST_CHECK_EQUAL(sched.minutes.size(), 1); + + auto minute_val = *sched.minutes.begin(); + BOOST_CHECK(std::holds_alternative(minute_val)); + auto step = std::get(minute_val); + BOOST_CHECK_EQUAL(step.step, 5); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_list_values) try { + auto sched_opt = parse_cron_schedule("0,15,30,45 * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + // Four exact minute values + BOOST_CHECK_EQUAL(sched.minutes.size(), 4); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_range_with_step) try { + auto sched_opt = parse_cron_schedule("10-50/10 * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + // Should expand to: 10, 20, 30, 40, 50 (5 values) + BOOST_CHECK_EQUAL(sched.minutes.size(), 5); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_extended_format_with_milliseconds) try { + auto sched_opt = parse_cron_schedule("5000 * * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + // Milliseconds field should have one exact value + BOOST_CHECK_EQUAL(sched.milliseconds.size(), 1); + + auto ms_val = *sched.milliseconds.begin(); + BOOST_CHECK(std::holds_alternative(ms_val)); + auto exact = std::get(ms_val); + BOOST_CHECK_EQUAL(exact.value, 5000); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_complex_schedule) try { + // Every 15 minutes, between 9 AM and 5 PM, on weekdays + auto sched_opt = parse_cron_schedule("*/15 9-17 * * 1-5"); + BOOST_REQUIRE(sched_opt.has_value()); + + auto& sched = *sched_opt; + BOOST_CHECK_EQUAL(sched.minutes.size(), 1); // step + BOOST_CHECK_EQUAL(sched.hours.size(), 1); // range + BOOST_CHECK(sched.day_of_month.empty()); // wildcard + BOOST_CHECK(sched.month.empty()); // wildcard + BOOST_CHECK_EQUAL(sched.day_of_week.size(), 1); // range +} FC_LOG_AND_RETHROW(); + +// ----------------------------------------------------------------------- +// Error handling tests +// ----------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(parse_invalid_empty_string) try { + auto sched_opt = parse_cron_schedule(""); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_invalid_too_few_fields) try { + auto sched_opt = parse_cron_schedule("* * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_invalid_too_many_fields) try { + auto sched_opt = parse_cron_schedule("* * * * * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_invalid_value_out_of_range) try { + // Minute 60 is out of range (0-59) + auto sched_opt = parse_cron_schedule("60 * * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_invalid_range_backwards) try { + // Range 17-9 is invalid (from > to) + auto sched_opt = parse_cron_schedule("* 17-9 * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_invalid_non_numeric) try { + auto sched_opt = parse_cron_schedule("abc * * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_invalid_zero_step) try { + auto sched_opt = parse_cron_schedule("*/0 * * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +// ----------------------------------------------------------------------- +// parse_cron_schedule_or_throw tests +// ----------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(parse_or_throw_valid) try { + auto sched = parse_cron_schedule_or_throw("* * * * *"); + BOOST_CHECK(sched.minutes.empty()); // Should succeed +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_or_throw_invalid) try { + BOOST_CHECK_THROW( + parse_cron_schedule_or_throw("invalid"), + fc::exception + ); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_or_throw_out_of_range) try { + BOOST_CHECK_THROW( + parse_cron_schedule_or_throw("60 * * * *"), + fc::exception + ); +} FC_LOG_AND_RETHROW(); + +// ----------------------------------------------------------------------- +// List / range-list / edge-case parsing +// ----------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(parse_list_of_ranges) try { + auto sched_opt = parse_cron_schedule("1-3,5-7 * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + // Two range_value entries in minutes + BOOST_CHECK_EQUAL(sched_opt->minutes.size(), 2); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_step_without_base_rejected) try { + // "/5" has no base before the slash - invalid syntax + auto sched_opt = parse_cron_schedule("/5 * * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_leading_comma_rejected) try { + auto sched_opt = parse_cron_schedule(",5 * * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_trailing_comma_rejected) try { + auto sched_opt = parse_cron_schedule("5, * * * *"); + BOOST_CHECK(!sched_opt.has_value()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_duplicate_values_deduplicated) try { + // std::set collapses duplicates; "5,5,5" yields a single exact_value + auto sched_opt = parse_cron_schedule("5,5,5 * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + BOOST_CHECK_EQUAL(sched_opt->minutes.size(), 1); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_dow_sunday_zero_accepted) try { + auto sched_opt = parse_cron_schedule("* * * * 0"); + BOOST_REQUIRE(sched_opt.has_value()); + BOOST_CHECK_EQUAL(sched_opt->day_of_week.size(), 1); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_dow_sunday_seven_alias) try { + // Documents current behavior for DOW=7 (the crontab Sunday-alias convention). + auto sched_opt = parse_cron_schedule("* * * * 7"); + BOOST_REQUIRE(sched_opt.has_value()); + BOOST_CHECK_EQUAL(sched_opt->day_of_week.size(), 1); + auto exact_val = *sched_opt->day_of_week.begin(); + BOOST_CHECK(std::holds_alternative(exact_val)); + auto exact = std::get(exact_val); + BOOST_CHECK_EQUAL(exact.value, 7u); +} FC_LOG_AND_RETHROW(); + +// ----------------------------------------------------------------------- +// Real-world examples +// ----------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(parse_example_every_minute) try { + auto sched_opt = parse_cron_schedule("* * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + // All wildcards - fires every minute +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_example_daily_midnight) try { + auto sched_opt = parse_cron_schedule("0 0 * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + auto& sched = *sched_opt; + BOOST_CHECK_EQUAL(sched.minutes.size(), 1); + BOOST_CHECK_EQUAL(sched.hours.size(), 1); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_example_weekday_business_hours) try { + auto sched_opt = parse_cron_schedule("0 9-17 * * 1-5"); + BOOST_REQUIRE(sched_opt.has_value()); + // Every hour from 9-5 on weekdays +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_example_first_of_month) try { + auto sched_opt = parse_cron_schedule("0 0 1 * *"); + BOOST_REQUIRE(sched_opt.has_value()); + auto& sched = *sched_opt; + // First day of every month at midnight + BOOST_CHECK_EQUAL(sched.day_of_month.size(), 1); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(parse_example_every_5_seconds_extended) try { + auto sched_opt = parse_cron_schedule("*/5000 * * * * *"); + BOOST_REQUIRE(sched_opt.has_value()); + auto& sched = *sched_opt; + // Every 5 seconds (5000 ms) + BOOST_CHECK_EQUAL(sched.milliseconds.size(), 1); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp index 5dd5f17e6f..4916dc2fc8 100644 --- a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp +++ b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp @@ -68,9 +68,11 @@ class outpost_ethereum_client_plugin : public appbase::plugin get_clients(); - ethereum_client_entry_ptr get_client(const std::string& id); - const std::vector>>& get_abi_files(); + std::vector get_clients() const; + ethereum_client_entry_ptr get_client(const std::string& id) const; + const std::vector>>& get_abi_files() const; + ethereum_client_ptr get_client_for_chain(fc::crypto::chain_kind_t target_chain) const; + std::vector get_abis_for_contract(const std::string& contract_name) const; /** * @brief Build an `outpost_client` concrete for an ETH outpost. diff --git a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp index e75beec3b7..9a7585f64e 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -74,10 +75,19 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti auto& id = parts[0]; auto& url = parts[2]; auto& sig_id = parts[1]; - fc::ostring chain_id_str = parts[3]; std::optional chain_id; - if (chain_id_str.has_value()) - chain_id = std::make_optional(fc::to_uint256(chain_id_str.value())); + fc::ostring chain_id_str; + if (parts.size() == 4) { + chain_id_str = parts[3]; + if (chain_id_str.has_value()) + chain_id = fc::to_uint256(chain_id_str.value()); + } else { + wlog("ethereum client `{}` has no chain-id pinned in its spec; the client will accept" + " whatever chainId the RPC returns. For a signing daemon this is a replay-attack" + " surface if the RPC is compromised or misconfigured. Consider adding a fourth" + " comma-separated field to --outpost-ethereum-client to pin the expected chain-id.", + id); + } auto sig_provider = plug_sig->get_provider(sig_id); my->add_client(id, @@ -89,7 +99,7 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti chain_id))); ilog("Added ethereum client (id={},sig_id={},chainId={},url={})", - id,sig_id,url,chain_id_str.value_or("none")); + id,sig_id,chain_id_str.value_or("none"),url); } } @@ -118,15 +128,15 @@ void outpost_ethereum_client_plugin::plugin_shutdown() { ilog("Shutdown outpost client plugin"); } -std::vector outpost_ethereum_client_plugin::get_clients() { +std::vector outpost_ethereum_client_plugin::get_clients() const { return my->get_clients(); } -ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client(const std::string& id) { +ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client(const std::string& id) const { return my->get_client(id); } -const std::vector>>& outpost_ethereum_client_plugin::get_abi_files() { +const std::vector>>& outpost_ethereum_client_plugin::get_abi_files() const { return my->get_abi_files(); } @@ -151,4 +161,53 @@ outpost_ethereum_client_plugin::create_outpost_client(const std::string& eth_cli chain_id); } +ethereum_client_ptr outpost_ethereum_client_plugin::get_client_for_chain(fc::crypto::chain_kind_t target_chain) const { + ethereum_client_ptr result; + for (const auto& entry : my->get_clients()) { + if (target_chain == entry->signature_provider->target_chain) { + SYS_ASSERT(!result, sysio::chain::plugin_config_exception, + "There should only be one ethereum client for chain kind {}, but there were at least 2", + static_cast(target_chain)); + result = entry->client; + } + } + SYS_ASSERT(!!result, sysio::chain::plugin_config_exception, + "could not find any ethereum client for chain kind {}", static_cast(target_chain)); + return result; +} + +std::vector outpost_ethereum_client_plugin::get_abis_for_contract(const std::string& contract_name) const { + static const std::regex contract_regex(R"(^(.+?)(?:V\d+)?$)"); + constexpr auto contract_name_field = "contractName"; + std::vector result; + + for (const auto& [json_abi_file, abi_contracts] : my->get_abi_files()) { + auto json_var = fc::json::from_file(json_abi_file); + if (!json_var.is_object()) + continue; + + const auto var_obj = json_var.get_object(); + if (!var_obj.contains(contract_name_field)) + continue; + + const auto name_var = var_obj[contract_name_field]; + if (name_var.is_array()) + continue; + + const auto name = name_var.as(); + + std::smatch matches; + if (!std::regex_search(name, matches, contract_regex)) + continue; + + if (matches[1].str() != contract_name) + continue; + + result.insert(result.end(), abi_contracts.begin(), abi_contracts.end()); + break; + } + + return result; +} + } // namespace sysio diff --git a/plugins/wire_eth_maintenance_plugin/CMakeLists.txt b/plugins/wire_eth_maintenance_plugin/CMakeLists.txt new file mode 100644 index 0000000000..d9906e91c2 --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/CMakeLists.txt @@ -0,0 +1,9 @@ +set(TARGET_LIB_NAME wire_eth_maintenance_plugin) + +plugin_target( + ${TARGET_LIB_NAME} + LIBRARIES + outpost_ethereum_client_plugin + cron_plugin + Boost::url +) diff --git a/plugins/wire_eth_maintenance_plugin/include/sysio/beacon_chain_config_updates.hpp b/plugins/wire_eth_maintenance_plugin/include/sysio/beacon_chain_config_updates.hpp new file mode 100644 index 0000000000..7b16a611aa --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/include/sysio/beacon_chain_config_updates.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace sysio { + +// Internal-only plain-data types: no FC_REFLECT declarations are provided because these +// are never serialized over the network or to disk - they exist to move values between +// the fetch/compute/transact/confirm steps within this plugin. + +struct queue_updates { + std::optional withdraw_delay_sec; + std::optional entry_queue_days; +}; + +struct apy_updates { + std::optional apy_bps; +}; + +struct beacon_chain_config_updates_deps { + std::function fetch_queues; + std::function fetch_apy; + std::function send_set_withdraw_delay; + std::function send_set_entry_queue; + std::function send_update_apy_bps; + /// Called once per successful send with the contract method name and the tx hash + /// returned by the corresponding `send_*` callback. The implementation is responsible + /// for blocking until the tx is confirmed (or determining that confirmation is + /// impossible) and reporting the outcome via logging - it must not throw to indicate + /// confirmation failure, since the surrounding orchestration treats throws as bugs. + std::function confirm_tx; +}; + +class beacon_chain_config_updates { +public: + beacon_chain_config_updates(beacon_chain_config_updates_deps deps, uint64_t exit_queue_buffer_days); + void operator()() const; + + queue_updates compute_queue_updates(const fc::variant& queues_response) const; + apy_updates compute_apy_updates(const fc::variant& ethstore_response) const; + +private: + /// Invoke `deps_.confirm_tx` with the given (method, hash) pair, swallowing any exception + /// it raises so that one bad confirmation cannot prevent subsequent sends from running. + void safely_confirm(std::string_view method, const std::string& tx_hash) const; + + beacon_chain_config_updates_deps deps_; + const uint64_t exit_queue_buffer_days_; +}; + +} // namespace sysio diff --git a/plugins/wire_eth_maintenance_plugin/include/sysio/beacon_chain_update_detail.hpp b/plugins/wire_eth_maintenance_plugin/include/sysio/beacon_chain_update_detail.hpp new file mode 100644 index 0000000000..041b50fe5e --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/include/sysio/beacon_chain_update_detail.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace sysio::beacon_chain_detail { + +/// The field name in beacon chain queue API responses that holds the estimated processing timestamp (Unix seconds). +inline constexpr auto epa_field = "estimated_processed_at"; + +/// Extract a named field from an fc::variant object. +/// Returns empty optional if expected_obj is not an object or does not contain expected_field. +std::optional get_field_from_object(const fc::variant& expected_obj, + const std::string& expected_field); + +/// Extract the queue wait time in seconds (from now) for the given queue branch from a beacon chain queues response. +/// Throws sysio::chain::plugin_config_exception if required fields are absent or malformed. +std::optional get_queue_length(const fc::variant& queues, const std::string& queue_branch); + +/// Convert an APY fraction (e.g. 0.05 for 5%) to basis points (e.g. 500). +/// Uses a small epsilon for floating-point robustness when the result should be a whole number. +/// NaN, Inf, and negative values are clamped to 0; extreme positive values are clamped at 100 (= 1,000,000 bps). +inline uint64_t apy_fraction_to_bps(double apr_fraction) { + if (!std::isfinite(apr_fraction) || apr_fraction < 0.0) + apr_fraction = 0.0; + constexpr double max_fraction = 100.0; + if (apr_fraction > max_fraction) + apr_fraction = max_fraction; + return static_cast(apr_fraction * 10000.0 + 1e-12); +} + +} // namespace sysio::beacon_chain_detail diff --git a/plugins/wire_eth_maintenance_plugin/include/sysio/wire_eth_maintenance_plugin.hpp b/plugins/wire_eth_maintenance_plugin/include/sysio/wire_eth_maintenance_plugin.hpp new file mode 100644 index 0000000000..2cbbac754e --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/include/sysio/wire_eth_maintenance_plugin.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +namespace sysio { + +class wire_eth_maintenance_plugin : public appbase::plugin { +public: + APPBASE_PLUGIN_REQUIRES((outpost_ethereum_client_plugin)(signature_provider_manager_plugin)(cron_plugin)) + wire_eth_maintenance_plugin(); + virtual ~wire_eth_maintenance_plugin() = default; + + virtual void set_program_options(options_description& cli, options_description& cfg) override; + + virtual void plugin_initialize(const variables_map& options); + + virtual void plugin_startup(); + + virtual void plugin_shutdown(); + + void interrupt(); + +private: + std::shared_ptr my; +}; + + +} // namespace sysio diff --git a/plugins/wire_eth_maintenance_plugin/src/beacon_chain_config_updates.cpp b/plugins/wire_eth_maintenance_plugin/src/beacon_chain_config_updates.cpp new file mode 100644 index 0000000000..ebccec79b8 --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/src/beacon_chain_config_updates.cpp @@ -0,0 +1,164 @@ +#include + +#include +#include +#include + +namespace { + /// TEMPORARY DIAGNOSTIC: logs send-call failures with the structured + /// json_rpc_error fields (code + data) intact. fc::exception::what() carries + /// code+message but drops the `data` field, which is where the EVM revert + /// reason rides on `eth_estimateGas` failures (Solidity Error(string) ABI- + /// encoded as a hex string). Remove this helper and its callers once the + /// revert root-cause is identified. + void log_send_failure(std::string_view method, const std::exception& e) { + if (auto* je = dynamic_cast(&e)) { + elog("{} failed: code={} what={} data={}", method, je->code, je->what(), + fc::json::to_string(je->data, fc::time_point::maximum())); + } else { + elog("{} failed: {}", method, e.what()); + } + } +} + +namespace sysio { + +queue_updates beacon_chain_config_updates::compute_queue_updates(const fc::variant& queues_response) const { + queue_updates result; + + constexpr uint64_t sec_per_day = 60 * 60 * 24; + constexpr uint64_t max_withdraw_delay_sec = 180ull * sec_per_day; // 180-day sanity cap + constexpr uint64_t max_entry_queue_days = 365; + + const uint64_t exit_queue_buffer_seconds = exit_queue_buffer_days_ * sec_per_day; + auto exit_eta = beacon_chain_detail::get_queue_length(queues_response, "exit_queue"); + if (!exit_eta) + wlog("exit_queue EPA was not a finite number, defaulting to {}-day buffer only", exit_queue_buffer_days_); + result.withdraw_delay_sec = exit_queue_buffer_seconds + exit_eta.value_or(0); + + auto deposit_eta = beacon_chain_detail::get_queue_length(queues_response, "deposit_queue"); + if (!deposit_eta) { + wlog("deposit_queue EPA was not a finite number, defaulting to 1 day"); + result.entry_queue_days = 1; + } else { + ilog("deposit_queue len={} sec, sec_per_day={}", *deposit_eta, sec_per_day); + result.entry_queue_days = *deposit_eta / sec_per_day; + } + + if (result.withdraw_delay_sec && *result.withdraw_delay_sec > max_withdraw_delay_sec) { + elog("withdraw_delay_sec={} exceeds sanity cap of {} seconds; skipping update", + *result.withdraw_delay_sec, max_withdraw_delay_sec); + result.withdraw_delay_sec.reset(); + } + if (result.entry_queue_days && *result.entry_queue_days > max_entry_queue_days) { + elog("entry_queue_days={} exceeds sanity cap of {} days; skipping update", + *result.entry_queue_days, max_entry_queue_days); + result.entry_queue_days.reset(); + } + + return result; +} + +apy_updates beacon_chain_config_updates::compute_apy_updates(const fc::variant& ethstore_response) const { + apy_updates result; + + constexpr auto avgapr7d_field = "avgapr7d"; + constexpr uint64_t max_apy_bps = 10000; // 100% cap + + auto apy_var = beacon_chain_detail::get_field_from_object(ethstore_response, avgapr7d_field); + if (!apy_var) { + elog("ethstore response did not have a {} field", avgapr7d_field); + } else if (!apy_var->is_numeric()) { + elog("ethstore response {} field was not numeric; skipping APY update", avgapr7d_field); + } else { + const double apr_fraction = apy_var->as_double(); + auto bps = beacon_chain_detail::apy_fraction_to_bps(apr_fraction); + if (bps > max_apy_bps) { + elog("apy_bps={} exceeds sanity cap of {}; skipping update", bps, max_apy_bps); + } else { + result.apy_bps = bps; + } + } + + return result; +} + +beacon_chain_config_updates::beacon_chain_config_updates(beacon_chain_config_updates_deps deps, + uint64_t exit_queue_buffer_days) + : deps_(std::move(deps)), exit_queue_buffer_days_(exit_queue_buffer_days) {} + +void beacon_chain_config_updates::safely_confirm(std::string_view method, + const std::string& tx_hash) const { + if (!deps_.confirm_tx) return; + try { + deps_.confirm_tx(method, tx_hash); + } catch (const std::exception& e) { + elog("confirm_tx for {} ({}) threw: {}", method, tx_hash, e.what()); + } +} + +void beacon_chain_config_updates::operator()() const { + try { + ilog("beacon_chain_config_updates: fetching queue data"); + auto queues = deps_.fetch_queues(); + ilog("queues: {}", fc::json::to_string(queues, fc::time_point::maximum())); + + auto check_hash = [&](const auto& method, const auto& hash) { + if (!hash.empty()) { + ilog("{} tx sent, hash: {}", method, hash); + safely_confirm(method, hash); + } + }; + + auto q = compute_queue_updates(queues); + + if (q.withdraw_delay_sec && deps_.send_set_withdraw_delay) { + const auto method = "setWithdrawDelay"; + ilog("Sending {}({} sec)", method, *q.withdraw_delay_sec); + try { + auto hash = deps_.send_set_withdraw_delay(*q.withdraw_delay_sec); + check_hash(method, hash); + } + catch(const std::exception& e) { + log_send_failure(method, e); + } + } + + if (q.entry_queue_days && deps_.send_set_entry_queue) { + const auto method = "setEntryQueue"; + ilog("Sending {}({} days)", method, *q.entry_queue_days); + try { + auto hash = deps_.send_set_entry_queue(*q.entry_queue_days); + check_hash(method, hash); + } + catch(const std::exception& e) { + log_send_failure(method, e); + } + } + + if (deps_.send_update_apy_bps) { + ilog("beacon_chain_config_updates: fetching APY data"); + auto ethstore = deps_.fetch_apy(); + ilog("ethstore: {}", fc::json::to_string(ethstore, fc::time_point::maximum())); + + auto a = compute_apy_updates(ethstore); + + if (a.apy_bps) { + const auto method = "updateApyBPS"; + ilog("Sending {}({} bps)", method, *a.apy_bps); + try { + auto hash = deps_.send_update_apy_bps(*a.apy_bps); + check_hash(method, hash); + } + catch(const std::exception& e) { + log_send_failure(method, e); + } + } + } + + } catch (const std::exception& e) { + elog("beacon_chain_config_updates failed: {}", e.what()); + } +} + +} // namespace sysio diff --git a/plugins/wire_eth_maintenance_plugin/src/wire_eth_maintenance_plugin.cpp b/plugins/wire_eth_maintenance_plugin/src/wire_eth_maintenance_plugin.cpp new file mode 100644 index 0000000000..f34c70c8ef --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/src/wire_eth_maintenance_plugin.cpp @@ -0,0 +1,590 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace bpo = boost::program_options; +using namespace appbase; +using namespace sysio; + +namespace sysio { +struct OPP : fc::network::ethereum::ethereum_contract_client { + static constexpr auto contract_name = "OPP"; + + ethereum_contract_tx_fn finalizeEpoch; + OPP(const ethereum_client_ptr& client, + const address_compat_type& contract_address_compat, + const std::vector& contracts) + : ethereum_contract_client(client, contract_address_compat, contracts) + , finalizeEpoch(create_tx(get_abi("finalizeEpoch"))) { + + } +}; + +struct deposit_manager : fc::network::ethereum::ethereum_contract_client { + static constexpr auto contract_name = "DepositManager"; + + ethereum_contract_tx_fn setEntryQueue; + ethereum_contract_tx_fn updateApyBPS; + deposit_manager(const ethereum_client_ptr& client, + const address_compat_type& contract_address_compat, + const std::vector& contracts) + : ethereum_contract_client(client, contract_address_compat, contracts) + , setEntryQueue(create_tx(get_abi("setEntryQueue"))) + , updateApyBPS(create_tx(get_abi("updateApyBPS"))) { + + } +}; + +struct withdrawal_queue : fc::network::ethereum::ethereum_contract_client { + static constexpr auto contract_name = "WithdrawalQueue"; + + ethereum_contract_tx_fn setWithdrawDelay; + withdrawal_queue(const ethereum_client_ptr& client, + const address_compat_type& contract_address_compat, + const std::vector& contracts) + : ethereum_contract_client(client, contract_address_compat, contracts) + , setWithdrawDelay(create_tx(get_abi("setWithdrawDelay"))) { + + } +}; +namespace { + constexpr auto beacon_chain_queue_url = "beacon-chain-queue-url"; + constexpr auto beacon_chain_default_queue_url = "https://beaconcha.in/api/v2/ethereum/queues"; + constexpr auto beacon_chain_apy_url = "beacon-chain-apy-url"; + constexpr auto beacon_chain_default_apy_url = "https://beaconcha.in/api/v1/ethstore/latest"; + constexpr auto beacon_chain_api_key = "beacon-chain-api-key"; + constexpr auto beacon_chain_contracts_addrs = "beacon-chain-contracts-addrs"; + constexpr auto beacon_chain_update_interval = "beacon-chain-update-interval"; + constexpr auto beacon_chain_interval = "beacon-chain-interval"; + constexpr auto beacon_chain_finalize_epoch_interval = "beacon-chain-finalize-epoch-interval"; + constexpr auto beacon_chain_network = "beacon-chain-network"; + constexpr auto beacon_chain_exit_buffer_days = "beacon-chain-exit-buffer-days"; + + constexpr auto client_target_chain = fc::crypto::chain_kind_t::chain_kind_ethereum; + constexpr auto default_interval_schedule = "*/60 * * * *"; // every hour + constexpr auto default_interval_name = "default"; + constexpr auto just_once_interval_name = "once"; + + fc::variant https_request(const std::string& url_str, + boost::beast::http::verb method, + const std::string& request_body, + const std::string& api_key, + std::chrono::seconds timeout = std::chrono::seconds(120)) { + namespace beast = boost::beast; + namespace http = beast::http; + namespace asio = boost::asio; + using tcp = asio::ip::tcp; + + fc::url url(url_str); + SYS_ASSERT(url.proto() == "https", sysio::chain::plugin_config_exception, + "Only https:// URLs are supported here; got `{}` with proto=`{}`", + url_str, url.proto()); + SYS_ASSERT(url.host().has_value(), sysio::chain::plugin_config_exception, + "URL `{}` has no host component", url_str); + auto host = *url.host(); + auto port = std::to_string(url.port().value_or(443)); + auto path = url.path().value_or(std::filesystem::path("/")).string(); + ilog("host = {}, port = {}, path = {}", host, port, path); + + asio::io_context ioc; + asio::ssl::context ssl_ctx{asio::ssl::context::tlsv12_client}; + tcp::resolver resolver{ioc}; + auto dest = resolver.resolve(host, port); + if (method == boost::beast::http::verb::get) { + path += "?apikey="; + path += boost::urls::encode(api_key, boost::urls::unreserved_chars); + } + + ssl_ctx.set_default_verify_paths(); + + uint retry = 0; + bool valid = false; + while(true) { + + http::request req{method, path, 11}; + req.set(http::field::host, host); + req.set(http::field::content_type, "application/json"); + if (method == boost::beast::http::verb::post) + req.set(http::field::authorization, "Bearer " + api_key); + if (!request_body.empty()) + req.body() = request_body; + req.prepare_payload(); + + beast::ssl_stream stream(ioc, ssl_ctx); + stream.set_verify_mode(asio::ssl::verify_peer); + stream.set_verify_callback(asio::ssl::host_name_verification(host)); + if (!SSL_set_tlsext_host_name(stream.native_handle(), host.c_str())) + throw beast::system_error(beast::error_code(static_cast(::ERR_get_error()), + asio::error::get_ssl_category())); + + beast::get_lowest_layer(stream).expires_after(timeout); + beast::get_lowest_layer(stream).connect(dest); + stream.handshake(asio::ssl::stream_base::client); + http::write(stream, req); + + beast::flat_buffer buffer; + http::response_parser parser; + parser.body_limit(8ull * 1024 * 1024); // 8 MiB cap on response body + http::read(stream, buffer, parser); + auto& res = parser.get(); + + beast::error_code ec; + stream.shutdown(ec); + // eof/stream_truncated are benign - many servers close without a clean TLS shutdown. + if (ec && ec != asio::error::eof && ec != asio::ssl::error::stream_truncated) + dlog("TLS shutdown returned non-benign error: {}", ec.message()); + + valid = res.result() == http::status::ok; + if (valid) { + dlog("res.body=\n{}", res.body()); + auto response = fc::json::from_string(res.body()); + return response["data"]; + } + + // if we already did one retry, then give up + if (retry > 0) { + return {}; + } + + const auto retry_after = res.base()[http::field::retry_after]; + if (!retry_after.empty()) { + uint64_t sec_sleep = 0; + auto [ptr, perr] = std::from_chars(retry_after.data(), + retry_after.data() + retry_after.size(), sec_sleep); + if (perr == std::errc() && ptr == retry_after.data() + retry_after.size()) { + std::this_thread::sleep_for(std::chrono::seconds(sec_sleep)); + } + } + ++retry; + } + + } + + fc::variant get_queues_network(const std::string& queue_url, const std::string& api_key, + const std::string& network) { + SYS_ASSERT(!api_key.empty(), sysio::chain::plugin_config_exception, + "beacon-chain-api-key is required for queues API"); + const auto body = fc::json::to_string( + fc::mutable_variant_object("chain", network), fc::time_point::maximum()); + return https_request(queue_url, boost::beast::http::verb::post, body, api_key); + } + + fc::variant get_ethstore_latest(const std::string& apy_url, const std::string& api_key) { + // Build the full URL with apikey query param — fc::url::query() is broken and never + // stores the query string during parsing, so we construct the URL string directly. + return https_request(apy_url, boost::beast::http::verb::get, + {}, api_key, std::chrono::seconds{180}); + } +} + +namespace beacon_chain_detail { + + std::optional get_field_from_object(const fc::variant& expected_obj, + const std::string& expected_field) { + if (!expected_obj.is_object()) + return {}; + + const auto& actual_obj = expected_obj.get_object(); + if (!actual_obj.contains(expected_field)) + return {}; + + return actual_obj[expected_field]; + } + + // reported in seconds + std::optional get_queue_length(const fc::variant& queues, const std::string& queue_branch) { + const auto deposit_queue = get_field_from_object(queues, queue_branch); + SYS_ASSERT(!!deposit_queue, sysio::chain::plugin_config_exception, + "Returned api request:\n{}\n doesn't contain the field {}", + fc::json::to_string(queues, fc::time_point::maximum()), queue_branch); + const auto epa_var = get_field_from_object(*deposit_queue, epa_field); + SYS_ASSERT(!!epa_var, sysio::chain::plugin_config_exception, + "{}:\n{}\n doesn't contain a key of {}", + queue_branch, fc::json::to_string(queues, fc::time_point::maximum()), epa_field); + SYS_ASSERT(epa_var->is_uint64() || epa_var->is_int64(), + sysio::chain::plugin_config_exception, + "queues[{}][{}]:\n{}\n is not an integer", + queue_branch, epa_field, + fc::json::to_string(queues, fc::time_point::maximum())); + if (epa_var->is_int64()) { + const auto signed_epa = epa_var->as_int64(); + SYS_ASSERT(signed_epa >= 0, sysio::chain::plugin_config_exception, + "queues[{}][{}] is negative: {}", queue_branch, epa_field, signed_epa); + } + + const auto now_sec = fc::time_point::now().sec_since_epoch(); + const auto epa = epa_var->as_uint64(); + if (epa <= now_sec) { + dlog("queue {} epa={} is in the past (now={}), returning nullopt", queue_branch, epa, now_sec); + return std::nullopt; + } + const auto eta = epa - now_sec; + ilog("Determined eta={} from now={} and epa={} on branch={}", + eta, now_sec, epa, queue_branch); + return eta; + } + +} // namespace beacon_chain_detail + +using std::optional; +using std::string; +using std::unordered_map; +using std::vector; + +using addr_map_t = std::map; +using action = std::function; +using interval_actions_t = vector; +using job_schedule = services::cron_service::job_schedule; +using schedules_t = unordered_map; +using ethereum_client_ptr = fc::network::ethereum::ethereum_client_ptr; + +class wire_eth_maintenance_plugin_impl { + +public: + schedules_t schedules; + string actual_default_schedule; + unordered_map intervals; + interval_actions_t just_once_actions; + optional just_once_jid; + + addr_map_t outpost_addrs; + + interval_actions_t& find_interval_actions(string interval_name) { + // if the interval actions are already created, we can just use it + if(intervals.count(interval_name) > 0) { + return intervals[interval_name]; + } + + if(interval_name == just_once_interval_name) { + return just_once_actions; + } + + // This is used to make sure that there is a corresponding cron schedule associated with each collection of actions + if(schedules.count(interval_name) == 0) { + ilog("Could not find a schedule named {}, using {} interval", interval_name, default_interval_name); + interval_name = actual_default_schedule; + } + + return intervals[interval_name]; + } + + template + std::pair, ethereum_client_ptr> get_contract(const outpost_ethereum_client_plugin& oec_plugin, + ethereum_client_ptr client = ethereum_client_ptr{}) const { + constexpr auto desired_contract_name = C::contract_name; + if(!client) + client = oec_plugin.get_client_for_chain(client_target_chain); + + auto itr = outpost_addrs.find(desired_contract_name); + SYS_ASSERT(itr != outpost_addrs.end(), sysio::chain::plugin_config_exception, + "contract {} address was not provided in an abi file", desired_contract_name); + + auto contract_abis = oec_plugin.get_abis_for_contract(desired_contract_name); + + std::shared_ptr contract; + if(!contract_abis.empty()) + contract = client->get_contract(itr->second, contract_abis); + + return {contract, client}; + } + +}; + +void wire_eth_maintenance_plugin::plugin_initialize(const variables_map& options) { + ilog("initializing beacon chain plugin"); + + if( options.contains(beacon_chain_contracts_addrs) ) { + auto client_specs = options.at(beacon_chain_contracts_addrs).as>(); + for(const auto& client_spec : client_specs) { + ilog("found beacon chain outpost addresses: {}", client_spec); + fc::variant addrs = fc::json::from_file(client_spec); + const auto addrs_obj = addrs.get_object(); + for(const auto& entry : addrs_obj) { + const auto name = entry.key(); + const auto addr = entry.value().as_string(); + ilog("outpost address - {}: {}", name, addr); + my->outpost_addrs.emplace(name, addr); + } + } + } + + if( options.contains(beacon_chain_interval) ) { + ilog("initializing beacon chain intervals"); + auto client_specs = options.at(beacon_chain_interval).as>(); + for (auto& client_spec : client_specs) { + auto parts = fc::split(client_spec, ',', 1); + SYS_ASSERT(parts.size() == 2, chain::plugin_config_exception, + "Interval spec `{}` must be of form `,`", client_spec); + SYS_ASSERT(parts[0] != just_once_interval_name, chain::plugin_config_exception, + "Cannot use reserved interval spec name: `{}`, to store schedule: `{}`", + just_once_interval_name, parts[1]); + auto schedule_inserted = my->schedules.emplace(parts[0], services::parse_cron_schedule_or_throw(parts[1])); + SYS_ASSERT(schedule_inserted.second, chain::plugin_config_exception, + "Repeated interval spec name: `{}`, schedule: `{}`", parts[0], parts[1]); + if(my->actual_default_schedule.empty()) { + my->actual_default_schedule = parts[0]; + ilog("Interval schedule name: `{}`, with schedule: `{}`, will be used for `{}`", + parts[0], parts[1], default_interval_name); + } + } + } + else { + ilog("No beacon chain interval schedules provided, using `{}` schedule with name `{}`", default_interval_schedule, default_interval_name); + my->actual_default_schedule = default_interval_name; + my->schedules.emplace(default_interval_name, services::parse_cron_schedule_or_throw(default_interval_schedule)); + } + + auto& oec_plugin = app().get_plugin(); + + auto [ opp_contract, eth_client ] = my->get_contract(oec_plugin); + if( opp_contract ) { + ilog("initializing beacon chain finalize epoch interval"); + auto safely_confirm = [&](const auto& method, const auto& tx_hash) { + eth_client->wait_for_confirmation(tx_hash); + auto bn = eth_client->get_block_for_transaction(tx_hash); + if (!bn) { + elog("failed to identify block for tx {}", tx_hash); + return; + } + ilog("tx for {} ({}) in block number {}", method, tx_hash, *bn); + }; + + auto& finalize_epoch_interval = options.at(beacon_chain_finalize_epoch_interval).as(); + auto& actions = my->find_interval_actions(finalize_epoch_interval); + auto action = [&my_ = *my, opp_contract, eth_client, safely_confirm=std::move(safely_confirm)]() { + ilog("finalizing OPP epoch"); + const auto bn = eth_client->get_block_number(); + ilog("Executing beacon chain update for interval bn {}", static_cast(bn)); + const auto method =""; + try { + ilog("Sending {} transaction to OPP contract using address {}", + method, fc::to_hex(eth_client->get_address(), true)); + auto res = opp_contract->finalizeEpoch(); + auto hash = res.as_string(); + if (!hash.empty()) { + ilog("{} tx sent, hash: {}", method, hash); + safely_confirm(method, hash); + } + } + catch (const std::exception& e) { + elog("Error executing beacon chain update for interval: {}", e.what()); + } + }; + actions.emplace_back(std::move(action)); + ilog("There are {} actions currently registered.", actions.size()); + + } + + if( options.contains(beacon_chain_api_key) ) { + ilog("beacon chain queue/apy update enabled"); + auto wq_contract = my->get_contract(oec_plugin, eth_client).first; + auto dm_contract = my->get_contract(oec_plugin, eth_client).first; + SYS_ASSERT(!!wq_contract || !!dm_contract, sysio::chain::plugin_config_exception, + "If {} is set, then must provide at least {}'s or {}'s contract address", + beacon_chain_api_key, withdrawal_queue::contract_name, deposit_manager::contract_name); + + auto queue_url = options.at(beacon_chain_queue_url).as(); + auto apy_url = options.at(beacon_chain_apy_url).as(); + auto api_key_val = options.at(beacon_chain_api_key).as(); + SYS_ASSERT(api_key_val.find_first_of("\r\n") == std::string::npos, + sysio::chain::plugin_config_exception, + "--beacon-chain-api-key must not contain CR/LF characters" + " (value would be injected into HTTP headers)."); + auto network_val = options.at(beacon_chain_network).as(); + auto update_interval = options.at(beacon_chain_update_interval).as(); + auto exit_buffer_days = options.at(beacon_chain_exit_buffer_days).as(); + + auto& actions = my->find_interval_actions(update_interval); + actions.emplace_back(beacon_chain_config_updates({ + .fetch_queues = [=]() { return get_queues_network(queue_url, api_key_val, network_val); }, + .fetch_apy = [=]() { return get_ethstore_latest(apy_url, api_key_val); }, + .send_set_withdraw_delay = wq_contract + ? std::function([wq_contract](uint64_t val) { + return wq_contract->setWithdrawDelay(val).as_string(); + }) + : std::function{}, + .send_set_entry_queue = dm_contract + ? std::function([dm_contract](uint64_t val) { + return dm_contract->setEntryQueue(val).as_string(); + }) + : std::function{}, + .send_update_apy_bps = dm_contract + ? std::function([dm_contract](uint64_t val) { + auto ret = dm_contract->updateApyBPS(val).as_string(); + return ret; + }) + : std::function{}, + .confirm_tx = [eth_client, &app_ref = app()](std::string_view method, + const std::string& tx_hash) { + eth_client->wait_for_confirmation(tx_hash); + auto bn = eth_client->get_block_for_transaction(tx_hash); + if (!bn) { + elog("failed to identify block for tx {}", tx_hash); + return; + } + ilog("tx for {} ({}) in block number {}", method, tx_hash, *bn); + } + }, exit_buffer_days)); + ilog("There are {} actions currently registered.", actions.size()); + } + else { + SYS_ASSERT(!!opp_contract, sysio::chain::plugin_config_exception, + "Nothing is configured to run in wire_eth_maintenance_plugin"); + } + + ilog("initializing beacon chain plugin DONE"); +} + +void wire_eth_maintenance_plugin::plugin_startup() { + ilog("Starting beacon chain update plugin"); + auto& cron = app().get_plugin(); + auto& oec_plugin = app().get_plugin(); + const auto clients = oec_plugin.get_clients(); + SYS_ASSERT(clients.size() > 0, sysio::chain::plugin_config_exception, + "At least one ethereum client must be configured for beacon chain update plugin"); + const auto eth_client = clients.front()->client; + + ilog("Scheduling {} to execute right after startup", just_once_interval_name); + job_schedule jo_schedule = services::parse_cron_schedule_or_throw("*/60 * * * * *"); + my->just_once_jid = + cron.add_job(jo_schedule, [my_=my,cron=&cron]() { + try { + if(!!my_->just_once_jid) + cron->cancel_job(*my_->just_once_jid); + } + catch (const std::exception& e) { + elog("Error cancelling the beacon chain update for the just once actions: {}", e.what()); + } + ilog("Executing beacon chain update for the processes that run `{}`", just_once_interval_name); + for(const auto& action : my_->just_once_actions) { + try { + action(); + } + catch (const std::exception& e) { + elog("Error executing beacon chain update for the just once actions: {}", e.what()); + } + } + }, + cron_service::job_metadata_t{ + .one_at_a_time = true, .tags = {"ethereum", "gas"}, .label = "beacon_chain_startup" + }); + + ilog("There are {} schedules currently available.", my->schedules.size()); + ilog("There are {} intervals currently registered.", my->intervals.size()); + for (const auto& [name, schedule] : my->schedules) { + ilog("Scheduling beacon chain update for interval {}", name); + + auto& actions = my->find_interval_actions(name); + ilog("There are {} actions currently registered for this interval.", actions.size()); + if(actions.empty()) { + ilog("No actions to register for interval {}", name); + continue; + } + ilog("{} actions to register for interval {}", actions.size(), name); + + cron.add_job(schedule, [my_=my, name=std::string{name}]() { + ilog("Executing beacon chain update for {}", name); + auto it = my_->intervals.find(name); + if (it == my_->intervals.end()) return; + for(const auto& action : it->second) { + try { + action(); + } + catch (const std::exception& e) { + elog("Error executing beacon chain update for interval: {}", e.what()); + } + } + }, + cron_service::job_metadata_t{ + .one_at_a_time = true, .tags = {"ethereum", "gas"}, .label = "beacon_chain_update:" + name + }); + } +} + + +wire_eth_maintenance_plugin::wire_eth_maintenance_plugin() : my( + std::make_shared()) {} + +void wire_eth_maintenance_plugin::set_program_options(options_description& cli, options_description& cfg) { + cfg.add_options() + (beacon_chain_queue_url, + bpo::value()->default_value(beacon_chain_default_queue_url), + "URL for the beacon chain queues endpoint to obtain the current queue duration.") + (beacon_chain_apy_url, + bpo::value()->default_value(beacon_chain_default_apy_url), + "URL for the beacon chain APY endpoint to obtain the current APY value.") + (beacon_chain_api_key, + bpo::value(), + "API key for authenticating requests to the beacon chain endpoints.") + (beacon_chain_update_interval, + bpo::value()->default_value(just_once_interval_name), + "Enable fetching the beacon chain deposit/exit queue data and updating on-chain contracts, using the indicated interval.") + (beacon_chain_contracts_addrs, + bpo::value>()->multitoken(), + "filename to provide addresses for any needed contracts.") + (beacon_chain_interval, + boost::program_options::value>()->multitoken(), + "Interval specification. Format is `,`" + " where cron-spec is in standard cron format (e.g. `*/5 * * * *` for every 5 minutes). If" + " none are provided, a default interval with name `default` and schedule of every 1 hour" + " will be used (e.g. `default, */60 * * * *`). Also, a `once` interval is automatically" + " provided which will just executes right after starting and then is not run again.") + (beacon_chain_finalize_epoch_interval, + bpo::value()->default_value(just_once_interval_name), + "Name of the interval (defined via --beacon-chain-interval) on which to run OPP finalizeEpoch.") + (beacon_chain_network, + bpo::value()->default_value("mainnet"), + "The beacon chain network name passed to the queues API (e.g. mainnet, holesky).") + (beacon_chain_exit_buffer_days, + bpo::value()->default_value(9), + "Buffer in days added to the exit queue ETA when computing withdraw delay;" + " also used as the fallback delay when the ETA is unavailable or in the past."); +} + + +void wire_eth_maintenance_plugin::plugin_shutdown() { + ilog("Shutdown beacon chain update plugin"); + if (my && my->just_once_jid.has_value()) { + auto* cron = app().find_plugin(); + if (cron) { + cron->cancel_job(*my->just_once_jid); + } + my->just_once_jid.reset(); + } +} + +/** + * Thread-safety contract: invoked from the signal-catching thread via the + * stop_executor_cb registered in main(), concurrently with cron pool workers + * that are running https_request. Must only touch thread-safe state — + * interruption_handle takes care of that via its atomic flag and + * mutex-guarded io_context set. + */ +void wire_eth_maintenance_plugin::interrupt() { + ilog("interrupt"); + app().executor().stop(); +} + +} // namespace sysio diff --git a/plugins/wire_eth_maintenance_plugin/test/CMakeLists.txt b/plugins/wire_eth_maintenance_plugin/test/CMakeLists.txt new file mode 100644 index 0000000000..4fee7f4736 --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/test/CMakeLists.txt @@ -0,0 +1,6 @@ +add_executable(test_wire_eth_maintenance_plugin + main.cpp + test_wire_eth_maintenance_plugin.cpp +) +target_link_libraries(test_wire_eth_maintenance_plugin wire_eth_maintenance_plugin sysio_testing sysio_chain_wrap) +add_test(NAME test_wire_eth_maintenance_plugin COMMAND plugins/wire_eth_maintenance_plugin/test/test_wire_eth_maintenance_plugin WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) diff --git a/plugins/wire_eth_maintenance_plugin/test/main.cpp b/plugins/wire_eth_maintenance_plugin/test/main.cpp new file mode 100644 index 0000000000..2b6689c6b7 --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/test/main.cpp @@ -0,0 +1,2 @@ +#define BOOST_TEST_MODULE wire_eth_maintenance_plugin +#include diff --git a/plugins/wire_eth_maintenance_plugin/test/test_wire_eth_maintenance_plugin.cpp b/plugins/wire_eth_maintenance_plugin/test/test_wire_eth_maintenance_plugin.cpp new file mode 100644 index 0000000000..2b3fdebd7f --- /dev/null +++ b/plugins/wire_eth_maintenance_plugin/test/test_wire_eth_maintenance_plugin.cpp @@ -0,0 +1,470 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +using namespace sysio::beacon_chain_detail; +using namespace sysio; + +namespace { + // A long-horizon EPA used by tests that only need a value "far enough in the future" to + // produce a positive ETA. Picked for readability, not precision; test assertions must not + // depend on the exact magnitude. Update before 2100-01-01 (the wall-clock value). + constexpr uint64_t far_future_epa = 4102444800ull; // 2100-01-01 00:00:00 UTC + + fc::variant make_queue(const char* branch_name) { + auto json = std::string("{\"") + branch_name + R"(": {"estimated_processed_at": )" + + std::to_string(far_future_epa) + "}}"; + return fc::json::from_string(json); + } +} + +BOOST_AUTO_TEST_SUITE(beacon_chain_update_detail_tests) + +// --------------------------------------------------------------------------- +// get_field_from_object +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(get_field_from_object_non_object_returns_empty) { + auto v = fc::variant("not an object"); + BOOST_CHECK(!get_field_from_object(v, "key").has_value()); +} + +BOOST_AUTO_TEST_CASE(get_field_from_object_null_returns_empty) { + fc::variant v; + BOOST_CHECK(!get_field_from_object(v, "key").has_value()); +} + +BOOST_AUTO_TEST_CASE(get_field_from_object_missing_field_returns_empty) { + auto v = fc::json::from_string(R"({"other": 1})"); + BOOST_CHECK(!get_field_from_object(v, "missing").has_value()); +} + +BOOST_AUTO_TEST_CASE(get_field_from_object_present_string_field) { + auto v = fc::json::from_string(R"({"name": "hello"})"); + auto result = get_field_from_object(v, "name"); + BOOST_REQUIRE(result.has_value()); + BOOST_CHECK_EQUAL(result->as_string(), "hello"); +} + +BOOST_AUTO_TEST_CASE(get_field_from_object_present_numeric_field) { + auto v = fc::json::from_string(R"({"count": 42})"); + auto result = get_field_from_object(v, "count"); + BOOST_REQUIRE(result.has_value()); + BOOST_CHECK_EQUAL(result->as_uint64(), 42u); +} + +BOOST_AUTO_TEST_CASE(get_field_from_object_nested_object_field) { + auto v = fc::json::from_string(R"({"inner": {"x": 7}})"); + auto result = get_field_from_object(v, "inner"); + BOOST_REQUIRE(result.has_value()); + BOOST_CHECK(result->is_object()); +} + +// --------------------------------------------------------------------------- +// get_queue_length +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(get_queue_length_missing_branch_throws) { + auto queues = fc::json::from_string(R"({"other_queue": {"estimated_processed_at": 9999999999}})"); + BOOST_CHECK_THROW(get_queue_length(queues, "exit_queue"), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_missing_epa_field_throws) { + auto queues = fc::json::from_string(R"({"exit_queue": {"some_other_field": 123}})"); + BOOST_CHECK_THROW(get_queue_length(queues, "exit_queue"), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_non_numeric_epa_throws) { + auto queues = fc::json::from_string(R"({"exit_queue": {"estimated_processed_at": "not-a-number"}})"); + BOOST_CHECK_THROW(get_queue_length(queues, "exit_queue"), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_branch_not_object_throws) { + auto queues = fc::json::from_string(R"({"exit_queue": 12345})"); + BOOST_CHECK_THROW(get_queue_length(queues, "exit_queue"), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_valid_returns_eta) { + auto result = get_queue_length(make_queue("exit_queue"), "exit_queue"); + BOOST_REQUIRE(result.has_value()); + BOOST_CHECK_GT(*result, uint64_t{0}); + // eta must be less than the raw epoch (now_sec > 0, so delta < epa) + BOOST_CHECK_LT(*result, far_future_epa); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_deposit_queue_branch) { + auto result = get_queue_length(make_queue("deposit_queue"), "deposit_queue"); + BOOST_REQUIRE(result.has_value()); + BOOST_CHECK_GT(*result, uint64_t{0}); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_past_epa_returns_empty) { + // epa=1 is in the past; should return nullopt rather than wrapping + auto queues = fc::json::from_string(R"({"exit_queue": {"estimated_processed_at": 1}})"); + auto result = get_queue_length(queues, "exit_queue"); + BOOST_CHECK(!result.has_value()); +} + +// --------------------------------------------------------------------------- +// apy_fraction_to_bps +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_zero) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(0.0), 0u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_five_percent) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(0.05), 500u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_one_hundred_percent) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(1.0), 10000u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_three_point_four_two_percent) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(0.0342), 342u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_twelve_point_three_four_percent) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(0.1234), 1234u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_epsilon_robustness) { + // 0.03 * 10000 may produce 299.9999... in floating point without the epsilon guard + BOOST_CHECK_EQUAL(apy_fraction_to_bps(0.03), 300u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_negative_clamped_to_zero) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(-0.05), 0u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_nan_clamped_to_zero) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(std::nan("")), 0u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_inf_clamped_to_zero) { + BOOST_CHECK_EQUAL(apy_fraction_to_bps(std::numeric_limits::infinity()), 0u); +} + +BOOST_AUTO_TEST_CASE(apy_fraction_to_bps_extremely_large_clamped) { + // Wave 2 caps fraction at 100.0 (= 1,000,000 bps) before the uint64_t cast. + BOOST_CHECK_EQUAL(apy_fraction_to_bps(1e300), 1000000u); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_negative_epa_throws) { + // Wave 2 rejects negative int64 EPA before casting to uint64 (which would wrap huge). + auto queues = fc::json::from_string(R"({"exit_queue": {"estimated_processed_at": -1}})"); + BOOST_CHECK_THROW(get_queue_length(queues, "exit_queue"), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(get_queue_length_uint64_max_far_future_returns_eta) { + // Extremely large but still a valid uint64 - must not wrap; returns a huge ETA. + // Separate sanity caps in compute_queue_updates guard against pushing such a value on-chain. + auto queues = fc::json::from_string(R"({"exit_queue": {"estimated_processed_at": 18446744073709551614}})"); + auto result = get_queue_length(queues, "exit_queue"); + BOOST_REQUIRE(result.has_value()); + BOOST_CHECK_GT(*result, uint64_t{0}); +} + +BOOST_AUTO_TEST_SUITE_END() + +// --------------------------------------------------------------------------- +// compute_queue_updates +// --------------------------------------------------------------------------- + +namespace { + constexpr uint64_t seconds_per_day = 60 * 60 * 24; + constexpr uint64_t nine_days_sec = seconds_per_day * 9; + + // Near-future EPA helpers chosen to fit under the sanity caps enforced by + // beacon_chain_config_updates (180-day withdraw cap, 365-day entry cap). + uint64_t near_future_epa(uint64_t days_from_now) { + return fc::time_point::now().sec_since_epoch() + days_from_now * seconds_per_day + 100; + } + + fc::variant make_queues_response(std::optional exit_epa, + std::optional deposit_epa) { + auto exit_val = exit_epa ? std::to_string(*exit_epa) : "1"; + auto dep_val = deposit_epa ? std::to_string(*deposit_epa) : "1"; + auto json = R"({"exit_queue": {"estimated_processed_at": )" + exit_val + + R"(}, "deposit_queue": {"estimated_processed_at": )" + dep_val + "}}"; + return fc::json::from_string(json); + } + + fc::variant make_ethstore_response(std::optional avgapr7d) { + if (!avgapr7d) + return fc::json::from_string(R"({"other_field": 123})"); + auto json = R"({"avgapr7d": )" + std::to_string(*avgapr7d) + "}"; + return fc::json::from_string(json); + } + + beacon_chain_config_updates make_crank(uint64_t exit_buffer_days = 9) { + return beacon_chain_config_updates({}, exit_buffer_days); + } + + // Capture shape used by the orchestration tests to record per-tx confirm_tx invocations. + // Replaces the production `pending_tx` struct that was removed when batching went away. + struct seen_tx { + std::string method; + std::string tx_hash; + }; +} + +BOOST_AUTO_TEST_SUITE(compute_queue_updates_tests) + +BOOST_AUTO_TEST_CASE(exit_queue_with_valid_eta) { + auto queues = make_queues_response(near_future_epa(7), near_future_epa(3)); + auto result = make_crank().compute_queue_updates(queues); + BOOST_REQUIRE(result.withdraw_delay_sec.has_value()); + BOOST_CHECK_GT(*result.withdraw_delay_sec, nine_days_sec); +} + +BOOST_AUTO_TEST_CASE(exit_queue_past_epa_defaults_to_nine_days) { + auto queues = make_queues_response(1, far_future_epa); + auto result = make_crank().compute_queue_updates(queues); + BOOST_REQUIRE(result.withdraw_delay_sec.has_value()); + BOOST_CHECK_EQUAL(*result.withdraw_delay_sec, nine_days_sec); +} + +BOOST_AUTO_TEST_CASE(deposit_queue_valid_eta_converts_to_days) { + uint64_t three_days_from_now_epa = + fc::time_point::now().sec_since_epoch() + 3 * seconds_per_day + 100; + auto queues = make_queues_response(far_future_epa, three_days_from_now_epa); + auto result = make_crank().compute_queue_updates(queues); + BOOST_REQUIRE(result.entry_queue_days.has_value()); + BOOST_CHECK_GE(*result.entry_queue_days, 2u); + BOOST_CHECK_LE(*result.entry_queue_days, 4u); +} + +BOOST_AUTO_TEST_CASE(deposit_queue_past_epa_defaults_to_one_day) { + auto queues = make_queues_response(far_future_epa, 1); + auto result = make_crank().compute_queue_updates(queues); + BOOST_REQUIRE(result.entry_queue_days.has_value()); + BOOST_CHECK_EQUAL(*result.entry_queue_days, 1u); +} + +BOOST_AUTO_TEST_CASE(all_queue_fields_populated) { + auto queues = make_queues_response(near_future_epa(7), near_future_epa(3)); + auto result = make_crank().compute_queue_updates(queues); + BOOST_CHECK(result.withdraw_delay_sec.has_value()); + BOOST_CHECK(result.entry_queue_days.has_value()); +} + +BOOST_AUTO_TEST_CASE(exit_queue_buffer_days_is_configurable) { + auto queues = make_queues_response(1, far_future_epa); // past ETA, uses pure buffer + auto result = make_crank(14).compute_queue_updates(queues); + BOOST_REQUIRE(result.withdraw_delay_sec.has_value()); + BOOST_CHECK_EQUAL(*result.withdraw_delay_sec, 14u * seconds_per_day); +} + +BOOST_AUTO_TEST_CASE(withdraw_delay_exceeding_cap_is_skipped) { + // Wave 2 sanity cap: 180 days. Use buffer large enough to blow past it. + // buffer_days=200 with past ETA -> 200-day withdraw -> exceeds 180-day cap -> skipped. + auto queues = make_queues_response(1, near_future_epa(3)); + auto result = make_crank(200).compute_queue_updates(queues); + BOOST_CHECK(!result.withdraw_delay_sec.has_value()); + BOOST_CHECK(result.entry_queue_days.has_value()); // unaffected +} + +BOOST_AUTO_TEST_CASE(entry_queue_days_exceeding_cap_is_skipped) { + // deposit_eta with 400-day horizon -> entry_queue_days=400 -> exceeds 365-day cap -> skipped. + auto queues = make_queues_response(near_future_epa(7), near_future_epa(400)); + auto result = make_crank().compute_queue_updates(queues); + BOOST_CHECK(result.withdraw_delay_sec.has_value()); // unaffected + BOOST_CHECK(!result.entry_queue_days.has_value()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// --------------------------------------------------------------------------- +// compute_apy_updates +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_SUITE(compute_apy_updates_tests) + +BOOST_AUTO_TEST_CASE(apy_present_and_numeric) { + auto ethstore = make_ethstore_response(0.05); + auto result = make_crank().compute_apy_updates(ethstore); + BOOST_REQUIRE(result.apy_bps.has_value()); + BOOST_CHECK_EQUAL(*result.apy_bps, 500u); +} + +BOOST_AUTO_TEST_CASE(apy_missing_field_returns_nullopt) { + auto ethstore = make_ethstore_response(std::nullopt); + auto result = make_crank().compute_apy_updates(ethstore); + BOOST_CHECK(!result.apy_bps.has_value()); +} + +BOOST_AUTO_TEST_CASE(apy_three_point_four_two_percent) { + auto ethstore = make_ethstore_response(0.0342); + auto result = make_crank().compute_apy_updates(ethstore); + BOOST_REQUIRE(result.apy_bps.has_value()); + BOOST_CHECK_EQUAL(*result.apy_bps, 342u); +} + +BOOST_AUTO_TEST_CASE(apy_integer_value_is_accepted) { + // Wave 2 broadened acceptance from is_double() to is_numeric(); an unquoted int + // like `"avgapr7d": 5` (meaning 500% APR) must parse, not silently yield 0. + auto ethstore = fc::json::from_string(R"({"avgapr7d": 1})"); + auto result = make_crank().compute_apy_updates(ethstore); + BOOST_REQUIRE(result.apy_bps.has_value()); + // 1.0 fraction = 10000 bps = the max_apy_bps cap, so it's accepted (not skipped). + BOOST_CHECK_EQUAL(*result.apy_bps, 10000u); +} + +BOOST_AUTO_TEST_CASE(apy_exceeds_cap_is_skipped) { + // 2.0 fraction -> 20000 bps -> exceeds 10000 bps cap -> skipped. + auto ethstore = make_ethstore_response(2.0); + auto result = make_crank().compute_apy_updates(ethstore); + BOOST_CHECK(!result.apy_bps.has_value()); +} + +BOOST_AUTO_TEST_CASE(apy_string_field_is_skipped) { + // Present-but-non-numeric field (string) must not broadcast a bogus value. + auto ethstore = fc::json::from_string(R"({"avgapr7d": "oops"})"); + auto result = make_crank().compute_apy_updates(ethstore); + BOOST_CHECK(!result.apy_bps.has_value()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// --------------------------------------------------------------------------- +// beacon_chain_config_updates orchestration +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_SUITE(beacon_chain_config_updates_tests) + +BOOST_AUTO_TEST_CASE(happy_path_all_txs_sent_and_confirmed) { + int withdraw_called = 0, entry_called = 0, apy_called = 0; + std::vector confirmed_txs; + + beacon_chain_config_updates crank({ + .fetch_queues = []() { return make_queues_response(near_future_epa(7), near_future_epa(3)); }, + .fetch_apy = []() { return make_ethstore_response(0.05); }, + .send_set_withdraw_delay = [&](uint64_t v) { ++withdraw_called; return "0xhash1"; }, + .send_set_entry_queue = [&](uint64_t v) { ++entry_called; return "0xhash2"; }, + .send_update_apy_bps = [&](uint64_t v) { ++apy_called; return "0xhash3"; }, + .confirm_tx = [&](std::string_view m, const std::string& h) { + confirmed_txs.push_back({std::string(m), h}); + } + }, 9); + crank(); + + BOOST_CHECK_EQUAL(withdraw_called, 1); + BOOST_CHECK_EQUAL(entry_called, 1); + BOOST_CHECK_EQUAL(apy_called, 1); + BOOST_CHECK_EQUAL(confirmed_txs.size(), 3u); +} + +BOOST_AUTO_TEST_CASE(null_withdraw_contract_skips_set_withdraw_delay) { + int withdraw_called = 0; + std::vector confirmed_txs; + + beacon_chain_config_updates crank({ + .fetch_queues = []() { return make_queues_response(near_future_epa(7), near_future_epa(3)); }, + .fetch_apy = []() { return make_ethstore_response(0.05); }, + .send_set_withdraw_delay = {}, + .send_set_entry_queue = [](uint64_t) { return "0xhash"; }, + .send_update_apy_bps = [](uint64_t) { return "0xhash"; }, + .confirm_tx = [&](std::string_view m, const std::string& h) { + confirmed_txs.push_back({std::string(m), h}); + } + }, 9); + crank(); + + BOOST_CHECK_EQUAL(withdraw_called, 0); + BOOST_CHECK_EQUAL(confirmed_txs.size(), 2u); +} + +BOOST_AUTO_TEST_CASE(null_deposit_manager_skips_entry_and_apy) { + int entry_called = 0, apy_called = 0; + std::vector confirmed_txs; + + beacon_chain_config_updates crank({ + .fetch_queues = []() { return make_queues_response(near_future_epa(7), near_future_epa(3)); }, + .fetch_apy = []() { return make_ethstore_response(0.05); }, + .send_set_withdraw_delay = [](uint64_t) { return "0xhash"; }, + .send_set_entry_queue = {}, + .send_update_apy_bps = {}, + .confirm_tx = [&](std::string_view m, const std::string& h) { + confirmed_txs.push_back({std::string(m), h}); + } + }, 9); + crank(); + + BOOST_CHECK_EQUAL(entry_called, 0); + BOOST_CHECK_EQUAL(apy_called, 0); + BOOST_CHECK_EQUAL(confirmed_txs.size(), 1u); +} + +BOOST_AUTO_TEST_CASE(apy_missing_skips_update_apy_bps) { + int apy_called = 0; + std::vector confirmed_txs; + + beacon_chain_config_updates crank({ + .fetch_queues = []() { return make_queues_response(near_future_epa(7), near_future_epa(3)); }, + .fetch_apy = []() { return make_ethstore_response(std::nullopt); }, + .send_set_withdraw_delay = [](uint64_t) { return "0xhash1"; }, + .send_set_entry_queue = [](uint64_t) { return "0xhash2"; }, + .send_update_apy_bps = [&](uint64_t) { ++apy_called; return "0xhash3"; }, + .confirm_tx = [&](std::string_view m, const std::string& h) { + confirmed_txs.push_back({std::string(m), h}); + } + }, 9); + crank(); + + BOOST_CHECK_EQUAL(apy_called, 0); + BOOST_CHECK_EQUAL(confirmed_txs.size(), 2u); +} + +BOOST_AUTO_TEST_CASE(fetch_throws_does_not_crash) { + beacon_chain_config_updates crank({ + .fetch_queues = []() -> fc::variant { throw std::runtime_error("network error"); }, + .fetch_apy = []() { return make_ethstore_response(0.05); }, + .send_set_withdraw_delay = [](uint64_t) { return "0xhash"; }, + .send_set_entry_queue = [](uint64_t) { return "0xhash"; }, + .send_update_apy_bps = [](uint64_t) { return "0xhash"; }, + .confirm_tx = [](std::string_view, const std::string&) {} + }, 9); + BOOST_CHECK_NO_THROW(crank()); +} + +BOOST_AUTO_TEST_CASE(send_callback_throws_does_not_crash) { + beacon_chain_config_updates crank({ + .fetch_queues = []() { return make_queues_response(near_future_epa(7), near_future_epa(3)); }, + .fetch_apy = []() { return make_ethstore_response(0.05); }, + .send_set_withdraw_delay = [](uint64_t) -> std::string { throw std::runtime_error("send failed"); }, + .send_set_entry_queue = [](uint64_t) { return std::string("0xhash2"); }, + .send_update_apy_bps = [](uint64_t) { return std::string("0xhash3"); }, + .confirm_tx = [](std::string_view, const std::string&) {} + }, 9); + BOOST_CHECK_NO_THROW(crank()); +} + +BOOST_AUTO_TEST_CASE(confirm_tx_throws_does_not_crash) { + // A throwing confirm_tx must not propagate, and must not prevent subsequent sends: + // safely_confirm() in the implementation logs and continues so that confirmation + // failure on tx 1 still lets txs 2 and 3 be sent. + int withdraw_called = 0, entry_called = 0, apy_called = 0; + beacon_chain_config_updates crank({ + .fetch_queues = []() { return make_queues_response(near_future_epa(7), near_future_epa(3)); }, + .fetch_apy = []() { return make_ethstore_response(0.05); }, + .send_set_withdraw_delay = [&](uint64_t) { ++withdraw_called; return std::string("0xhash1"); }, + .send_set_entry_queue = [&](uint64_t) { ++entry_called; return std::string("0xhash2"); }, + .send_update_apy_bps = [&](uint64_t) { ++apy_called; return std::string("0xhash3"); }, + .confirm_tx = [](std::string_view, const std::string&) { throw std::runtime_error("confirm failed"); } + }, 9); + BOOST_CHECK_NO_THROW(crank()); + BOOST_CHECK_EQUAL(withdraw_called, 1); + BOOST_CHECK_EQUAL(entry_called, 1); + BOOST_CHECK_EQUAL(apy_called, 1); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/programs/CMakeLists.txt b/programs/CMakeLists.txt index 4ea08f1ab9..9bc53d1f72 100644 --- a/programs/CMakeLists.txt +++ b/programs/CMakeLists.txt @@ -1,5 +1,6 @@ add_subdirectory( nodeop ) add_subdirectory( clio ) +add_subdirectory( cranker ) add_subdirectory( kiod ) add_subdirectory( sys-util ) diff --git a/programs/cranker/CMakeLists.txt b/programs/cranker/CMakeLists.txt new file mode 100644 index 0000000000..38eebd89c9 --- /dev/null +++ b/programs/cranker/CMakeLists.txt @@ -0,0 +1,8 @@ + +set(TARGET_NAME cranker) + +file(GLOB_RECURSE SRC_FILES src/*.cpp src/*.hpp) + +chain_target(${TARGET_NAME} SOURCE_FILES ${SRC_FILES}) + +target_link_libraries(${TARGET_NAME} PRIVATE wire_eth_maintenance_plugin) diff --git a/programs/cranker/README.md b/programs/cranker/README.md new file mode 100644 index 0000000000..9d37be6dff --- /dev/null +++ b/programs/cranker/README.md @@ -0,0 +1,144 @@ +# Cranker + +`cranker` is a standalone executable that periodically fetches Ethereum beacon chain state from [beaconcha.in](https://beaconcha.in) and pushes updates into on-chain smart contracts. It builds on the same plugin infrastructure as `nodeop` but runs only the subset of plugins needed to crank -- no producer, net, or state-history work is performed. + +## What it does + +On each scheduled interval, `cranker`: + +1. **Updates withdrawal delay** — Fetches the `exit_queue.estimated_processed_at` epoch from the beaconcha.in queues API and calls `WithdrawalQueue.setWithdrawDelay` with the queue length in seconds (plus a 9-day base floor). +2. **Updates entry queue** — Fetches the `deposit_queue.estimated_processed_at` epoch and calls `DepositManager.setEntryQueue` with the queue length in days (default: 1 day if the queue timestamp is in the past). +3. **Updates APY** — Fetches the `avgapr7d` field from the beaconcha.in ethstore API and calls `DepositManager.updateApyBPS` with the value expressed in basis points. +4. **Finalizes epochs** — Calls `OPP.finalizeEpoch` on a separate configurable interval. + +Each on-chain call is submitted via the `outpost_ethereum_client_plugin` and awaits block confirmation (up to 50 minutes: 600 retries at 5-second intervals) before proceeding to the next step. + +## Minimum required configuration + +1. At least one Ethereum **signature provider** (`--signature-provider`) +2. At least one Ethereum **outpost client** (`--outpost-ethereum-client`) +3. At least one **ABI file** (`--ethereum-abi-file`) containing the relevant contract definitions +4. A **contract addresses file** (`--beacon-chain-contracts-addrs`) mapping contract names to addresses +5. A **beacon chain API key** (`--beacon-chain-api-key`) if queue/APY updates are enabled + +## Security considerations + +The cranker is a signing daemon that holds private keys and broadcasts signed Ethereum transactions. Treat it accordingly: + +- **Do not pass private keys on the command line.** Inline forms like `KEY:0x` on an interactive shell or systemd `ExecStart=` line leak the key to `/proc//cmdline`, shell history, `ps`/`htop` output, and the systemd journal. Prefer: + - A **config file** (`--config-dir=/etc/cranker`) with mode `0600` and owner restricted to the cranker user, specifying `signature-provider = ...` directives. + - Or a **`KIOD:` backend** (`KIOD::`), which keeps the private key in a separate key daemon (`kiod`) that the cranker talks to over a local socket. +- **Pin the chain-id** in every `--outpost-ethereum-client` spec (the optional 4th comma-separated field). An unpinned client trusts whatever chainId the RPC returns, which is a replay-attack surface if the RPC is compromised or misconfigured. +- **Restrict RPC egress.** The signer only needs outbound access to its pinned RPC endpoint and to `beaconcha.in`. Close everything else at the firewall. +- **Rotate the beacon-chain API key** if the cranker host is ever suspected compromised. The key is a third-party rate-limit token for a public explorer and its blast radius is small, but it is still a secret. +- **Logs contain operator-identifiable metadata.** Treat the cranker's stdout/journal with the same sensitivity as an ordinary signer log pipeline. + +## Configuration options + +### Signature provider (`--signature-provider`) + +Registers an Ethereum signing key. Format: `,,,,` + +| Field | Description | +|---|---| +| `name` | Reference name for this provider (e.g. `eth-01`) | +| `chain-kind` | Chain kind: `ethereum` | +| `key-type` | Key format: `ethereum` | +| `public-key` | Hex-encoded public key | +| `private-key-spec` | Private key specifier, e.g. `KEY:0x` | + +### Outpost Ethereum client (`--outpost-ethereum-client`) + +Configures the connection to an Ethereum JSON-RPC node. Format: `,,[,]` + +| Field | Description | +|---|---| +| `client-id` | Unique name for this client | +| `sig-provider-id` | Name of the signature provider to use (must match a `--signature-provider` name) | +| `rpc-url` | Ethereum JSON-RPC endpoint URL | +| `chain-id` | (Optional) Ethereum chain ID; omit to let the client query it | + +### Ethereum ABI file (`--ethereum-abi-file`) + +Path to a JSON file containing an array of ABI-compliant contract definitions. The file must include a `contractName` field for each contract so that `cranker` can match entries against the expected contract names (`OPP`, `DepositManager`, `WithdrawalQueue`). Can be specified multiple times. + +### Contract addresses (`--beacon-chain-contracts-addrs`) + +Path to a JSON file mapping contract names to their deployed addresses. Example: + +```json +{ + "OPP": "0x1234...", + "DepositManager": "0xabcd...", + "WithdrawalQueue": "0xef01..." +} +``` + +Can be specified multiple times. Contracts whose names are absent are silently skipped — only the contracts whose addresses are provided will be driven. + +### Interval schedules (`--beacon-chain-interval`) + +Defines named cron schedules. Format: `,` + +The cron expression supports the standard 5-field format (`minute hour day-of-month month day-of-week`) and an extended 6-field format with a leading millisecond field. Common examples: + +| Expression | Meaning | +|---|---| +| `* * * * *` | Every minute | +| `0 * * * *` | Every hour | +| `0 */6 * * *` | Every 6 hours | +| `0 0 * * *` | Daily at midnight | + +If no `--beacon-chain-interval` is provided, a single default interval named `default` is created with a schedule of `0 * * * *` (every hour at :00). + +A built-in `once` interval is always available — it runs immediately on startup and does not repeat. This is the default for both `--beacon-chain-update-interval` and `--beacon-chain-finalize-epoch-interval` if not overridden. + +**Reserved name:** `once` cannot be used as a custom interval name. + +### Beacon chain API key (`--beacon-chain-api-key`) + +Bearer token for authenticating with the beaconcha.in API. **Required** to enable queue length and APY updates. When this option is absent, only epoch finalization runs (if configured). + +### Queue/APY update interval (`--beacon-chain-update-interval`) + +Name of the interval (defined via `--beacon-chain-interval`) on which to run the queue and APY update. Defaults to `once` (runs immediately on startup, does not repeat). + +### Finalize epoch interval (`--beacon-chain-finalize-epoch-interval`) + +Name of the interval on which to call `OPP.finalizeEpoch`. Defaults to `once`. Has no effect if no `OPP` contract address is configured. + +### Beacon chain endpoint URLs (optional overrides) + +| Option | Default | +|---|---| +| `--beacon-chain-queue-url` | `https://beaconcha.in/api/v2/ethereum/queues` | +| `--beacon-chain-apy-url` | `https://beaconcha.in/api/v1/ethstore/latest` | + +## Example + +```shell +cranker \ + --signature-provider eth-signer,ethereum,ethereum,0x,KEY:0x \ + --outpost-ethereum-client mainnet,eth-signer,https://eth-rpc.example.com,1 \ + --ethereum-abi-file /etc/cranker/abis.json \ + --beacon-chain-contracts-addrs /etc/cranker/addresses.json \ + --beacon-chain-api-key \ + --beacon-chain-interval "hourly,0 * * * *" \ + --beacon-chain-update-interval hourly \ + --beacon-chain-finalize-epoch-interval hourly +``` + +This runs queue/APY updates and epoch finalization once per hour. + +To run everything once immediately and exit: + +```shell +cranker \ + --signature-provider eth-signer,ethereum,ethereum,0x,KEY:0x \ + --outpost-ethereum-client mainnet,eth-signer,https://eth-rpc.example.com,1 \ + --ethereum-abi-file /etc/cranker/abis.json \ + --beacon-chain-contracts-addrs /etc/cranker/addresses.json \ + --beacon-chain-api-key +``` + +Omitting `--beacon-chain-interval` uses the default `once` interval for both `--beacon-chain-update-interval` and `--beacon-chain-finalize-epoch-interval`. diff --git a/programs/cranker/src/main.cpp b/programs/cranker/src/main.cpp new file mode 100644 index 0000000000..658885c64e --- /dev/null +++ b/programs/cranker/src/main.cpp @@ -0,0 +1,39 @@ + +#include +#include +#include +#include + +using namespace appbase; +using namespace sysio; +using namespace sysio::chain; + +int main(int argc, char** argv) { + + chain::application exe{application_config{.enable_resource_monitor = false, .log_on_exit = false}}; + + auto r = exe.init(argc, argv); + if (r != exit_code::SUCCESS) + return r == exit_code::NODE_MANAGEMENT_SUCCESS ? exit_code::SUCCESS : r; + + wire_eth_maintenance_plugin& wire_plug = app().get_plugin(); + exe.set_stop_executor_cb([&wire_plug]() { + ilog("Exiting cranker"); + wire_plug.interrupt(); + }); + + try { + return exe.exec(); + } catch (const fc::exception& e) { + elog("{}", e.to_detail_string()); + } catch (const boost::exception& e) { + elog("{}", boost::diagnostic_information(e)); + } catch (const std::exception& e) { + elog("{}", e.what()); + } catch (...) { + elog("unknown exception"); + } + + return exit_code::OTHER_FAIL; + +}