From 535c9f567c8da8aea419905ed8cadf2482c18cb6 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Wed, 22 Jul 2026 22:14:49 +0000 Subject: [PATCH 1/2] Validate outpost client startup configuration --- docs/outpost-client-plugins.md | 8 + .../include/fc-test/one_shot_http_server.hpp | 164 +++++++++++++++ .../sysio/outpost_ethereum_client_plugin.hpp | 4 +- .../src/outpost_ethereum_client_plugin.cpp | 164 +++++++++++++-- .../test_outpost_ethereum_client_plugin.cpp | 196 ++++++++++++++++++ .../outpost_solana_client_plugin/README.md | 23 +- .../src/outpost_solana_client_plugin.cpp | 111 +++++++++- .../test_outpost_solana_client_plugin.cpp | 155 ++++++++++++++ .../signature_provider_manager_plugin.hpp | 16 +- .../src/signature_provider_manager_plugin.cpp | 63 +++++- .../test/test_create_provider_specs.cpp | 6 + 11 files changed, 874 insertions(+), 36 deletions(-) create mode 100644 libraries/libfc-test/include/fc-test/one_shot_http_server.hpp diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index c7f0692df6..166174e4b5 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -12,6 +12,14 @@ The Ethereum client plugin is configured via program options as follows: ``` > NOTE: If you look closely, the reference to `eth-01` in the Ethereum client config, matches the signature provider configured for `Ethereum`. This mapping is what enables `1..n` clients in a single process +The signer reference is validated during startup. Each +`--outpost-ethereum-client` must reference the explicit, non-empty name of a +configured `--signature-provider`; anonymous signature-provider specs cannot be +referenced by an Ethereum client. When the optional Ethereum chain ID is +present, startup also calls `eth_chainId` on the configured RPC endpoint and +fails if the endpoint is unavailable, returns an invalid value, or reports a +different chain ID. + With the above configuration and the appropriate `app` & `plugin` config, you can access the `outpost-ethereum-client` configured with name/id == `eth-anvil-local` as follows ```cpp diff --git a/libraries/libfc-test/include/fc-test/one_shot_http_server.hpp b/libraries/libfc-test/include/fc-test/one_shot_http_server.hpp new file mode 100644 index 0000000000..e42f5af264 --- /dev/null +++ b/libraries/libfc-test/include/fc-test/one_shot_http_server.hpp @@ -0,0 +1,164 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace fc::test { + +/** + * Minimal one-request loopback HTTP server for synchronous RPC client tests. + * + * The caller supplies the complete response body. The server accepts one + * request, returns HTTP 200 with that body, and closes the connection. Its + * destructor also unblocks an unused accept so tests that fail before making + * the expected request do not strand a worker thread. + */ +class one_shot_http_server { +public: + explicit one_shot_http_server(std::string response_body, std::string expected_rpc_method = {}) + : _response_body(std::move(response_body)) + , _expected_rpc_method(std::move(expected_rpc_method)) + , _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) + , _port(_acceptor.local_endpoint().port()) + , _worker([this] { serve(); }) {} + + one_shot_http_server(const one_shot_http_server&) = delete; + one_shot_http_server& operator=(const one_shot_http_server&) = delete; + + ~one_shot_http_server() { + // Wake a worker blocked in synchronous accept without concurrently + // operating on the acceptor from two threads. The temporary socket is + // closed immediately, so a worker that accepts it exits its HTTP read. + boost::system::error_code error; + boost::asio::io_context io; + tcp::socket socket(io); + socket.connect(tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), _port), error); + socket.close(error); + if (_worker.joinable()) { + _worker.join(); + } + _acceptor.close(error); + } + + /** Return the loopback URL selected for this server. */ + std::string url() const { + return "http://127.0.0.1:" + std::to_string(_port); + } + +private: + using tcp = boost::asio::ip::tcp; + + void serve() { + boost::system::error_code error; + tcp::socket socket(_io); + _acceptor.accept(socket, error); + if (error) { + return; + } + + boost::beast::flat_buffer request_buffer; + boost::beast::http::request request; + boost::beast::http::read(socket, request_buffer, request, error); + if (error) { + return; + } + + if (!_expected_rpc_method.empty()) { + bool request_matches = false; + try { + const auto request_object = fc::json::from_string(request.body()).get_object(); + request_matches = + request_object.contains("method") && + request_object["method"].as_string() == _expected_rpc_method && + request_object.contains("params") && + request_object["params"].is_array() && + request_object["params"].get_array().empty(); + } catch (...) { + return; + } + if (!request_matches) { + return; + } + } + + std::ostringstream response; + response << "HTTP/1.1 200 OK\r\n" + << "Content-Type: application/json\r\n" + << "Content-Length: " << _response_body.size() << "\r\n" + << "Connection: close\r\n\r\n" + << _response_body; + const auto response_text = response.str(); + boost::asio::write(socket, boost::asio::buffer(response_text), error); + } + + std::string _response_body; + std::string _expected_rpc_method; + boost::asio::io_context _io; + tcp::acceptor _acceptor; + uint16_t _port; + std::thread _worker; +}; + +/** + * Loopback server that deterministically accepts one connection and closes it. + * + * Unlike selecting and releasing an unused port, this fixture keeps ownership + * of the port until the client connects, so another process cannot race the + * test and unexpectedly provide a working endpoint. + */ +class connection_closing_http_server { +public: + connection_closing_http_server() + : _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) + , _port(_acceptor.local_endpoint().port()) + , _worker([this] { close_connection(); }) {} + + connection_closing_http_server(const connection_closing_http_server&) = delete; + connection_closing_http_server& operator=(const connection_closing_http_server&) = delete; + + ~connection_closing_http_server() { + boost::system::error_code error; + boost::asio::io_context io; + tcp::socket socket(io); + socket.connect(tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), _port), error); + socket.close(error); + if (_worker.joinable()) { + _worker.join(); + } + _acceptor.close(error); + } + + std::string url() const { + return "http://127.0.0.1:" + std::to_string(_port); + } + +private: + using tcp = boost::asio::ip::tcp; + + void close_connection() { + boost::system::error_code error; + tcp::socket socket(_io); + _acceptor.accept(socket, error); + if (error) { + return; + } + socket.set_option(boost::asio::socket_base::linger(true, 0), error); + socket.close(error); + } + + boost::asio::io_context _io; + tcp::acceptor _acceptor; + uint16_t _port; + std::thread _worker; +}; + +} // namespace fc::test 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 1be9389344..f02f504bae 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 @@ -19,7 +19,7 @@ struct ethereum_client_entry_t { /// batch operator auto-select the client for an outpost row by matching the /// row's `external_chain_id`, so multiple EVM outposts never share one /// remote endpoint. `nullopt` when the spec omitted the chain id. - std::optional chain_id; + std::optional chain_id; }; using ethereum_client_entry_ptr = std::shared_ptr; @@ -108,7 +108,7 @@ class outpost_ethereum_client_plugin : public appbase::plugin>>& get_abi_files(); 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 7f3cb356e3..ceef0ef1a2 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,5 +1,8 @@ #include +#include + #include +#include #include #include @@ -8,13 +11,114 @@ namespace sysio { // using namespace outpost_client::ethereum; namespace { -constexpr auto option_name_client = "outpost-ethereum-client"; -constexpr auto option_abi_file = "ethereum-abi-file"; +constexpr auto option_name_client = "outpost-ethereum-client"; +constexpr auto option_abi_file = "ethereum-abi-file"; +constexpr auto chain_id_validation_timeout = fc::seconds(5); [[maybe_unused]] inline fc::logger& logger() { static fc::logger log{"outpost_ethereum_client_plugin"}; return log; } + +/** Parse a positive decimal or Ethereum hex quantity without fixed-width wraparound. */ +std::optional parse_chain_id(std::string_view text) { + if (text.empty()) { + return std::nullopt; + } + + uint32_t base = 10; + size_t offset = 0; + if (text.size() >= 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X')) { + base = 16; + offset = 2; + } + if (offset == text.size()) { + return std::nullopt; + } + + uint32_t value = 0; + for (; offset < text.size(); ++offset) { + const char c = text[offset]; + uint32_t digit; + if (c >= '0' && c <= '9') { + digit = static_cast(c - '0'); + } else if (c >= 'a' && c <= 'f') { + digit = static_cast(c - 'a' + 10); + } else if (c >= 'A' && c <= 'F') { + digit = static_cast(c - 'A' + 10); + } else { + return std::nullopt; + } + if (digit >= base || value > (std::numeric_limits::max() - digit) / base) { + return std::nullopt; + } + value = value * base + digit; + } + + return value == 0 ? std::nullopt : std::optional{value}; +} + +/** Construct the client and verify any explicit chain id within one startup deadline. */ +ethereum_client_ptr create_validated_client( + const std::string& client_id, + const fc::crypto::signature_provider_ptr& signature_provider, + const std::string& url, + const std::optional& configured_chain_id) { + fc::task::deadline_scope deadline(fc::time_point::now() + chain_id_validation_timeout); + + ethereum_client_ptr client; + try { + const auto client_chain_id = configured_chain_id + ? std::optional{fc::uint256{*configured_chain_id}} + : std::nullopt; + client = std::make_shared(signature_provider, url, client_chain_id); + } catch (const fc::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to initialize outpost Ethereum client '{}': the configured RPC endpoint is invalid or could not be resolved", + client_id); + } catch (const std::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to initialize outpost Ethereum client '{}': the configured RPC endpoint is invalid or could not be resolved", + client_id); + } + + if (configured_chain_id) { + std::string remote_chain_id_text; + try { + remote_chain_id_text = client->execute("eth_chainId", fc::variants{}).as_string(); + } catch (const fc::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to validate chain id for outpost Ethereum client '{}': " + "the configured RPC endpoint did not return a valid eth_chainId", + client_id); + } catch (const std::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to validate chain id for outpost Ethereum client '{}': " + "the configured RPC endpoint did not return a valid eth_chainId", + client_id); + } + + const auto remote_chain_id = parse_chain_id(remote_chain_id_text); + SYS_ASSERT(remote_chain_id, + chain::plugin_config_exception, + "Unable to validate chain id for outpost Ethereum client '{}': " + "the configured RPC endpoint did not return a valid 32-bit eth_chainId", + client_id); + + SYS_ASSERT(*remote_chain_id == *configured_chain_id, + chain::plugin_config_exception, + "Chain id mismatch for outpost Ethereum client '{}': configured {}, RPC endpoint reports {}", + client_id, + *configured_chain_id, + *remote_chain_id); + } + + return client; +} } class outpost_ethereum_client_plugin_impl { @@ -46,7 +150,7 @@ class outpost_ethereum_client_plugin_impl { return _clients.at(id); } - ethereum_client_entry_ptr get_client_by_chain_id(uint64_t chain_id) { + ethereum_client_entry_ptr get_client_by_chain_id(uint32_t chain_id) { ethereum_client_entry_ptr match; for (auto& [id, entry] : _clients) { if (entry->chain_id && *entry->chain_id == chain_id) { @@ -85,26 +189,56 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti for (auto& client_spec : client_specs) { dlog("Adding ethereum client with spec: {}", client_spec); auto parts = fc::split(client_spec, ','); - FC_ASSERT(parts.size() == 3 || parts.size() == 4, "Invalid spec {}", client_spec); + SYS_ASSERT(parts.size() == 3 || parts.size() == 4, + chain::plugin_config_exception, + "Invalid {} spec '{}' (expected: ,,[,])", + option_name_client, + client_spec); auto& id = parts[0]; auto& sig_id = parts[1]; auto& url = parts[2]; + SYS_ASSERT(!id.empty(), chain::plugin_config_exception, + "Invalid {} spec: Ethereum client id must not be empty", option_name_client); + SYS_ASSERT(!sig_id.empty(), chain::plugin_config_exception, + "Invalid {} spec for client '{}': signer name must not be empty", option_name_client, id); + SYS_ASSERT(!url.empty(), chain::plugin_config_exception, + "Invalid {} spec for client '{}': RPC URL must not be empty", option_name_client, id); + SYS_ASSERT(sig_mgr.is_explicitly_configured_provider(sig_id), + chain::plugin_config_exception, + "Outpost Ethereum client '{}' references signer '{}', but no explicitly named " + "--signature-provider with that name was specified", + id, + sig_id); + fc::ostring chain_id_str = parts.size() == 4 ? fc::ostring{parts[3]} : fc::ostring{}; - std::optional chain_id; - std::optional chain_id_num; + std::optional chain_id; if (chain_id_str.has_value()) { - chain_id = std::make_optional(fc::to_uint256(chain_id_str.value())); - chain_id_num = chain_id->convert_to(); + SYS_ASSERT(!chain_id_str->empty(), chain::plugin_config_exception, + "Invalid {} spec for client '{}': chain id must not be empty", option_name_client, id); + chain_id = parse_chain_id(*chain_id_str); + SYS_ASSERT(chain_id, + chain::plugin_config_exception, + "Invalid {} spec for client '{}': chain id must be a positive 32-bit decimal or hex integer", + option_name_client, + id); } auto sig_provider = sig_mgr.get_provider(sig_id); + SYS_ASSERT(sig_provider->target_chain == fc::crypto::chain_kind_ethereum && + sig_provider->key_type == fc::crypto::chain_key_type_ethereum, + chain::plugin_config_exception, + "Outpost Ethereum client '{}' signer '{}' must use chain=ethereum and key-type=ethereum", + id, + sig_id); + + auto eth_client = create_validated_client(id, sig_provider, url, chain_id); my->add_client(id, std::make_shared( id, url, sig_provider, - std::make_shared(sig_provider, url, chain_id), - chain_id_num)); + std::move(eth_client), + chain_id)); ilog("Added ethereum client (id={},sig_id={},url={},chainId={})", id, sig_id, url, - chain_id_num ? std::to_string(*chain_id_num) : "none"); + chain_id ? std::to_string(*chain_id) : "none"); } } @@ -120,8 +254,10 @@ void outpost_ethereum_client_plugin::set_program_options(options_description& cl cfg.add_options()( option_name_client, boost::program_options::value>()->multitoken(), - "Outpost Ethereum Client spec, the plugin supports 1 to many clients in a given process" - "`,,[,]`")( + "Outpost Ethereum Client spec, the plugin supports 1 to many clients in a given process: " + "`,,[,]`. The signer id must " + "match an explicitly named --signature-provider; an explicit chain ID is checked against " + "the endpoint's eth_chainId response during startup")( option_abi_file, boost::program_options::value>()->multitoken(), "Ethereum contract ABI file(s). Expects the file to have a JSON array of ABI complient contract definitions." @@ -141,7 +277,7 @@ ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client(const std:: return my->get_client(id); } -ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client_by_chain_id(uint64_t chain_id) { +ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client_by_chain_id(uint32_t chain_id) { return my->get_client_by_chain_id(chain_id); } diff --git a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp index 5d75f399ce..22bd0778b4 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -187,6 +188,46 @@ constexpr size_t rpc_length_oversized_envelope_bytes = sysio::OPP_MAX_ENVELOPE_B constexpr char malformed_envelope_byte = static_cast(0xff); constexpr char oversized_envelope_fill_byte = static_cast(0x01); +/** Build a one-shot JSON-RPC endpoint reporting Anvil's chain id (31337). */ +fc::test::one_shot_http_server chain_id_rpc_server(std::string result_json = "\"0x7a69\"") { + return fc::test::one_shot_http_server{ + R"json({"jsonrpc":"2.0","id":1,"result":)json" + result_json + "}", + "eth_chainId"}; +} + +/** Build the canonical named Ethereum signature-provider test spec. */ +std::string named_ethereum_signature_provider(std::string name = "signer-a", + std::string chain_kind = "ethereum") { + return name + "," + chain_kind + ",ethereum," + std::string(latest_slot_test_public_key) + + ",KEY:" + std::string(latest_slot_test_private_key); +} + +/** Build a provider with Ethereum targeting but a valid non-Ethereum key type. */ +std::string ethereum_target_with_wire_key_provider() { + const auto fixture = fc::test::load_keygen_fixture("wire", 1); + return fc::crypto::to_signature_provider_spec( + "signer-a", + fc::crypto::chain_kind_ethereum, + fixture.chain_key_type, + fixture.public_key, + fc::test::to_private_key_spec(fixture.private_key)); +} + +/** Initialize the complete outpost plugin with the supplied configuration arguments. */ +void initialize_outpost_plugin(const std::vector& configuration_arguments) { + appbase::scoped_app test_application{}; + std::vector arguments{"test_outpost_ethereum_client_plugin"}; + arguments.insert(arguments.end(), configuration_arguments.begin(), configuration_arguments.end()); + + std::vector argv; + argv.reserve(arguments.size()); + for (auto& argument : arguments) { + argv.emplace_back(argument.data()); + } + + BOOST_REQUIRE(test_application->initialize(argv.size(), argv.data())); +} + auto load_abi_fixture(std::string_view filename) { auto path = fc::test::get_test_fixtures_path() / bfs::path(filename); return fc::network::ethereum::abi::parse_contracts(std::filesystem::path(path.generic_string())); @@ -221,6 +262,161 @@ std::vector serialize_envelope(uint32_t epoch) { BOOST_AUTO_TEST_SUITE(outpost_ethereum_client_plugin) +// --------------------------------------------------------------------------- +// Startup configuration validation +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(startup_accepts_matching_named_signer_and_remote_chain_id) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",31337", + })); +} + +BOOST_AUTO_TEST_CASE(startup_accepts_three_field_client_without_remote_chain_id_check) { + fc::test::connection_closing_http_server rpc_server; + BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url(), + })); +} + +BOOST_AUTO_TEST_CASE(startup_accepts_maximum_registered_chain_id) { + auto rpc_server = chain_id_rpc_server("\"0xffffffff\""); + BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",4294967295", + })); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_client_without_matching_named_signature_provider) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider("other-signer"), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",31337", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_anonymous_signature_provider_reference) { + auto rpc_server = chain_id_rpc_server(); + const std::string anonymous_provider = + "ethereum,ethereum," + std::string(latest_slot_test_public_key) + + ",KEY:" + std::string(latest_slot_test_private_key); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + anonymous_provider, + "--outpost-ethereum-client", + "client-a,key-0," + rpc_server.url() + ",31337", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_for_wrong_chain) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider("signer-a", "wire"), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",31337", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_with_wrong_key_type) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + ethereum_target_with_wire_key_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",31337", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_chain_id_mismatch_with_rpc_endpoint) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",1", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_unavailable_rpc_when_chain_id_is_explicit) { + fc::test::connection_closing_http_server rpc_server; + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",31337", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_invalid_remote_chain_id) { + auto rpc_server = chain_id_rpc_server("\"not-a-chain-id\""); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",31337", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_malformed_configured_chain_id_as_plugin_configuration) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",not-a-chain-id", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_chain_id_that_cannot_match_registered_outpost_id) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",4294998633", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_configured_chain_id_wider_than_uint256_without_wraparound) { + auto rpc_server = chain_id_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + + ",115792089237316195423570985008687907853269984665640564039457584007913129671273", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_remote_chain_id_wider_than_uint256_without_wraparound) { + auto rpc_server = chain_id_rpc_server( + "\"0x10000000000000000000000000000000000000000000000000000000000007a69\""); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + rpc_server.url() + ",31337", + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(one_shot_http_server_destruction_without_request_does_not_block) { + fc::test::one_shot_http_server unused_server{ + R"json({"jsonrpc":"2.0","id":1,"result":"0x1"})json", + "eth_chainId"}; +} + // --------------------------------------------------------------------------- // OPP typed contract client tests // --------------------------------------------------------------------------- diff --git a/plugins/outpost_solana_client_plugin/README.md b/plugins/outpost_solana_client_plugin/README.md index 8010621dfb..734aeb53d8 100644 --- a/plugins/outpost_solana_client_plugin/README.md +++ b/plugins/outpost_solana_client_plugin/README.md @@ -39,14 +39,14 @@ Registers a signing key. Format: |---|---| | `name` | Lookup ID for this provider (used in `--outpost-solana-client`) | | `chain-kind` | Target chain: `solana`, `ethereum`, `wire` | -| `key-type` | Key algorithm, e.g. `ed` (ED25519 for Solana) | -| `public-key` | Base58 or hex-encoded public key | +| `key-type` | Native key type: `solana` (ED25519) | +| `public-key` | Base58-encoded Solana public key | | `provider-type:data` | Either `KEY:` or `KIOD:` | -Example using an inline private key: +Example using the repository's public test fixture key: ``` ---signature-provider sol-signer,solana,ed,PUB_ED_...,KEY:PVT_ED_... +--signature-provider sol-signer,solana,solana,FDkbys9aWZSSCMYmstq948DfFmjxY69x4qUj7KSr7LCa,KEY:5pNoVifxrWsaAPDyaKGPoKiPjhhbHGaEFdTVmjx9qZJKS472eGsA5118YqEf2m7xUreC2kv6TDq9sssRceeQXHrJ ``` #### `--outpost-solana-client` (required, multi-token) @@ -54,14 +54,21 @@ Example using an inline private key: Registers a Solana RPC client instance. Format: ``` -,, +,,[,] ``` | Field | Description | |---|---| | `client-id` | Unique identifier for this client instance | -| `sig-provider-id` | Name of a registered `--signature-provider` | +| `sig-provider-id` | Explicit, non-empty name of a registered `--signature-provider` | | `rpc-url` | Solana JSON-RPC endpoint URL | +| `genesis-hash` | Optional expected base58 genesis hash for exact cluster identity | + +Startup resolves the signer reference exactly and requires it to use the +Solana chain and key type. Anonymous signature-provider aliases cannot be +referenced. Every configured endpoint must return a valid `getGenesisHash` +response within five seconds. When `genesis-hash` is present, that response +must match it exactly or startup fails. Multiple clients can be configured: @@ -94,7 +101,7 @@ program; the clean-room outpost implementation is hosted inside the ```bash ./outpost_solana_client_tool \ - --signature-provider sol-signer,solana,ed,PUB_ED_abc123...,KEY:PVT_ED_def456... \ + --signature-provider sol-signer,solana,solana,FDkbys9aWZSSCMYmstq948DfFmjxY69x4qUj7KSr7LCa,KEY:5pNoVifxrWsaAPDyaKGPoKiPjhhbHGaEFdTVmjx9qZJKS472eGsA5118YqEf2m7xUreC2kv6TDq9sssRceeQXHrJ \ --outpost-solana-client my-client,sol-signer,https://api.devnet.solana.com \ --solana-idl-file ./idl/counter_anchor.json ``` @@ -104,7 +111,7 @@ program; the clean-room outpost implementation is hosted inside the The same options work in a config `.ini` file: ```ini -signature-provider = sol-signer,solana,ed,PUB_ED_abc123...,KEY:PVT_ED_def456... +signature-provider = sol-signer,solana,solana,FDkbys9aWZSSCMYmstq948DfFmjxY69x4qUj7KSr7LCa,KEY:5pNoVifxrWsaAPDyaKGPoKiPjhhbHGaEFdTVmjx9qZJKS472eGsA5118YqEf2m7xUreC2kv6TDq9sssRceeQXHrJ outpost-solana-client = my-client,sol-signer,https://api.devnet.solana.com solana-idl-file = ./idl/counter_anchor.json ``` diff --git a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp index 21c2c86552..207e2c5039 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp @@ -1,5 +1,7 @@ #include + #include +#include #include #include @@ -10,11 +12,77 @@ namespace { constexpr auto option_name_client = "outpost-solana-client"; constexpr auto option_idl_file = "solana-idl-file"; constexpr auto option_outpost_program_name = "solana-outpost-program-name"; +constexpr auto genesis_hash_validation_timeout = fc::seconds(5); [[maybe_unused]] inline fc::logger& logger() { static fc::logger log{"outpost_solana_client_plugin"}; return log; } + +/** Construct the client and validate its cluster identity within one startup deadline. */ +std::pair create_validated_client( + const std::string& client_id, + const fc::crypto::signature_provider_ptr& signature_provider, + const std::string& url, + const std::optional& expected_genesis_hash) { + if (expected_genesis_hash) { + try { + fc::crypto::solana::solana_public_key::from_base58_string(*expected_genesis_hash); + } catch (const fc::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Invalid expected genesis hash for outpost Solana client '{}': expected a base58-encoded 32-byte hash", + client_id); + } catch (const std::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Invalid expected genesis hash for outpost Solana client '{}': expected a base58-encoded 32-byte hash", + client_id); + } + } + + fc::task::deadline_scope deadline(fc::time_point::now() + genesis_hash_validation_timeout); + solana_client_ptr client; + try { + client = std::make_shared(signature_provider, url); + } catch (const fc::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to initialize outpost Solana client '{}': the configured RPC endpoint is invalid or could not be resolved", + client_id); + } catch (const std::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to initialize outpost Solana client '{}': the configured RPC endpoint is invalid or could not be resolved", + client_id); + } + + std::string remote_genesis_hash; + try { + remote_genesis_hash = client->get_genesis_hash(); + fc::crypto::solana::solana_public_key::from_base58_string(remote_genesis_hash); + } catch (const fc::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to validate outpost Solana client '{}': the configured RPC endpoint did not " + "return a valid getGenesisHash result", + client_id); + } catch (const std::exception&) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to validate outpost Solana client '{}': the configured RPC endpoint did not " + "return a valid getGenesisHash result", + client_id); + } + + SYS_ASSERT(!expected_genesis_hash || remote_genesis_hash == *expected_genesis_hash, + chain::plugin_config_exception, + "Genesis hash mismatch for outpost Solana client '{}': configured {}, RPC endpoint reports {}", + client_id, + expected_genesis_hash.value_or("none"), + remote_genesis_hash); + return {std::move(client), std::move(remote_genesis_hash)}; +} } // namespace class outpost_solana_client_plugin_impl { @@ -94,17 +162,48 @@ void outpost_solana_client_plugin::plugin_initialize(const variables_map& option for (auto& client_spec : client_specs) { dlog("Adding solana client with spec: {}", client_spec); auto parts = fc::split(client_spec, ','); - FC_ASSERT(parts.size() == 3, "Invalid spec {} (expected: ,,)", - client_spec); + SYS_ASSERT(parts.size() == 3 || parts.size() == 4, + chain::plugin_config_exception, + "Invalid spec {} (expected: ,,[,])", + client_spec); auto& id = parts[0]; auto& sig_id = parts[1]; auto& url = parts[2]; + SYS_ASSERT(!id.empty(), chain::plugin_config_exception, + "Invalid {} spec: Solana client id must not be empty", option_name_client); + SYS_ASSERT(!sig_id.empty(), chain::plugin_config_exception, + "Invalid {} spec for client '{}': signer name must not be empty", option_name_client, id); + SYS_ASSERT(!url.empty(), chain::plugin_config_exception, + "Invalid {} spec for client '{}': RPC URL must not be empty", option_name_client, id); + SYS_ASSERT(sig_mgr.is_explicitly_configured_provider(sig_id), + chain::plugin_config_exception, + "Outpost Solana client '{}' references signer '{}', but no explicitly named " + "--signature-provider with that name was specified", + id, + sig_id); + + std::optional expected_genesis_hash; + if (parts.size() == 4) { + SYS_ASSERT(!parts[3].empty(), chain::plugin_config_exception, + "Invalid {} spec for client '{}': genesis hash must not be empty", option_name_client, id); + expected_genesis_hash = parts[3]; + } + auto sig_provider = sig_mgr.get_provider(sig_id); + SYS_ASSERT(sig_provider->target_chain == fc::crypto::chain_kind_solana && + sig_provider->key_type == fc::crypto::chain_key_type_solana, + chain::plugin_config_exception, + "Outpost Solana client '{}' signer '{}' must use chain=solana and key-type=solana", + id, + sig_id); + + auto [sol_client, genesis_hash] = create_validated_client(id, sig_provider, url, expected_genesis_hash); my->add_client(id, std::make_shared( id, url, sig_provider, - std::make_shared(sig_provider, url))); - ilog("Added solana client (id={},sig_id={},url={})", id, sig_id, url); + std::move(sol_client))); + ilog("Added solana client (id={},sig_id={},genesisHash={},url={})", + id, sig_id, genesis_hash, url); } } @@ -117,7 +216,9 @@ void outpost_solana_client_plugin::set_program_options(options_description& cli, option_name_client, boost::program_options::value>()->multitoken(), "Outpost Solana Client spec, the plugin supports 1 to many clients in a given process. " - "Format: `,,`")( + "Format: `,,[,]`. The signer id must " + "match an explicitly named --signature-provider. Startup validates getGenesisHash, and an " + "explicit genesis hash must match the endpoint's response")( option_idl_file, boost::program_options::value>()->multitoken(), "Solana program IDL file(s). Expects each file to be a JSON IDL (Anchor format) program definition.")( diff --git a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp index b195db73b6..9a67039820 100644 --- a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include #include #include #include @@ -26,6 +28,55 @@ namespace { constexpr std::string_view counter_anchor_idl_fixture = "solana-idl-counter-anchor.json"; constexpr std::string_view opp_outpost_idl_fixture = "solana-idl-opp-outpost-stub.json"; constexpr std::string_view sec94_terminal_budget_fixture = "sec-94-solana-terminal-budget.json"; +constexpr std::string_view test_genesis_hash = "11111111111111111111111111111111"; +constexpr std::string_view different_genesis_hash = "SysvarC1ock11111111111111111111111111111111"; + +/** Build a one-shot JSON-RPC endpoint returning the supplied genesis hash. */ +fc::test::one_shot_http_server genesis_hash_rpc_server( + std::string genesis_hash = std::string(test_genesis_hash)) { + return fc::test::one_shot_http_server{ + R"json({"jsonrpc":"2.0","id":1,"result":")json" + genesis_hash + R"json("})json", + "getGenesisHash"}; +} + +/** Build a named Solana signature-provider spec from the canonical fixture. */ +std::string named_solana_signature_provider( + std::string name = "signer-a", + fc::crypto::chain_kind_t chain_kind = fc::crypto::chain_kind_solana) { + const auto fixture = fc::test::load_keygen_fixture("solana", 1); + return fc::crypto::to_signature_provider_spec( + name, + chain_kind, + fixture.chain_key_type, + fixture.public_key, + fc::test::to_private_key_spec(fixture.private_key)); +} + +/** Build a provider with Solana targeting but a valid non-Solana key type. */ +std::string solana_target_with_wire_key_provider() { + const auto fixture = fc::test::load_keygen_fixture("wire", 1); + return fc::crypto::to_signature_provider_spec( + "signer-a", + fc::crypto::chain_kind_solana, + fixture.chain_key_type, + fixture.public_key, + fc::test::to_private_key_spec(fixture.private_key)); +} + +/** Initialize the complete Solana outpost plugin with supplied configuration arguments. */ +void initialize_outpost_plugin(const std::vector& configuration_arguments) { + appbase::scoped_app test_application{}; + std::vector arguments{"test_outpost_solana_client_plugin"}; + arguments.insert(arguments.end(), configuration_arguments.begin(), configuration_arguments.end()); + + std::vector argv; + argv.reserve(arguments.size()); + for (auto& argument : arguments) { + argv.emplace_back(argument.data()); + } + + BOOST_REQUIRE(test_application->initialize(argv.size(), argv.data())); +} /// Measured legacy transaction dimensions for a terminal Solana `epoch_in` call. struct terminal_tx_measurement { @@ -267,6 +318,110 @@ void check_measurement_matches_fixture(const terminal_tx_measurement& measured, BOOST_AUTO_TEST_SUITE(outpost_solana_client_plugin) +// --------------------------------------------------------------------------- +// Startup configuration validation +// --------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(startup_accepts_matching_named_signer_and_genesis_hash) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider(), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), + })); +} + +BOOST_AUTO_TEST_CASE(startup_validates_rpc_without_configured_genesis_hash) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider(), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url(), + })); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_client_without_matching_named_signature_provider) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider("other-signer"), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_anonymous_signature_provider_reference) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider(""), + "--outpost-solana-client", + "client-a,key-0," + rpc_server.url() + "," + std::string(test_genesis_hash), + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_for_wrong_chain) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider("signer-a", fc::crypto::chain_kind_wire), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_with_wrong_key_type) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + solana_target_with_wire_key_provider(), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_genesis_hash_mismatch_with_rpc_endpoint) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider(), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url() + "," + std::string(different_genesis_hash), + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_unavailable_solana_rpc) { + fc::test::connection_closing_http_server rpc_server; + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider(), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url(), + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_invalid_remote_genesis_hash) { + auto rpc_server = genesis_hash_rpc_server("not-a-genesis-hash"); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider(), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url(), + }), sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(startup_rejects_invalid_configured_genesis_hash) { + auto rpc_server = genesis_hash_rpc_server(); + BOOST_CHECK_THROW(initialize_outpost_plugin({ + "--signature-provider", + named_solana_signature_provider(), + "--outpost-solana-client", + "client-a,signer-a," + rpc_server.url() + ",not-a-genesis-hash", + }), sysio::chain::plugin_config_exception); +} + BOOST_AUTO_TEST_CASE(can_load_counter_anchor_idl) try { auto prog = load_idl_fixture(counter_anchor_idl_fixture); diff --git a/plugins/signature_provider_manager_plugin/include/sysio/signature_provider_manager_plugin/signature_provider_manager_plugin.hpp b/plugins/signature_provider_manager_plugin/include/sysio/signature_provider_manager_plugin/signature_provider_manager_plugin.hpp index ea161d11df..7f9f6566e2 100644 --- a/plugins/signature_provider_manager_plugin/include/sysio/signature_provider_manager_plugin/signature_provider_manager_plugin.hpp +++ b/plugins/signature_provider_manager_plugin/include/sysio/signature_provider_manager_plugin/signature_provider_manager_plugin.hpp @@ -178,6 +178,20 @@ class signature_provider_manager_plugin : public appbase::plugin query_signature_providers( -} \ No newline at end of file +} diff --git a/plugins/signature_provider_manager_plugin/src/signature_provider_manager_plugin.cpp b/plugins/signature_provider_manager_plugin/src/signature_provider_manager_plugin.cpp index 6235c41517..53695a5eb7 100644 --- a/plugins/signature_provider_manager_plugin/src/signature_provider_manager_plugin.cpp +++ b/plugins/signature_provider_manager_plugin/src/signature_provider_manager_plugin.cpp @@ -272,6 +272,14 @@ class signature_provider_manager_plugin_impl { return _signing_providers_by_pubkey.contains(std::get(key)); } + void mark_explicitly_configured_provider(const std::string& key_name) { + _explicitly_configured_provider_names.insert(key_name); + } + + bool is_explicitly_configured_provider(const std::string& key_name) const { + return _explicitly_configured_provider_names.contains(key_name); + } + fc::crypto::signature_provider_ptr get_provider(const fc::crypto::signature_provider_id_t& key) { if (holds_alternative(key)) { auto& keyName = std::get(key); @@ -429,7 +437,16 @@ class signature_provider_manager_plugin_impl { } } - fc::crypto::signature_provider_ptr create_provider(const string& spec) { + struct parsed_provider_spec { + std::string key_name; + fc::crypto::chain_kind_t target_chain; + fc::crypto::chain_key_type_t key_type; + std::string public_key_text; + std::string private_key_provider_spec; + bool has_explicit_name; + }; + + parsed_provider_spec parse_provider_spec(const string& spec) const { using namespace fc::crypto; //,,,, auto spec_parts = fc::split(spec, ',', 5); @@ -449,11 +466,40 @@ class signature_provider_manager_plugin_impl { auto public_key_text = spec_parts[target_chain_idx + 2]; auto private_key_provider_spec = spec_parts[target_chain_idx + 3]; - if (key_name.empty()) { - key_name = std::format("key-{}", next_anon_key_counter()); + return { + .key_name = key_name, + .target_chain = kind, + .key_type = key_type, + .public_key_text = std::move(public_key_text), + .private_key_provider_spec = std::move(private_key_provider_spec), + .has_explicit_name = num_parts == 5 && !key_name.empty(), + }; + } + + fc::crypto::signature_provider_ptr create_provider(parsed_provider_spec parsed) { + if (parsed.key_name.empty()) { + parsed.key_name = std::format("key-{}", next_anon_key_counter()); } - return create_provider(key_name, kind, key_type, public_key_text, private_key_provider_spec); + return create_provider(parsed.key_name, + parsed.target_chain, + parsed.key_type, + parsed.public_key_text, + parsed.private_key_provider_spec); + } + + fc::crypto::signature_provider_ptr create_provider(const string& spec) { + return create_provider(parse_provider_spec(spec)); + } + + fc::crypto::signature_provider_ptr create_configured_provider(const string& spec) { + auto parsed = parse_provider_spec(spec); + const bool has_explicit_name = parsed.has_explicit_name; + auto provider = create_provider(std::move(parsed)); + if (has_explicit_name) { + mark_explicitly_configured_provider(provider->key_name); + } + return provider; } fc::crypto::signature_provider_ptr create_provider(const std::string& key_name, @@ -601,6 +647,7 @@ class signature_provider_manager_plugin_impl { */ std::map _signing_providers_by_name{}; std::map _signing_providers_by_pubkey{}; + std::set _explicitly_configured_provider_names{}; /** * One-shot startup probes, one per provider whose handler attached one (today: KMS). Collected as providers are @@ -690,7 +737,7 @@ void signature_provider_manager_plugin::plugin_initialize(const variables_map& o auto specs = options.at(option_name_provider).as>(); for (const auto& spec : specs) { dlog("Registering signature provider from spec: {}", redact_signature_provider_spec(spec)); - auto provider = my->create_provider(spec); + auto provider = my->create_configured_provider(spec); dlog("Registered signature provider ({}): {}", provider->key_name, provider->public_key.to_string({})); } @@ -725,6 +772,10 @@ bool signature_provider_manager_plugin::has_provider(const fc::crypto::signature return my->has_provider(key); } +bool signature_provider_manager_plugin::is_explicitly_configured_provider(const std::string& key_name) { + return my->is_explicitly_configured_provider(key_name); +} + fc::crypto::signature_provider_ptr signature_provider_manager_plugin::get_provider( const fc::crypto::signature_provider_id_t& key) { return my->get_provider(key); @@ -769,4 +820,4 @@ fc::crypto::signature_provider_ptr get_signature_provider(const fc::crypto::sign return providers[0]; } -} // namespace sysio \ No newline at end of file +} // namespace sysio diff --git a/plugins/signature_provider_manager_plugin/test/test_create_provider_specs.cpp b/plugins/signature_provider_manager_plugin/test/test_create_provider_specs.cpp index d13f174b85..85294c1863 100644 --- a/plugins/signature_provider_manager_plugin/test/test_create_provider_specs.cpp +++ b/plugins/signature_provider_manager_plugin/test/test_create_provider_specs.cpp @@ -81,6 +81,10 @@ BOOST_AUTO_TEST_CASE(create_provider_wire_key_from_example_spec) { auto provider = mgr.create_provider(provider_spec); + // A named provider created through the public API was not supplied through + // --signature-provider and must not satisfy another plugin's config reference. + BOOST_CHECK(!mgr.is_explicitly_configured_provider(provider->key_name)); + // Public key should match the one provided in spec BOOST_CHECK_EQUAL(provider->public_key.to_string({}), pub.to_string({})); BOOST_CHECK_EQUAL(provider->public_key, pub); @@ -196,8 +200,10 @@ BOOST_AUTO_TEST_CASE(ethereum_signature_provider_spec_options) { // Provider 1 should be retrievable BOOST_CHECK(!mgr.query_providers(fixture1.key_name).empty()); + BOOST_CHECK(mgr.is_explicitly_configured_provider(fixture1.key_name)); // Provider 2 should be retrievable BOOST_CHECK(!mgr.query_providers(fixture2.key_name).empty()); + BOOST_CHECK(mgr.is_explicitly_configured_provider(fixture2.key_name)); } BOOST_AUTO_TEST_CASE(wire_signature_provider_spec_options) { From b9a58cfdace81de962d144f76daa66c6bd4990bb Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Thu, 23 Jul 2026 14:57:19 +0000 Subject: [PATCH 2/2] Address PR review feedback --- .../src/outpost_ethereum_client_plugin.cpp | 39 ++++---- .../outpost_solana_client_plugin/README.md | 7 +- .../src/outpost_solana_client_plugin.cpp | 90 ++----------------- .../test_outpost_solana_client_plugin.cpp | 59 ++++-------- 4 files changed, 45 insertions(+), 150 deletions(-) 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 ceef0ef1a2..ada0cdda52 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 @@ -58,6 +58,23 @@ std::optional parse_chain_id(std::string_view text) { return value == 0 ? std::nullopt : std::optional{value}; } +/** Report a neutral configuration error after client construction fails. */ +[[noreturn]] void throw_client_initialization_failure(const std::string& client_id) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Failed to initialize outpost Ethereum client '{}'", + client_id); +} + +/** Report a stable configuration error when the explicit chain ID cannot be verified. */ +[[noreturn]] void throw_chain_id_validation_failure(const std::string& client_id) { + FC_THROW_EXCEPTION( + chain::plugin_config_exception, + "Unable to validate chain id for outpost Ethereum client '{}': " + "the configured RPC endpoint did not return a valid eth_chainId", + client_id); +} + /** Construct the client and verify any explicit chain id within one startup deadline. */ ethereum_client_ptr create_validated_client( const std::string& client_id, @@ -73,15 +90,9 @@ ethereum_client_ptr create_validated_client( : std::nullopt; client = std::make_shared(signature_provider, url, client_chain_id); } catch (const fc::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to initialize outpost Ethereum client '{}': the configured RPC endpoint is invalid or could not be resolved", - client_id); + throw_client_initialization_failure(client_id); } catch (const std::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to initialize outpost Ethereum client '{}': the configured RPC endpoint is invalid or could not be resolved", - client_id); + throw_client_initialization_failure(client_id); } if (configured_chain_id) { @@ -89,17 +100,9 @@ ethereum_client_ptr create_validated_client( try { remote_chain_id_text = client->execute("eth_chainId", fc::variants{}).as_string(); } catch (const fc::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to validate chain id for outpost Ethereum client '{}': " - "the configured RPC endpoint did not return a valid eth_chainId", - client_id); + throw_chain_id_validation_failure(client_id); } catch (const std::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to validate chain id for outpost Ethereum client '{}': " - "the configured RPC endpoint did not return a valid eth_chainId", - client_id); + throw_chain_id_validation_failure(client_id); } const auto remote_chain_id = parse_chain_id(remote_chain_id_text); diff --git a/plugins/outpost_solana_client_plugin/README.md b/plugins/outpost_solana_client_plugin/README.md index 734aeb53d8..01a6005f21 100644 --- a/plugins/outpost_solana_client_plugin/README.md +++ b/plugins/outpost_solana_client_plugin/README.md @@ -54,7 +54,7 @@ Example using the repository's public test fixture key: Registers a Solana RPC client instance. Format: ``` -,,[,] +,, ``` | Field | Description | @@ -62,13 +62,10 @@ Registers a Solana RPC client instance. Format: | `client-id` | Unique identifier for this client instance | | `sig-provider-id` | Explicit, non-empty name of a registered `--signature-provider` | | `rpc-url` | Solana JSON-RPC endpoint URL | -| `genesis-hash` | Optional expected base58 genesis hash for exact cluster identity | Startup resolves the signer reference exactly and requires it to use the Solana chain and key type. Anonymous signature-provider aliases cannot be -referenced. Every configured endpoint must return a valid `getGenesisHash` -response within five seconds. When `genesis-hash` is present, that response -must match it exactly or startup fails. +referenced. Multiple clients can be configured: diff --git a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp index 207e2c5039..0a691cd46c 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp @@ -1,7 +1,6 @@ #include #include -#include #include #include @@ -12,77 +11,11 @@ namespace { constexpr auto option_name_client = "outpost-solana-client"; constexpr auto option_idl_file = "solana-idl-file"; constexpr auto option_outpost_program_name = "solana-outpost-program-name"; -constexpr auto genesis_hash_validation_timeout = fc::seconds(5); [[maybe_unused]] inline fc::logger& logger() { static fc::logger log{"outpost_solana_client_plugin"}; return log; } - -/** Construct the client and validate its cluster identity within one startup deadline. */ -std::pair create_validated_client( - const std::string& client_id, - const fc::crypto::signature_provider_ptr& signature_provider, - const std::string& url, - const std::optional& expected_genesis_hash) { - if (expected_genesis_hash) { - try { - fc::crypto::solana::solana_public_key::from_base58_string(*expected_genesis_hash); - } catch (const fc::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Invalid expected genesis hash for outpost Solana client '{}': expected a base58-encoded 32-byte hash", - client_id); - } catch (const std::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Invalid expected genesis hash for outpost Solana client '{}': expected a base58-encoded 32-byte hash", - client_id); - } - } - - fc::task::deadline_scope deadline(fc::time_point::now() + genesis_hash_validation_timeout); - solana_client_ptr client; - try { - client = std::make_shared(signature_provider, url); - } catch (const fc::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to initialize outpost Solana client '{}': the configured RPC endpoint is invalid or could not be resolved", - client_id); - } catch (const std::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to initialize outpost Solana client '{}': the configured RPC endpoint is invalid or could not be resolved", - client_id); - } - - std::string remote_genesis_hash; - try { - remote_genesis_hash = client->get_genesis_hash(); - fc::crypto::solana::solana_public_key::from_base58_string(remote_genesis_hash); - } catch (const fc::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to validate outpost Solana client '{}': the configured RPC endpoint did not " - "return a valid getGenesisHash result", - client_id); - } catch (const std::exception&) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to validate outpost Solana client '{}': the configured RPC endpoint did not " - "return a valid getGenesisHash result", - client_id); - } - - SYS_ASSERT(!expected_genesis_hash || remote_genesis_hash == *expected_genesis_hash, - chain::plugin_config_exception, - "Genesis hash mismatch for outpost Solana client '{}': configured {}, RPC endpoint reports {}", - client_id, - expected_genesis_hash.value_or("none"), - remote_genesis_hash); - return {std::move(client), std::move(remote_genesis_hash)}; -} } // namespace class outpost_solana_client_plugin_impl { @@ -162,9 +95,10 @@ void outpost_solana_client_plugin::plugin_initialize(const variables_map& option for (auto& client_spec : client_specs) { dlog("Adding solana client with spec: {}", client_spec); auto parts = fc::split(client_spec, ','); - SYS_ASSERT(parts.size() == 3 || parts.size() == 4, + SYS_ASSERT(parts.size() == 3, chain::plugin_config_exception, - "Invalid spec {} (expected: ,,[,])", + "Invalid {} spec '{}' (expected: ,,)", + option_name_client, client_spec); auto& id = parts[0]; @@ -183,13 +117,6 @@ void outpost_solana_client_plugin::plugin_initialize(const variables_map& option id, sig_id); - std::optional expected_genesis_hash; - if (parts.size() == 4) { - SYS_ASSERT(!parts[3].empty(), chain::plugin_config_exception, - "Invalid {} spec for client '{}': genesis hash must not be empty", option_name_client, id); - expected_genesis_hash = parts[3]; - } - auto sig_provider = sig_mgr.get_provider(sig_id); SYS_ASSERT(sig_provider->target_chain == fc::crypto::chain_kind_solana && sig_provider->key_type == fc::crypto::chain_key_type_solana, @@ -198,12 +125,10 @@ void outpost_solana_client_plugin::plugin_initialize(const variables_map& option id, sig_id); - auto [sol_client, genesis_hash] = create_validated_client(id, sig_provider, url, expected_genesis_hash); my->add_client(id, std::make_shared( id, url, sig_provider, - std::move(sol_client))); - ilog("Added solana client (id={},sig_id={},genesisHash={},url={})", - id, sig_id, genesis_hash, url); + std::make_shared(sig_provider, url))); + ilog("Added solana client (id={},sig_id={},url={})", id, sig_id, url); } } @@ -216,9 +141,8 @@ void outpost_solana_client_plugin::set_program_options(options_description& cli, option_name_client, boost::program_options::value>()->multitoken(), "Outpost Solana Client spec, the plugin supports 1 to many clients in a given process. " - "Format: `,,[,]`. The signer id must " - "match an explicitly named --signature-provider. Startup validates getGenesisHash, and an " - "explicit genesis hash must match the endpoint's response")( + "Format: `,,`. The signer id must " + "match an explicitly named --signature-provider with the Solana target chain and key type")( option_idl_file, boost::program_options::value>()->multitoken(), "Solana program IDL file(s). Expects each file to be a JSON IDL (Anchor format) program definition.")( diff --git a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp index 9a67039820..a7f1925f46 100644 --- a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -28,16 +27,7 @@ namespace { constexpr std::string_view counter_anchor_idl_fixture = "solana-idl-counter-anchor.json"; constexpr std::string_view opp_outpost_idl_fixture = "solana-idl-opp-outpost-stub.json"; constexpr std::string_view sec94_terminal_budget_fixture = "sec-94-solana-terminal-budget.json"; -constexpr std::string_view test_genesis_hash = "11111111111111111111111111111111"; -constexpr std::string_view different_genesis_hash = "SysvarC1ock11111111111111111111111111111111"; - -/** Build a one-shot JSON-RPC endpoint returning the supplied genesis hash. */ -fc::test::one_shot_http_server genesis_hash_rpc_server( - std::string genesis_hash = std::string(test_genesis_hash)) { - return fc::test::one_shot_http_server{ - R"json({"jsonrpc":"2.0","id":1,"result":")json" + genesis_hash + R"json("})json", - "getGenesisHash"}; -} +constexpr std::string_view startup_test_rpc_url = "http://127.0.0.1:1"; /** Build a named Solana signature-provider spec from the canonical fixture. */ std::string named_solana_signature_provider( @@ -322,103 +312,84 @@ BOOST_AUTO_TEST_SUITE(outpost_solana_client_plugin) // Startup configuration validation // --------------------------------------------------------------------------- -BOOST_AUTO_TEST_CASE(startup_accepts_matching_named_signer_and_genesis_hash) { - auto rpc_server = genesis_hash_rpc_server(); - BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ - "--signature-provider", - named_solana_signature_provider(), - "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), - })); -} - -BOOST_AUTO_TEST_CASE(startup_validates_rpc_without_configured_genesis_hash) { - auto rpc_server = genesis_hash_rpc_server(); +BOOST_AUTO_TEST_CASE(startup_accepts_matching_named_signer) { BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider(), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url(), + "client-a,signer-a," + std::string(startup_test_rpc_url), })); } BOOST_AUTO_TEST_CASE(startup_rejects_client_without_matching_named_signature_provider) { - auto rpc_server = genesis_hash_rpc_server(); BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider("other-signer"), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), + "client-a,signer-a," + std::string(startup_test_rpc_url), }), sysio::chain::plugin_config_exception); } BOOST_AUTO_TEST_CASE(startup_rejects_anonymous_signature_provider_reference) { - auto rpc_server = genesis_hash_rpc_server(); BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider(""), "--outpost-solana-client", - "client-a,key-0," + rpc_server.url() + "," + std::string(test_genesis_hash), + "client-a,key-0," + std::string(startup_test_rpc_url), }), sysio::chain::plugin_config_exception); } BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_for_wrong_chain) { - auto rpc_server = genesis_hash_rpc_server(); BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider("signer-a", fc::crypto::chain_kind_wire), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), + "client-a,signer-a," + std::string(startup_test_rpc_url), }), sysio::chain::plugin_config_exception); } BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_with_wrong_key_type) { - auto rpc_server = genesis_hash_rpc_server(); BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", solana_target_with_wire_key_provider(), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url() + "," + std::string(test_genesis_hash), + "client-a,signer-a," + std::string(startup_test_rpc_url), }), sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(startup_rejects_genesis_hash_mismatch_with_rpc_endpoint) { - auto rpc_server = genesis_hash_rpc_server(); +BOOST_AUTO_TEST_CASE(startup_rejects_empty_client_id) { BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider(), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url() + "," + std::string(different_genesis_hash), + ",signer-a," + std::string(startup_test_rpc_url), }), sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(startup_rejects_unavailable_solana_rpc) { - fc::test::connection_closing_http_server rpc_server; +BOOST_AUTO_TEST_CASE(startup_rejects_empty_signer_name) { BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider(), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url(), + "client-a,," + std::string(startup_test_rpc_url), }), sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(startup_rejects_invalid_remote_genesis_hash) { - auto rpc_server = genesis_hash_rpc_server("not-a-genesis-hash"); +BOOST_AUTO_TEST_CASE(startup_rejects_empty_rpc_url) { BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider(), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url(), + "client-a,signer-a,", }), sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(startup_rejects_invalid_configured_genesis_hash) { - auto rpc_server = genesis_hash_rpc_server(); +BOOST_AUTO_TEST_CASE(startup_rejects_four_field_client_spec) { BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", named_solana_signature_provider(), "--outpost-solana-client", - "client-a,signer-a," + rpc_server.url() + ",not-a-genesis-hash", + "client-a,signer-a," + std::string(startup_test_rpc_url) + ",unexpected-fourth-field", }), sysio::chain::plugin_config_exception); }