From 9491e0ab50646385bd8eb0759295774d6c36eb85 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 18:04:19 +0000 Subject: [PATCH 01/13] Enforce Ethereum transaction expenditure policy --- docs/ethereum-transaction-policy.example.json | 13 + docs/outpost-client-plugins.md | 36 +- libraries/libfc/CMakeLists.txt | 1 + .../fc/network/ethereum/ethereum_client.hpp | 30 +- .../network/ethereum/ethereum_rlp_encoder.hpp | 4 +- .../ethereum/ethereum_transaction_policy.hpp | 134 ++++ .../src/network/ethereum/ethereum_client.cpp | 141 ++-- .../ethereum/ethereum_transaction_policy.cpp | 303 +++++++++ libraries/libfc/test/CMakeLists.txt | 1 + .../ethereum/test_ethereum_client_and_rlp.cpp | 28 + .../test_ethereum_transaction_policy.cpp | 284 ++++++++ .../sysio/outpost_ethereum_client_plugin.hpp | 32 +- .../src/outpost_ethereum_client_plugin.cpp | 389 +++++++++-- .../test/test_ethereum_transaction_policy.cpp | 610 ++++++++++++++++++ .../test_outpost_ethereum_client_plugin.cpp | 15 +- programs/examples/cranker-example/README.md | 10 +- 16 files changed, 1907 insertions(+), 124 deletions(-) create mode 100644 docs/ethereum-transaction-policy.example.json create mode 100644 libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp create mode 100644 libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp create mode 100644 libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp create mode 100644 plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp diff --git a/docs/ethereum-transaction-policy.example.json b/docs/ethereum-transaction-policy.example.json new file mode 100644 index 0000000000..0045b72da3 --- /dev/null +++ b/docs/ethereum-transaction-policy.example.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "policies": [ + { + "client_id": "eth-anvil-local", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } + ] +} diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index c7f0692df6..cb35f4b5af 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -9,8 +9,40 @@ The Ethereum client plugin is configured via program options as follows: ```sh --signature-provider "eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" --outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 +--outpost-ethereum-transaction-policy-file /etc/wire/ethereum-transaction-policy.json ``` -> 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 policy file is required whenever the plugin is configured. A minimal file is: + +```json +{ + "version": 1, + "policies": [ + { + "client_id": "eth-anvil-local", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } + ] +} +``` + +All policy integers are positive canonical decimal strings. Strings avoid JSON number precision loss for values up to `uint256`. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. + +Immediately before signing, the client requires: + +- `maxPriorityFeePerGas <= max_priority_fee_per_gas_wei` +- `maxFeePerGas <= max_fee_per_gas_wei` +- `gasLimit <= max_gas_limit` +- `maxFeePerGas >= maxPriorityFeePerGas` +- `gasLimit * maxFeePerGas + value <= max_total_native_cost_wei` + +All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. + +The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. 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 @@ -24,7 +56,7 @@ auto client_entry = eth_plug.get_clients()[0]; // CLIENT IS A `std::shared_ptr` auto& client = client_entry->client; -// GET CHAIN ID, JUST AN EXAMPLE +// GET THE AUTHORITATIVE POLICY CHAIN ID, JUST AN EXAMPLE // `chain_id` will have the type `fc::uint256` auto chain_id = client->get_chain_id(); ``` diff --git a/libraries/libfc/CMakeLists.txt b/libraries/libfc/CMakeLists.txt index 21bf152082..66e1255c6a 100644 --- a/libraries/libfc/CMakeLists.txt +++ b/libraries/libfc/CMakeLists.txt @@ -63,6 +63,7 @@ set(fc_sources src/network/ethereum/ethereum_abi.cpp src/network/ethereum/ethereum_client.cpp src/network/ethereum/ethereum_rlp_encoder.cpp + src/network/ethereum/ethereum_transaction_policy.cpp src/network/http/http_client.cpp src/network/json_rpc/json_rpc_client.cpp src/network/solana/solana_types.cpp diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp index 3720f1b15d..7355343f01 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp @@ -10,9 +10,12 @@ #include #include #include +#include #include #include +#include + namespace fc::network::ethereum { using namespace fc::crypto; using namespace fc::crypto::ethereum; @@ -328,10 +331,13 @@ class ethereum_client : public std::enable_shared_from_this { * @brief Constructs an EthereumClient instance. * @param sig_provider `signature_provider` shared pointer * @param url_source The URL of the Ethereum node (e.g., Infura, local node). - * @param chain_id optional uint256 encapsulating the chain id + * @param transaction_policy Required local expenditure policy and authoritative chain id */ ethereum_client(const signature_provider_ptr& sig_provider, const std::variant& url_source, - std::optional chain_id = std::nullopt); + ethereum_transaction_policy transaction_policy); + + /** Virtual destructor supporting deterministic RPC fakes in client tests. */ + virtual ~ethereum_client() = default; /** * @brief General method to send RPC requests. @@ -339,7 +345,7 @@ class ethereum_client : public std::enable_shared_from_this { * @param params The parameters for the RPC method (as a JSON object). * @return The raw JSON response as a string, wrapped in std::optional. */ - fc::variant execute(const std::string& method, const fc::variant& params); + virtual fc::variant execute(const std::string& method, const fc::variant& params); fc::variant execute_contract_view_fn(const address& contract_address, const abi::contract& abi, const block_number_or_tag_t& block, const contract_invoke_data_items& params); @@ -518,7 +524,7 @@ class ethereum_client : public std::enable_shared_from_this { * @brief Retrieves the chain ID of the connected Ethereum network. * @return The chain ID. */ - fc::uint256 get_chain_id(); + fc::uint256 get_chain_id() const; /** * @brief Retrieves the version of the connected Ethereum network. @@ -546,6 +552,9 @@ class ethereum_client : public std::enable_shared_from_this { */ ethereum::address get_signer_address() const { return _address; }; + /** Return the immutable local transaction policy attached at construction. */ + const ethereum_transaction_policy& transaction_policy() const { return _transaction_policy; } + /** * @brief Creates a default EIP-1559 transaction with estimated gas and current fees * @@ -580,6 +589,13 @@ class ethereum_client : public std::enable_shared_from_this { } private: + /** Fetch and validate fee suggestions without logging a duplicate rejection. */ + gas_config_t get_gas_config_unlogged(); + + /** Emit one sanitized high-severity record for a policy rejection. */ + void log_policy_rejection(const ethereum_transaction_policy_exception& rejection, + std::string_view operation_type) const; + /** * @brief Signature provider for signing transactions */ @@ -595,10 +611,8 @@ class ethereum_client : public std::enable_shared_from_this { */ json_rpc_client _client; - /** - * @brief Cached chain ID (fetched once and reused) - */ - std::optional _chain_id; + /** Required immutable policy, including the locally configured chain ID. */ + const ethereum_transaction_policy _transaction_policy; /** * @brief Mutex for thread-safe access to _contracts_map diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp index a8738a874e..cb8a4306d4 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp @@ -79,7 +79,7 @@ bytes encode_uint(T value) { bytes buf; bool started = false; - for (int shift = 56; shift >= 0; shift -= 8) { + for (int shift = 248; shift >= 0; shift -= 8) { std::uint8_t byte = static_cast((value >> shift) & 0xff); if (byte == 0 && !started) continue; @@ -154,4 +154,4 @@ bytes encode_eip1559_signed(const eip1559_tx& tx); bytes encode_eip1559_signed_typed(const eip1559_tx& tx); -} // namespace fc::network::ethereum::rlp \ No newline at end of file +} // namespace fc::network::ethereum::rlp diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp new file mode 100644 index 0000000000..84db9c72f2 --- /dev/null +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace fc::network::ethereum { + +/** Stable reason codes emitted when Ethereum transaction policy validation fails. */ +enum class ethereum_transaction_policy_reason { + configuration_file_unreadable, + configuration_schema_invalid, + configuration_version_unsupported, + configuration_client_missing, + configuration_client_duplicate, + configuration_client_unknown, + configuration_chain_id_mismatch, + configuration_value_invalid, + rpc_quantity_invalid, + rpc_quantity_out_of_range, + max_fee_derivation_overflow, + gas_limit_derivation_overflow, + fee_relationship_invalid, + chain_id_mismatch, + priority_fee_cap_exceeded, + max_fee_cap_exceeded, + gas_limit_cap_exceeded, + total_cost_multiplication_overflow, + total_cost_addition_overflow, + total_cost_cap_exceeded +}; + +/** Return the stable wire/log spelling for a transaction-policy reason. */ +inline std::string_view reason_code_name(ethereum_transaction_policy_reason reason) { + return magic_enum::enum_name(reason); +} + +/** Return whether an identifier is bounded and ASCII-safe for policy logs and lookups. */ +bool is_safe_transaction_policy_identifier(std::string_view identifier); + +/** + * Exception carrying structured, non-sensitive Ethereum transaction-policy rejection details. + * + * The reason enum is the stable machine-readable code. Field, observed, and allowed values are + * intentionally strings so arithmetic-overflow diagnostics can describe both operands without + * performing another potentially unsafe calculation. + */ +class ethereum_transaction_policy_exception : public fc::exception { +public: + /** Construct a structured transaction-policy rejection. */ + ethereum_transaction_policy_exception(ethereum_transaction_policy_reason reason, + std::string field, + std::string observed, + std::optional allowed = std::nullopt); + + /** Copy this exception without slicing its structured fields. */ + std::shared_ptr dynamic_copy_exception() const override; + + /** Rethrow this exception while preserving its dynamic type. */ + void rethrow() const override; + + /** Stable rejection reason. */ + ethereum_transaction_policy_reason reason() const { return _reason; } + + /** Transaction field or configuration field associated with the rejection. */ + const std::string& field() const { return _field; } + + /** Sanitized observed value or arithmetic operands. */ + const std::string& observed() const { return _observed; } + + /** Sanitized configured limit, when the rejection has one. */ + const std::optional& allowed() const { return _allowed; } + +private: + ethereum_transaction_policy_reason _reason; + std::string _field; + std::string _observed; + std::optional _allowed; +}; + +/** Immutable local expenditure policy for one configured Ethereum client and chain. */ +struct ethereum_transaction_policy { + std::string client_id; + fc::uint256 chain_id; + fc::uint256 max_priority_fee_per_gas; + fc::uint256 max_fee_per_gas; + fc::uint256 max_gas_limit; + fc::uint256 max_total_native_cost; +}; + +/** + * Parse a canonical unsigned decimal uint256 configuration value. + * + * Canonical values contain only ASCII decimal digits and have no leading zeroes. Zero is accepted + * only when `allow_zero` is true. Values wider than uint256 are rejected before conversion. + */ +fc::uint256 parse_canonical_uint256_decimal(std::string_view value, + std::string_view field, + bool allow_zero = false); + +/** + * Parse a canonical Ethereum JSON-RPC QUANTITY without truncation. + * + * The accepted form is `0x0` or `0x` followed by at most 64 hexadecimal digits with no leading zero. + */ +fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field); + +/** Format a uint256 as a canonical Ethereum JSON-RPC QUANTITY. */ +std::string format_rpc_quantity(const fc::uint256& value); + +/** Validate the policy itself before it is attached to a signing-capable client. */ +void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy); + +/** Derive and cap `2 * base_fee + priority_fee` using checked uint256 arithmetic. */ +fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, + const fc::uint256& priority_fee, + const fc::uint256& base_fee); + +/** Apply checked `(estimated_gas * 6) / 5` headroom and enforce the gas cap. */ +fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, + const fc::uint256& estimated_gas); + +/** Validate every settled EIP-1559 transaction field before a private-key operation. */ +void validate_transaction_against_policy(const ethereum_transaction_policy& policy, + const fc::crypto::ethereum::eip1559_tx& transaction); + +} // namespace fc::network::ethereum diff --git a/libraries/libfc/src/network/ethereum/ethereum_client.cpp b/libraries/libfc/src/network/ethereum/ethereum_client.cpp index 6e69cecc0a..58f46e5428 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_client.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_client.cpp @@ -8,7 +8,9 @@ #include #include #include + #include +#include #include @@ -18,6 +20,8 @@ namespace { using namespace fc::crypto; using namespace fc::crypto::ethereum; using namespace fc::network::json_rpc; + +constexpr std::string_view invalid_log_identifier = ""; } // namespace /** @@ -70,20 +74,22 @@ const abi::contract& ethereum_contract_client::get_abi(const std::string& contra * @brief Constructs an ethereum_client instance * * Initializes the client with a signature provider for transaction signing, - * a JSON-RPC endpoint URL, and an optional chain ID. The Ethereum address - * is derived from the signature provider's public key. + * a JSON-RPC endpoint URL, and a required local transaction policy. The + * Ethereum address is derived from the signature provider's public key. * * @param sig_provider Signature provider for signing transactions * @param url_source URL of the Ethereum node (string or fc::url) - * @param chain_id Optional chain ID (if not provided, will be fetched from the node) + * @param transaction_policy Required expenditure limits and authoritative chain ID */ ethereum_client::ethereum_client(const signature_provider_ptr& sig_provider, const std::variant& url_source, - std::optional chain_id) + ethereum_transaction_policy transaction_policy) : _signature_provider(sig_provider) , _address(to_address(_signature_provider->public_key)) , _client(json_rpc_client::create(url_source)) - , _chain_id(chain_id) {} + , _transaction_policy(std::move(transaction_policy)) { + validate_transaction_policy_configuration(_transaction_policy); +} /** * @brief Executes a JSON-RPC method call on the Ethereum node @@ -97,6 +103,22 @@ fc::variant ethereum_client::execute(const std::string& method, const fc::varian return _client.call(method, params); } +/** Emit a sanitized policy-rejection record without endpoint, key, calldata, or signature data. */ +void ethereum_client::log_policy_rejection(const ethereum_transaction_policy_exception& rejection, + std::string_view operation_type) const { + const std::string_view safe_operation_type = + is_safe_transaction_policy_identifier(operation_type) ? operation_type : invalid_log_identifier; + elog("Ethereum transaction policy rejected reason_code={} client_id={} chain_id={} operation={} " + "field={} observed={} allowed={}", + reason_code_name(rejection.reason()), + _transaction_policy.client_id, + _transaction_policy.chain_id.str(), + safe_operation_type, + rejection.field(), + rejection.observed(), + rejection.allowed().value_or("n/a")); +} + /** * @brief Executes a contract view function (read-only call) * @@ -136,6 +158,16 @@ fc::variant ethereum_client::execute_contract_tx_fn(const eip1559_tx& source_tx, const contract_invoke_data_items& params, bool sign) { eip1559_tx tx = source_tx; tx.data = from_hex(contract_encode_data(abi, params)); + + if (sign) { + try { + validate_transaction_against_policy(_transaction_policy, tx); + } catch (const ethereum_transaction_policy_exception& rejection) { + log_policy_rejection(rejection, abi.name); + throw; + } + } + auto tx_encoded = rlp::encode_eip1559_unsigned_typed(tx); if (sign) { fc::crypto::eth_client_signer signer(*_signature_provider); @@ -175,30 +207,19 @@ fc::uint256 ethereum_client::get_transaction_count(const address_compat_type& ad auto from_addr_hex = to_hex(from_addr, true); fc::variants params{from_addr_hex, to_block_tag(block)}; auto res = execute("eth_getTransactionCount", params); - dlog("tx_count: {}", res.as_string()); - return to_uint256(res); + return parse_rpc_quantity(res, "nonce"); } /** * @brief Retrieves the chain ID of the connected Ethereum network * - * Fetches the chain ID from the node on first call and caches it for subsequent calls. - * The chain ID is used in EIP-155 replay protection for transactions. + * Returns the locally configured policy chain ID. An RPC provider is never trusted to + * select the replay-protection domain of a transaction this client will sign. * * @return The chain ID as a uint256 - * @throws fc::network::json_rpc::json_rpc_exception if the RPC call fails */ -fc::uint256 ethereum_client::get_chain_id() { - static std::mutex mutex; - std::scoped_lock lock(mutex); - if (_chain_id.has_value()) { - return *_chain_id; - } - - fc::variants params; // empty array - _chain_id = to_uint256(execute("eth_chainId", params)); - - return _chain_id.value(); +fc::uint256 ethereum_client::get_chain_id() const { + return _transaction_policy.chain_id; } /** @@ -245,22 +266,26 @@ fc::variant ethereum_client::get_syncing_status() { */ eip1559_tx ethereum_client::create_default_tx(const address_compat_type& to, const abi::contract& contract, const fc::variants& params) { - auto gc = get_gas_config(); - auto data = contract_encode_data(contract, params); - - auto estimated_gas = estimate_gas(to, contract, data, gc); - // add 20% buffer - same as ceil(x * 1.2), but integer division only - auto gas_limit = (estimated_gas * 6) /5; - - return eip1559_tx{.chain_id = get_chain_id(), - .nonce = get_transaction_count(get_signer_address(), "pending"), - .max_priority_fee_per_gas = gc.tip, - .max_fee_per_gas = gc.max_fee_per_gas, - .gas_limit = gas_limit, - .to = to_address(to), - .value = 0, - .data = from_hex(data), - .access_list = {}}; + try { + auto gc = get_gas_config_unlogged(); + auto data = contract_encode_data(contract, params); + + auto estimated_gas = estimate_gas(to, contract, data, gc); + auto gas_limit = derive_buffered_gas_limit(_transaction_policy, estimated_gas); + + return eip1559_tx{.chain_id = get_chain_id(), + .nonce = get_transaction_count(get_signer_address(), block_tag_t::pending), + .max_priority_fee_per_gas = gc.tip, + .max_fee_per_gas = gc.max_fee_per_gas, + .gas_limit = gas_limit, + .to = to_address(to), + .value = 0, + .data = from_hex(data), + .access_list = {}}; + } catch (const ethereum_transaction_policy_exception& rejection) { + log_policy_rejection(rejection, contract.name); + throw; + } } /** @@ -363,9 +388,13 @@ fc::variant ethereum_client::get_transaction_by_hash(const std::string& tx_hash) */ fc::uint256 ethereum_client::get_base_fee_per_gas() { auto block = get_block_by_number(block_tag_t::latest); - FC_ASSERT_FMT(block.contains("baseFeePerGas"), "Block {} does not contain baseFeePerGas", - to_string(block_tag_t::latest)); - return block["baseFeePerGas"].as_uint256(); + if (!block.contains("baseFeePerGas")) { + throw ethereum_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, + "base_fee_per_gas", + ""); + } + return parse_rpc_quantity(block["baseFeePerGas"], "base_fee_per_gas"); } /** @@ -380,7 +409,7 @@ fc::uint256 ethereum_client::get_base_fee_per_gas() { fc::uint256 ethereum_client::get_max_priority_fee_per_gas() { fc::variants params; // empty auto resp = execute("eth_maxPriorityFeePerGas", params); - return resp.as_uint256(); + return parse_rpc_quantity(resp, "max_priority_fee_per_gas"); } /** @@ -400,7 +429,7 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const s tx("from", get_address())("to", to_address(to))("value", value); fc::variants params{fc::variant(tx)}; auto resp = execute("eth_estimateGas", params); - return resp.as_uint256(); + return parse_rpc_quantity(resp, "estimated_gas"); } /** @@ -414,9 +443,19 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const s * @throws fc::network::json_rpc::json_rpc_exception if any RPC call fails */ ethereum_client::gas_config_t ethereum_client::get_gas_config() { + try { + return get_gas_config_unlogged(); + } catch (const ethereum_transaction_policy_exception& rejection) { + log_policy_rejection(rejection, "get_gas_config"); + throw; + } +} + +/** Fetch fee suggestions and apply the local caps without emitting a duplicate log record. */ +ethereum_client::gas_config_t ethereum_client::get_gas_config_unlogged() { auto tip = get_max_priority_fee_per_gas(); auto base_fee = get_base_fee_per_gas(); - auto max_fee_per_gas = (base_fee * 2) + tip; + auto max_fee_per_gas = derive_max_fee_per_gas(_transaction_policy, tip, base_fee); return gas_config_t{ .tip = tip, @@ -443,25 +482,19 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const a const data_or_params_t& data_or_params, const std::optional& gas_config_opt) { fc::mutable_variant_object tx; - gas_config_t gc = gas_config_opt.value_or(get_gas_config()); + gas_config_t gc = gas_config_opt ? *gas_config_opt : get_gas_config(); - std::string data = to_data_from_params(contract, data_or_params);; - if (std::holds_alternative(data_or_params)) { - auto& params = std::get(data_or_params); - data = "0x" + contract_encode_data(contract, params); - } else { - data = "0x" + std::get(data_or_params); - } + std::string data = to_data_from_params(contract, data_or_params, true); tx("from", to_hex(get_address(), true)) ("to", to_hex(to_address(to), true)) - ("maxPriorityFeePerGas", to_hex(rlp::encode_uint(gc.max_fee_per_gas), true)) - ("maxFeePerGas", to_hex(rlp::encode_uint(gc.max_fee_per_gas), true)) + ("maxPriorityFeePerGas", format_rpc_quantity(gc.tip)) + ("maxFeePerGas", format_rpc_quantity(gc.max_fee_per_gas)) ("data", data) ("input", data); auto resp = execute("eth_estimateGas", fc::variants{tx}); - return resp.as_uint256(); + return parse_rpc_quantity(resp, "estimated_gas"); } /** diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp new file mode 100644 index 0000000000..5beee48366 --- /dev/null +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -0,0 +1,303 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fc::network::ethereum { + +namespace { + +constexpr std::string_view max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; +constexpr size_t max_uint256_hex_digits = 64; +constexpr size_t max_diagnostic_value_chars = 96; +constexpr size_t max_client_id_chars = 64; +constexpr std::string_view hex_quantity_prefix = "0x"; +constexpr uint64_t gas_headroom_multiplier = 6; +constexpr uint64_t gas_headroom_divisor = 5; +constexpr uint64_t max_fee_base_multiplier = 2; + +/** Return the largest uint256 value without relying on a narrowing intermediate. */ +const fc::uint256& max_uint256() { + static const fc::uint256 value{std::string(max_uint256_decimal)}; + return value; +} + +/** Bound untrusted diagnostic strings so malformed configuration cannot flood logs. */ +std::string diagnostic_value(std::string_view value) { + if (value.size() <= max_diagnostic_value_chars) return std::string(value); + return std::string(value.substr(0, max_diagnostic_value_chars)) + "..."; +} + +/** Throw a structured policy exception. */ +[[noreturn]] void reject(ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed = std::nullopt) { + throw ethereum_transaction_policy_exception( + reason, std::string(field), std::move(observed), std::move(allowed)); +} + +/** Check whether every character is an ASCII decimal digit. */ +bool is_decimal(std::string_view value) { + return std::ranges::all_of(value, [](unsigned char c) { return c >= '0' && c <= '9'; }); +} + +/** Check whether every character is an ASCII hexadecimal digit. */ +bool is_hexadecimal(std::string_view value) { + return std::ranges::all_of(value, [](unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + }); +} + +} // namespace + +bool is_safe_transaction_policy_identifier(std::string_view identifier) { + return !identifier.empty() && identifier.size() <= max_client_id_chars && + std::ranges::all_of(identifier, [](unsigned char c) { + const bool ascii_alphanumeric = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z'); + return ascii_alphanumeric || c == '-' || c == '_' || c == '.'; + }); +} + +ethereum_transaction_policy_exception::ethereum_transaction_policy_exception( + ethereum_transaction_policy_reason reason, + std::string field, + std::string observed, + std::optional allowed) + : fc::exception(FC_LOG_MESSAGE(error, + "reason_code={} field={} observed={} allowed={}", + reason_code_name(reason), + field, + observed, + allowed.value_or("n/a")), + fc::invalid_arg_exception_code, + "ethereum_transaction_policy_exception", + "Ethereum transaction policy rejected") + , _reason(reason) + , _field(std::move(field)) + , _observed(std::move(observed)) + , _allowed(std::move(allowed)) {} + +std::shared_ptr ethereum_transaction_policy_exception::dynamic_copy_exception() const { + return std::make_shared(*this); +} + +void ethereum_transaction_policy_exception::rethrow() const { + throw *this; +} + +fc::uint256 parse_canonical_uint256_decimal(std::string_view value, + std::string_view field, + bool allow_zero) { + const bool numeric_value = is_decimal(value); + const auto invalid = [&] { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, + field, + numeric_value ? diagnostic_value(value) : ""); + }; + + if (value.empty() || !numeric_value || (value.size() > 1 && value.front() == '0')) invalid(); + if (value.size() > max_uint256_decimal.size() || + (value.size() == max_uint256_decimal.size() && value > max_uint256_decimal)) { + invalid(); + } + + fc::uint256 parsed{std::string(value)}; + if (!allow_zero && parsed == 0) invalid(); + return parsed; +} + +fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field) { + if (!value.is_string()) { + reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + } + + const std::string encoded = value.as_string(); + if (!encoded.starts_with(hex_quantity_prefix)) { + reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + } + + const std::string_view digits{encoded.data() + hex_quantity_prefix.size(), + encoded.size() - hex_quantity_prefix.size()}; + const bool hexadecimal_value = is_hexadecimal(digits); + if (digits.empty() || !hexadecimal_value || (digits.size() > 1 && digits.front() == '0')) { + reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, + field, + hexadecimal_value ? diagnostic_value(encoded) : ""); + } + if (digits.size() > max_uint256_hex_digits) { + reject(ethereum_transaction_policy_reason::rpc_quantity_out_of_range, field, diagnostic_value(encoded)); + } + + return fc::uint256(fc::from_hex(encoded)); +} + +std::string format_rpc_quantity(const fc::uint256& value) { + return std::string(hex_quantity_prefix) + value.str(0, std::ios_base::hex); +} + +void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy) { + if (!is_safe_transaction_policy_identifier(policy.client_id)) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "client_id", ""); + } + if (policy.chain_id == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "chain_id", "0"); + } + if (policy.max_priority_fee_per_gas == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, + "max_priority_fee_per_gas_wei", + "0"); + } + if (policy.max_fee_per_gas == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_fee_per_gas_wei", "0"); + } + if (policy.max_gas_limit == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_gas_limit", "0"); + } + if (policy.max_total_native_cost == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, + "max_total_native_cost_wei", + "0"); + } + if (policy.max_priority_fee_per_gas > policy.max_fee_per_gas) { + reject(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_priority_fee_per_gas_wei", + policy.max_priority_fee_per_gas.str(), + policy.max_fee_per_gas.str()); + } +} + +fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, + const fc::uint256& priority_fee, + const fc::uint256& base_fee) { + if (priority_fee > policy.max_priority_fee_per_gas) { + reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + priority_fee.str(), + policy.max_priority_fee_per_gas.str()); + } + + const fc::uint256 maximum_base_fee = max_uint256() / max_fee_base_multiplier; + if (base_fee > maximum_base_fee) { + reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), + max_uint256().str()); + } + const fc::uint256 doubled_base_fee = base_fee * max_fee_base_multiplier; + const fc::uint256 remaining_fee_capacity = max_uint256() - doubled_base_fee; + if (priority_fee > remaining_fee_capacity) { + reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), + max_uint256().str()); + } + + const fc::uint256 max_fee = doubled_base_fee + priority_fee; + if (max_fee > policy.max_fee_per_gas) { + reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas", + max_fee.str(), + policy.max_fee_per_gas.str()); + } + return max_fee; +} + +fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, + const fc::uint256& estimated_gas) { + if (estimated_gas > policy.max_gas_limit) { + reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "estimated_gas", + estimated_gas.str(), + policy.max_gas_limit.str()); + } + + const fc::uint256 maximum_estimate = max_uint256() / gas_headroom_multiplier; + if (estimated_gas > maximum_estimate) { + reject(ethereum_transaction_policy_reason::gas_limit_derivation_overflow, + "gas_limit", + "estimated_gas=" + estimated_gas.str() + + ",multiplier=" + std::to_string(gas_headroom_multiplier), + max_uint256().str()); + } + + const fc::uint256 gas_limit = (estimated_gas * gas_headroom_multiplier) / gas_headroom_divisor; + if (gas_limit > policy.max_gas_limit) { + reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + gas_limit.str(), + policy.max_gas_limit.str()); + } + return gas_limit; +} + +void validate_transaction_against_policy(const ethereum_transaction_policy& policy, + const fc::crypto::ethereum::eip1559_tx& transaction) { + if (transaction.chain_id != policy.chain_id) { + reject(ethereum_transaction_policy_reason::chain_id_mismatch, + "chain_id", + transaction.chain_id.str(), + policy.chain_id.str()); + } + if (transaction.max_fee_per_gas < transaction.max_priority_fee_per_gas) { + reject(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + transaction.max_priority_fee_per_gas.str()); + } + if (transaction.max_priority_fee_per_gas > policy.max_priority_fee_per_gas) { + reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + transaction.max_priority_fee_per_gas.str(), + policy.max_priority_fee_per_gas.str()); + } + if (transaction.max_fee_per_gas > policy.max_fee_per_gas) { + reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + policy.max_fee_per_gas.str()); + } + if (transaction.gas_limit > policy.max_gas_limit) { + reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + transaction.gas_limit.str(), + policy.max_gas_limit.str()); + } + + const fc::uint256 maximum_fee_without_overflow = + transaction.gas_limit == 0 ? max_uint256() : fc::uint256{max_uint256() / transaction.gas_limit}; + if (transaction.max_fee_per_gas > maximum_fee_without_overflow) { + reject(ethereum_transaction_policy_reason::total_cost_multiplication_overflow, + "max_total_native_cost", + "gas_limit=" + transaction.gas_limit.str() + + ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), + max_uint256().str()); + } + const fc::uint256 maximum_gas_cost = transaction.gas_limit * transaction.max_fee_per_gas; + const fc::uint256 remaining_total_capacity = max_uint256() - maximum_gas_cost; + if (transaction.value > remaining_total_capacity) { + reject(ethereum_transaction_policy_reason::total_cost_addition_overflow, + "max_total_native_cost", + "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), + max_uint256().str()); + } + + const fc::uint256 maximum_total_cost = maximum_gas_cost + transaction.value; + if (maximum_total_cost > policy.max_total_native_cost) { + reject(ethereum_transaction_policy_reason::total_cost_cap_exceeded, + "max_total_native_cost", + maximum_total_cost.str(), + policy.max_total_native_cost.str()); + } +} + +} // namespace fc::network::ethereum diff --git a/libraries/libfc/test/CMakeLists.txt b/libraries/libfc/test/CMakeLists.txt index ec1cc0e211..7f8393ebd2 100644 --- a/libraries/libfc/test/CMakeLists.txt +++ b/libraries/libfc/test/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable( test_fc io/test_raw.cpp io/test_tracked_storage.cpp network/ethereum/test_ethereum_client_and_rlp.cpp + network/ethereum/test_ethereum_transaction_policy.cpp network/test_json_rpc_client.cpp network/solana/test_solana_client.cpp network/test_message_buffer.cpp diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp index e925b4b4a2..9c50071030 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp @@ -381,6 +381,34 @@ BOOST_AUTO_TEST_CASE(can_encode_tx_01) try { } FC_LOG_AND_RETHROW(); +BOOST_AUTO_TEST_CASE(eip1559_rlp_preserves_transaction_fields_above_uint64) try { + const fc::uint256 two_to_64{"18446744073709551616"}; + auto wide_tx = test_tx_01; + wide_tx.chain_id = two_to_64 + 1; + wide_tx.nonce = two_to_64 + 2; + wide_tx.max_priority_fee_per_gas = two_to_64 + 3; + wide_tx.max_fee_per_gas = two_to_64 + 4; + wide_tx.gas_limit = two_to_64 + 5; + wide_tx.value = two_to_64 + 6; + wide_tx.data.clear(); + wide_tx.v = 0; + wide_tx.r = {}; + wide_tx.s = {}; + + const auto encoded_hex = rlp::to_hex(rlp::encode_eip1559_signed_typed(wide_tx), false); + const std::string expected_fields = + "89010000000000000001" + "89010000000000000002" + "89010000000000000003" + "89010000000000000004" + "89010000000000000005" + "945fbdb2315678afecb367f032d93f642f64180aa3" + "89010000000000000006" + "80c0"; + BOOST_CHECK(encoded_hex.contains(expected_fields)); +} +FC_LOG_AND_RETHROW(); + // Signed EIP-1559 r/s must be encoded as minimal big-endian integers. // Strict RLP decoders (alloy-rs used by reth/anvil) reject non-minimal // fixed-width 32-byte string encodings of r/s with "leading zero" when the diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp new file mode 100644 index 0000000000..aa38f87ff2 --- /dev/null +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -0,0 +1,284 @@ +#include + +#include + +#include +#include + +using namespace fc::crypto::ethereum; +using namespace fc::network::ethereum; + +namespace { + +constexpr std::string_view max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; +constexpr std::string_view above_max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639936"; + +ethereum_transaction_policy bounded_policy() { + return ethereum_transaction_policy{ + .client_id = "client-a", + .chain_id = 31337, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .max_gas_limit = 1000, + .max_total_native_cost = 100000, + }; +} + +ethereum_transaction_policy uint256_wide_policy() { + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + return ethereum_transaction_policy{ + .client_id = "client-a", + .chain_id = 31337, + .max_priority_fee_per_gas = maximum, + .max_fee_per_gas = maximum, + .max_gas_limit = maximum, + .max_total_native_cost = maximum, + }; +} + +eip1559_tx bounded_transaction() { + return eip1559_tx{ + .chain_id = 31337, + .nonce = 0, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .gas_limit = 1000, + .to = {}, + .value = 0, + .data = {}, + .access_list = {}, + }; +} + +void check_rejection_reason(const std::function& operation, + ethereum_transaction_policy_reason expected_reason) { + try { + operation(); + BOOST_FAIL("expected ethereum transaction policy rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == expected_reason); + BOOST_CHECK_EQUAL(reason_code_name(rejection.reason()), reason_code_name(expected_reason)); + } +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(ethereum_transaction_policy_tests) + +BOOST_AUTO_TEST_CASE(canonical_configuration_values_preserve_uint256_range) { + const auto maximum = parse_canonical_uint256_decimal(max_uint256_decimal, "limit"); + BOOST_CHECK_EQUAL(maximum.str(), max_uint256_decimal); + + check_rejection_reason( + [] { parse_canonical_uint256_decimal(above_max_uint256_decimal, "limit"); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + check_rejection_reason( + [] { parse_canonical_uint256_decimal("01", "limit"); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + check_rejection_reason( + [] { parse_canonical_uint256_decimal("0", "limit"); }, + ethereum_transaction_policy_reason::configuration_value_invalid); +} + +BOOST_AUTO_TEST_CASE(rpc_quantities_are_canonical_and_non_truncating) { + const std::string maximum_quantity = "0x" + std::string(64, 'f'); + BOOST_CHECK_EQUAL(parse_rpc_quantity(fc::variant(maximum_quantity), "nonce").str(), max_uint256_decimal); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{0}), "0x0"); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{127}), "0x7f"); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{128}), "0x80"); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{"18446744073709551617"}), + "0x10000000000000001"); + BOOST_CHECK_EQUAL(format_rpc_quantity(parse_rpc_quantity(fc::variant(maximum_quantity), "nonce")), + maximum_quantity); + + check_rejection_reason( + [] { parse_rpc_quantity(fc::variant("0x" + std::string(65, '1')), "nonce"); }, + ethereum_transaction_policy_reason::rpc_quantity_out_of_range); + check_rejection_reason( + [] { parse_rpc_quantity(fc::variant("0x01"), "nonce"); }, + ethereum_transaction_policy_reason::rpc_quantity_invalid); + check_rejection_reason( + [] { parse_rpc_quantity(fc::variant(uint64_t{1}), "nonce"); }, + ethereum_transaction_policy_reason::rpc_quantity_invalid); +} + +BOOST_AUTO_TEST_CASE(malformed_values_do_not_reappear_in_policy_diagnostics) { + constexpr std::string_view sensitive_url = "https://user:password@example.invalid/rpc?token=secret"; + try { + parse_rpc_quantity(fc::variant(std::string(sensitive_url)), "nonce"); + BOOST_FAIL("expected malformed RPC quantity rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } + + auto policy = bounded_policy(); + policy.client_id = sensitive_url; + try { + validate_transaction_policy_configuration(policy); + BOOST_FAIL("expected malformed client id rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } +} + +BOOST_AUTO_TEST_CASE(policy_configuration_requires_positive_consistent_limits) { + auto policy = bounded_policy(); + policy.chain_id = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_priority_fee_per_gas = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_fee_per_gas = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_gas_limit = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_total_native_cost = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_priority_fee_per_gas = policy.max_fee_per_gas + 1; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::fee_relationship_invalid); +} + +BOOST_AUTO_TEST_CASE(exact_caps_pass_and_each_cap_plus_one_rejects) { + const auto policy = bounded_policy(); + const auto exact = bounded_transaction(); + BOOST_CHECK_NO_THROW(validate_transaction_against_policy(policy, exact)); + + auto transaction = exact; + ++transaction.max_priority_fee_per_gas; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::priority_fee_cap_exceeded); + + transaction = exact; + ++transaction.max_fee_per_gas; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::max_fee_cap_exceeded); + + transaction = exact; + transaction.max_fee_per_gas = 99; + ++transaction.gas_limit; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::gas_limit_cap_exceeded); + + transaction = exact; + transaction.gas_limit = 999; + transaction.value = 101; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_cap_exceeded); +} + +BOOST_AUTO_TEST_CASE(nonzero_value_is_included_in_the_total_cost) { + const auto policy = bounded_policy(); + auto transaction = bounded_transaction(); + transaction.gas_limit = 999; + + transaction.value = 99; + BOOST_CHECK_NO_THROW(validate_transaction_against_policy(policy, transaction)); + + transaction.value = 100; + BOOST_CHECK_NO_THROW(validate_transaction_against_policy(policy, transaction)); + + ++transaction.value; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_cap_exceeded); +} + +BOOST_AUTO_TEST_CASE(fee_relation_and_chain_domain_are_enforced) { + const auto policy = bounded_policy(); + auto transaction = bounded_transaction(); + transaction.max_fee_per_gas = transaction.max_priority_fee_per_gas - 1; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::fee_relationship_invalid); + + transaction = bounded_transaction(); + ++transaction.chain_id; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::chain_id_mismatch); +} + +BOOST_AUTO_TEST_CASE(total_cost_multiplication_and_addition_overflow_reject) { + const auto policy = uint256_wide_policy(); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + auto transaction = bounded_transaction(); + transaction.max_priority_fee_per_gas = 1; + transaction.max_fee_per_gas = maximum; + transaction.gas_limit = 2; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_multiplication_overflow); + + transaction.max_fee_per_gas = maximum - 1; + transaction.gas_limit = 1; + transaction.value = 2; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_addition_overflow); +} + +BOOST_AUTO_TEST_CASE(fee_derivation_checks_each_intermediate_and_caps_the_result) { + const auto wide_policy = uint256_wide_policy(); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + check_rejection_reason( + [&] { derive_max_fee_per_gas(wide_policy, 1, maximum / 2 + 1); }, + ethereum_transaction_policy_reason::max_fee_derivation_overflow); + check_rejection_reason( + [&] { derive_max_fee_per_gas(wide_policy, 2, maximum / 2); }, + ethereum_transaction_policy_reason::max_fee_derivation_overflow); + + const auto policy = bounded_policy(); + BOOST_CHECK_EQUAL(derive_max_fee_per_gas(policy, 10, 45), 100); + check_rejection_reason( + [&] { derive_max_fee_per_gas(policy, 10, 46); }, + ethereum_transaction_policy_reason::max_fee_cap_exceeded); + check_rejection_reason( + [&] { derive_max_fee_per_gas(policy, 11, 1); }, + ethereum_transaction_policy_reason::priority_fee_cap_exceeded); +} + +BOOST_AUTO_TEST_CASE(gas_headroom_checks_multiplication_and_final_cap) { + const auto wide_policy = uint256_wide_policy(); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + check_rejection_reason( + [&] { derive_buffered_gas_limit(wide_policy, maximum / 6 + 1); }, + ethereum_transaction_policy_reason::gas_limit_derivation_overflow); + + const auto policy = bounded_policy(); + BOOST_CHECK_EQUAL(derive_buffered_gas_limit(policy, 833), 999); + BOOST_CHECK_EQUAL(derive_buffered_gas_limit(policy, 834), 1000); + check_rejection_reason( + [&] { derive_buffered_gas_limit(policy, 835); }, + ethereum_transaction_policy_reason::gas_limit_cap_exceeded); +} + +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 1be9389344..d69b4cc6ac 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 @@ -6,23 +6,34 @@ #include #include +#include +#include +#include +#include +#include +#include + namespace sysio { using namespace fc::crypto::ethereum; using namespace fc::network::ethereum; struct ethereum_client_entry_t { + /** Configuration identifier used to select this connection. */ std::string id; - std::string url; + /** Provider used by the policy-enforcing client at the final signing boundary. */ fc::crypto::signature_provider_ptr signature_provider; + /** Ethereum JSON-RPC client with its immutable local transaction policy. */ ethereum_client_ptr client; - /// Numeric EVM chain id from the client spec's optional 4th field. Lets the - /// 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; + /** Authoritative EVM chain id from the client's policy. */ + uint32_t chain_id; }; using ethereum_client_entry_ptr = std::shared_ptr; +using ethereum_transaction_policy_map = std::map; + +/** Load and strictly validate a versioned per-client Ethereum transaction-policy file. */ +ethereum_transaction_policy_map +load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file); /// Typed contract client for OPP.sol. State-changing calls go through /// `create_tx_and_confirm` — OPP writes are consensus-critical and must @@ -102,12 +113,9 @@ class outpost_ethereum_client_plugin : public appbase::plugin get_clients(); ethereum_client_entry_ptr get_client(const std::string& id); - /// Return the single configured client whose spec chain id equals - /// `chain_id`, or nullptr when none — or more than one — match. The batch - /// operator uses this to bind each EVM outpost row to its own RPC client by - /// `external_chain_id`; an ambiguous (duplicate chain id) or missing match - /// yields nullptr so the caller can fail closed rather than relay an - /// outpost through the wrong endpoint. + /// Return the single configured client whose policy chain id equals `chain_id`, or nullptr when + /// none — or more than one — match. Ambiguous same-chain clients fail closed because this lookup + /// has no separate endpoint-selection signal. ethereum_client_entry_ptr get_client_by_chain_id(uint64_t chain_id); const std::vector>>& 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..27962ab965 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,22 +1,245 @@ -#include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include + 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_name_transaction_policy_file = "outpost-ethereum-transaction-policy-file"; +constexpr auto option_abi_file = "ethereum-abi-file"; + +constexpr auto root_field_version = "version"; +constexpr auto root_field_policies = "policies"; +constexpr auto policy_field_client_id = "client_id"; +constexpr auto policy_field_chain_id = "chain_id"; +constexpr auto policy_field_max_priority_fee_per_gas_wei = "max_priority_fee_per_gas_wei"; +constexpr auto policy_field_max_fee_per_gas_wei = "max_fee_per_gas_wei"; +constexpr auto policy_field_max_gas_limit = "max_gas_limit"; +constexpr auto policy_field_max_total_native_cost_wei = "max_total_native_cost_wei"; +constexpr uint64_t transaction_policy_schema_version = 1; + +constexpr std::array root_fields{ + root_field_version, + root_field_policies, +}; +constexpr std::array policy_fields{ + policy_field_client_id, + policy_field_chain_id, + policy_field_max_priority_fee_per_gas_wei, + policy_field_max_fee_per_gas_wei, + policy_field_max_gas_limit, + policy_field_max_total_native_cost_wei, +}; + +/** Parsed non-secret fields of one `--outpost-ethereum-client` specification. */ +struct ethereum_client_spec { + std::string id; + std::string signature_provider_id; + std::string url; + std::optional chain_id; +}; +/** Throw a structured, sanitized plugin-configuration rejection. */ +[[noreturn]] void reject_configuration(ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed = std::nullopt) { + throw ethereum_transaction_policy_exception( + reason, std::string(field), std::move(observed), std::move(allowed)); +} + +/** Require an object to contain each expected field exactly once and no unknown fields. */ +template +void validate_exact_fields(const fc::variant_object& object, + const std::array& expected, + std::string_view context) { + std::set observed_fields; + for (const auto& entry : object) { + const bool known = std::ranges::find(expected, entry.key()) != expected.end(); + if (!known) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); + } + if (!observed_fields.insert(entry.key()).second) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); + } + } + + for (const auto field : expected) { + if (!observed_fields.contains(std::string(field))) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + "missing=" + std::string(field)); + } + } +} + +/** Read a required JSON string without coercing another JSON type. */ +std::string require_string_field(const fc::variant_object& object, std::string_view field) { + const auto& value = object[std::string(field)]; + if (!value.is_string()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + field, + ""); + } + return value.as_string(); +} + +/** Parse an externally registered chain id without truncation, then enforce its uint32 domain. */ +uint32_t require_external_chain_id(std::string_view value, std::string_view field) { + const fc::uint256 chain_id = parse_canonical_uint256_decimal(value, field); + constexpr auto max_external_chain_id = std::numeric_limits::max(); + if (chain_id > max_external_chain_id) { + reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, + field, + chain_id.str(), + std::to_string(max_external_chain_id)); + } + return chain_id.convert_to(); +} + +/** Parse a client specification while keeping its credential-bearing URL out of diagnostics. */ +ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { + auto parts = fc::split(encoded_spec, ','); + if (parts.size() != 3 && parts.size() != 4) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_client, + "field_count=" + std::to_string(parts.size()), + "3 or 4"); + } + if (parts[0].empty() || parts[1].empty() || parts[2].empty()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, + option_name_client, + ""); + } + if (!is_safe_transaction_policy_identifier(parts[0])) { + reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field_client_id, + ""); + } + + ethereum_client_spec result{ + .id = parts[0], + .signature_provider_id = parts[1], + .url = parts[2], + }; + if (parts.size() == 4) { + result.chain_id = require_external_chain_id(parts[3], "client_spec.chain_id"); + } + return result; +} + +/** Return this plugin's logger for appbase log macros. */ [[maybe_unused]] inline fc::logger& logger() { static fc::logger log{"outpost_ethereum_client_plugin"}; return log; } } +ethereum_transaction_policy_map +load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) { + std::ifstream readable_policy_file{policy_file}; + if (!readable_policy_file.is_open()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); + } + + fc::variant document; + try { + document = fc::json::from_file(policy_file, fc::json::parse_type::strict_parser); + } catch (const fc::exception&) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); + } catch (const std::exception&) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); + } + + if (!document.is_object()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "root", + ""); + } + const auto root = document.get_object(); + validate_exact_fields(root, root_fields, "root"); + + const auto& version = root[root_field_version]; + const bool supported_version = + (version.is_uint64() && version.as_uint64() == transaction_policy_schema_version) || + (version.is_int64() && version.as_int64() == transaction_policy_schema_version); + if (!supported_version) { + reject_configuration(ethereum_transaction_policy_reason::configuration_version_unsupported, + root_field_version, + "", + std::to_string(transaction_policy_schema_version)); + } + + const auto& policies_value = root[root_field_policies]; + if (!policies_value.is_array()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + root_field_policies, + ""); + } + + ethereum_transaction_policy_map result; + for (const auto& policy_value : policies_value.get_array()) { + if (!policy_value.is_object()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "policy", + ""); + } + const auto policy_object = policy_value.get_object(); + validate_exact_fields(policy_object, policy_fields, "policy"); + + const auto client_id = require_string_field(policy_object, policy_field_client_id); + ethereum_transaction_policy policy{ + .client_id = client_id, + .chain_id = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id), + .max_priority_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), + policy_field_max_priority_fee_per_gas_wei), + .max_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_fee_per_gas_wei), + policy_field_max_fee_per_gas_wei), + .max_gas_limit = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_gas_limit), policy_field_max_gas_limit), + .max_total_native_cost = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_total_native_cost_wei), + policy_field_max_total_native_cost_wei), + }; + validate_transaction_policy_configuration(policy); + require_external_chain_id(policy.chain_id.str(), policy_field_chain_id); + + if (!result.emplace(client_id, std::move(policy)).second) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + client_id); + } + } + return result; +} + class outpost_ethereum_client_plugin_impl { std::map _clients{}; using file_abi_contracts_t = std::pair>; @@ -49,7 +272,7 @@ class outpost_ethereum_client_plugin_impl { ethereum_client_entry_ptr get_client_by_chain_id(uint64_t chain_id) { ethereum_client_entry_ptr match; for (auto& [id, entry] : _clients) { - if (entry->chain_id && *entry->chain_id == chain_id) { + if (entry->chain_id == chain_id) { if (match) return nullptr; // ambiguous: >1 client on this chain id match = entry; } @@ -70,41 +293,103 @@ class outpost_ethereum_client_plugin_impl { }; void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& options) { - if (options.contains(option_abi_file)) { - auto& abi_files = options.at(option_abi_file).as>(); - my->load_abi_files(abi_files); - } - FC_ASSERT(options.count(option_name_client), "At least one ethereum client argument is required {}", option_name_client); - - // This plugin APPBASE_PLUGIN_REQUIRES the signature_provider_manager_plugin, which creates every configured provider - // at its own plugin_initialize (failing the boot there on a misconfigured or not-enabled scheme). So by the time - // this runs, every provider already exists regardless of `--plugin` ordering, and clients can be resolved and - // constructed here rather than deferred to startup. - auto& sig_mgr = app().get_plugin(); - auto client_specs = options.at(option_name_client).as>(); - 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); - auto& id = parts[0]; - auto& sig_id = parts[1]; - auto& url = parts[2]; - fc::ostring chain_id_str = parts.size() == 4 ? fc::ostring{parts[3]} : fc::ostring{}; - std::optional chain_id; - std::optional chain_id_num; - 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(); + try { + if (options.contains(option_abi_file)) { + const auto abi_files = options.at(option_abi_file).as>(); + my->load_abi_files(abi_files); + } + if (!options.contains(option_name_client)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); + } + if (!options.contains(option_name_transaction_policy_file)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); } - auto sig_provider = sig_mgr.get_provider(sig_id); - my->add_client(id, - std::make_shared( - id, url, sig_provider, - std::make_shared(sig_provider, url, chain_id), - chain_id_num)); - ilog("Added ethereum client (id={},sig_id={},url={},chainId={})", id, sig_id, url, - chain_id_num ? std::to_string(*chain_id_num) : "none"); + const auto policy_file = options.at(option_name_transaction_policy_file).as(); + auto policies = load_ethereum_transaction_policy_file(policy_file); + + // This required plugin has already initialized every configured provider, independent of `--plugin` ordering. + auto& signature_manager = app().get_plugin(); + const auto encoded_client_specs = options.at(option_name_client).as>(); + if (encoded_client_specs.empty()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); + } + + std::vector client_specs; + std::set configured_client_ids; + client_specs.reserve(encoded_client_specs.size()); + for (const auto& encoded_spec : encoded_client_specs) { + auto spec = parse_client_spec(encoded_spec); + if (!configured_client_ids.insert(spec.id).second) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + spec.id); + } + client_specs.emplace_back(std::move(spec)); + } + + for (const auto& [client_id, policy] : policies) { + if (!configured_client_ids.contains(client_id)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_unknown, + policy_field_client_id, + client_id); + } + } + + for (const auto& spec : client_specs) { + const auto policy_iterator = policies.find(spec.id); + if (policy_iterator == policies.end()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, + policy_field_client_id, + spec.id); + } + const auto& policy = policy_iterator->second; + if (spec.chain_id && fc::uint256{*spec.chain_id} != policy.chain_id) { + reject_configuration(ethereum_transaction_policy_reason::configuration_chain_id_mismatch, + "client_spec.chain_id", + std::to_string(*spec.chain_id), + policy.chain_id.str()); + } + + if (!signature_manager.has_provider(spec.signature_provider_id)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "signature_provider", + ""); + } + const auto signature_provider = signature_manager.get_provider(spec.signature_provider_id); + const auto chain_id = policy.chain_id.convert_to(); + ethereum_client_ptr client; + try { + client = std::make_shared(signature_provider, spec.url, policy); + } catch (const ethereum_transaction_policy_exception&) { + throw; + } catch (const std::exception&) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "client_spec.url", + ""); + } + my->add_client(spec.id, + std::make_shared( + spec.id, + signature_provider, + std::move(client), + chain_id)); + + ilog("Added policy-constrained ethereum client client_id={} chain_id={}", spec.id, chain_id); + } + } catch (const ethereum_transaction_policy_exception& rejection) { + elog("Ethereum transaction policy configuration rejected reason_code={} field={} observed={} allowed={}", + reason_code_name(rejection.reason()), + rejection.field(), + rejection.observed(), + rejection.allowed().value_or("n/a")); + throw; } } @@ -122,6 +407,10 @@ void outpost_ethereum_client_plugin::set_program_options(options_description& cl boost::program_options::value>()->multitoken(), "Outpost Ethereum Client spec, the plugin supports 1 to many clients in a given process" "`,,[,]`")( + option_name_transaction_policy_file, + boost::program_options::value(), + "Versioned JSON file containing exactly one finite transaction expenditure policy per configured " + "Ethereum client id and chain id")( 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." @@ -151,13 +440,29 @@ const std::vector outpost_ethereum_client_plugin::create_outpost_client(const std::string& eth_client_id, - uint64_t chain_code, - uint32_t chain_id, - const std::string& opp_addr, - const std::string& opp_inbound_addr, - const std::string& operator_registry_addr) { + uint64_t chain_code, + uint32_t chain_id, + const std::string& opp_addr, + const std::string& opp_inbound_addr, + const std::string& operator_registry_addr) { auto entry = my->get_client(eth_client_id); FC_ASSERT(entry, "Unknown ethereum client id: {}", eth_client_id); + if (entry->chain_id != chain_id) { + ethereum_transaction_policy_exception rejection{ + ethereum_transaction_policy_reason::configuration_chain_id_mismatch, + "outpost.chain_id", + std::to_string(chain_id), + std::to_string(entry->chain_id), + }; + elog("Ethereum transaction policy client binding rejected reason_code={} client_id={} field={} " + "observed={} allowed={}", + reason_code_name(rejection.reason()), + entry->id, + rejection.field(), + rejection.observed(), + rejection.allowed().value_or("n/a")); + throw rejection; + } std::vector all_abis; for (auto& [path, contracts] : my->get_abi_files()) { diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp new file mode 100644 index 0000000000..70c26a7984 --- /dev/null +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -0,0 +1,610 @@ +#include + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace fc::crypto; +using namespace fc::crypto::ethereum; +using namespace fc::network::ethereum; +namespace ethabi = fc::network::ethereum::abi; + +namespace { + +constexpr std::string_view fake_rpc_url = "http://127.0.0.1:1"; +constexpr std::string_view contract_address = "5FbDB2315678afecb367f032d93F642f64180aa3"; +constexpr std::string_view transaction_hash = + "0x1111111111111111111111111111111111111111111111111111111111111111"; +constexpr std::string_view signer_public_key = + "0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed7535" + "47f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5"; +constexpr std::string_view signer_private_key = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +constexpr std::string_view max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + +ethereum_transaction_policy bounded_policy(std::string client_id = "client-a") { + return ethereum_transaction_policy{ + .client_id = std::move(client_id), + .chain_id = 31337, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .max_gas_limit = 1000, + .max_total_native_cost = 100000, + }; +} + +ethabi::contract no_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {}, + .outputs = {}, + }; +} + +ethabi::contract bytes_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {ethabi::component_type{"data", ethabi::data_type::bytes}}, + .outputs = {}, + }; +} + +ethabi::contract uint32_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {ethabi::component_type{"wireEpochIndex", ethabi::data_type::uint32}}, + .outputs = {}, + }; +} + +signature_provider_ptr make_recording_signer(std::atomic& sign_count) { + auto private_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); + auto ethereum_key = private_key.get(); + auto provider = std::make_shared(); + provider->target_chain = chain_kind_ethereum; + provider->key_type = chain_key_type_ethereum; + provider->key_name = "recording-signer"; + provider->public_key = private_key.get_public_key(); + provider->private_key.reset(); + provider->sign = [ethereum_key, &sign_count](const fc::sha256& digest) { + ++sign_count; + const fc::crypto::keccak256 ethereum_digest{digest.str()}; + return signature(signature::storage_type(ethereum_key.sign_keccak256(ethereum_digest))); + }; + return provider; +} + +class recording_ethereum_client final : public ethereum_client { +public: + recording_ethereum_client(const signature_provider_ptr& provider, + ethereum_transaction_policy policy) + : ethereum_client(provider, std::string(fake_rpc_url), std::move(policy)) {} + + fc::variant execute(const std::string& method, const fc::variant& params) override { + methods.emplace_back(method); + if (method == "eth_maxPriorityFeePerGas") return fc::variant("0xa"); + if (method == "eth_getBlockByNumber") { + return fc::variant(fc::mutable_variant_object("baseFeePerGas", "0x2d")); + } + if (method == "eth_estimateGas") { + estimate_gas_params = params; + return fc::variant("0x342"); + } + if (method == "eth_getTransactionCount") return fc::variant("0x0"); + if (method == "eth_sendRawTransaction") { + ++broadcast_count; + return fc::variant(std::string(transaction_hash)); + } + FC_THROW_EXCEPTION(fc::invalid_arg_exception, "unexpected fake RPC method {}", method); + } + + std::vector methods; + fc::variant estimate_gas_params; + size_t broadcast_count = 0; +}; + +eip1559_tx exact_transaction() { + return eip1559_tx{ + .chain_id = 31337, + .nonce = 0, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .gas_limit = 1000, + .to = fc::crypto::ethereum::to_address(std::string(contract_address)), + .value = 0, + .data = {}, + .access_list = {}, + }; +} + +void expect_policy_rejection(const std::function& operation) { + BOOST_CHECK_THROW(operation(), ethereum_transaction_policy_exception); +} + +std::filesystem::path write_policy_file(fc::temp_directory& directory, std::string_view contents) { + const auto path = directory.path() / "ethereum-transaction-policy.json"; + std::ofstream output{path}; + output << contents; + output.close(); + return path; +} + +void with_initialized_outpost_plugin( + const std::filesystem::path& policy_file, + const std::vector& client_specs, + const std::function& inspect_plugin) { + auto reset_application = gsl_lite::finally([] { appbase::application::reset_app_singleton(); }); + appbase::scoped_app test_application{}; + + const std::string signature_spec = + "signer-a,ethereum,ethereum," + std::string(signer_public_key) + ",KEY:" + + std::string(signer_private_key); + std::vector arguments{ + "test_outpost_ethereum_transaction_policy", + "--signature-provider", + signature_spec, + "--outpost-ethereum-transaction-policy-file", + policy_file.string(), + }; + for (const auto& client_spec : client_specs) { + arguments.emplace_back("--outpost-ethereum-client"); + arguments.emplace_back(client_spec); + } + + std::vector argv; + argv.reserve(arguments.size()); + for (auto& argument : arguments) argv.emplace_back(argument.data()); + + if (!test_application->initialize(argv.size(), argv.data())) { + FC_THROW_EXCEPTION(fc::invalid_arg_exception, "test application initialization returned false"); + } + inspect_plugin(test_application->get_plugin()); +} + +std::vector +initialize_outpost_plugin(const std::filesystem::path& policy_file, + const std::vector& client_specs) { + std::vector clients; + with_initialized_outpost_plugin( + policy_file, + client_specs, + [&](auto& plugin) { clients = plugin.get_clients(); }); + return clients; +} + +ethereum_transaction_policy_reason startup_rejection_reason( + const std::filesystem::path& policy_file, + const std::vector& client_specs) { + try { + initialize_outpost_plugin(policy_file, client_specs); + BOOST_FAIL("expected startup policy rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + return rejection.reason(); + } + return ethereum_transaction_policy_reason::configuration_schema_invalid; +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(outpost_ethereum_transaction_policy_tests) + +BOOST_AUTO_TEST_CASE(final_signing_boundary_rejects_without_signing_or_broadcasting) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto client = std::make_shared(provider, bounded_policy()); + const auto contract = no_argument_function("submit"); + + std::vector rejected_transactions; + auto transaction = exact_transaction(); + ++transaction.max_priority_fee_per_gas; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + ++transaction.max_fee_per_gas; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + ++transaction.gas_limit; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + transaction.gas_limit = 999; + transaction.value = 101; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + transaction.max_fee_per_gas = 9; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + ++transaction.chain_id; + rejected_transactions.emplace_back(transaction); + + for (const auto& rejected : rejected_transactions) { + expect_policy_rejection([&] { client->execute_contract_tx_fn(rejected, contract); }); + } + + auto wide_policy = bounded_policy("wide-client"); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + wide_policy.max_priority_fee_per_gas = maximum; + wide_policy.max_fee_per_gas = maximum; + wide_policy.max_gas_limit = maximum; + wide_policy.max_total_native_cost = maximum; + auto wide_client = std::make_shared(provider, wide_policy); + + transaction = exact_transaction(); + transaction.max_priority_fee_per_gas = 1; + transaction.max_fee_per_gas = maximum; + transaction.gas_limit = 2; + expect_policy_rejection([&] { wide_client->execute_contract_tx_fn(transaction, contract); }); + + transaction.max_fee_per_gas = maximum - 1; + transaction.gas_limit = 1; + transaction.value = 2; + expect_policy_rejection([&] { wide_client->execute_contract_tx_fn(transaction, contract); }); + + BOOST_CHECK_EQUAL(sign_count.load(), 0u); + BOOST_CHECK_EQUAL(client->broadcast_count, 0u); + BOOST_CHECK_EQUAL(wide_client->broadcast_count, 0u); +} + +BOOST_AUTO_TEST_CASE(exact_caps_reach_the_signer_and_broadcast_once) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto client = std::make_shared(provider, bounded_policy()); + + const auto result = client->execute_contract_tx_fn(exact_transaction(), no_argument_function("submit")); + BOOST_CHECK_EQUAL(result.as_string(), transaction_hash); + BOOST_CHECK_EQUAL(sign_count.load(), 1u); + BOOST_CHECK_EQUAL(client->broadcast_count, 1u); +} + +BOOST_AUTO_TEST_CASE(two_clients_enforce_their_own_runtime_policies) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + + auto client_a = std::make_shared(provider, bounded_policy("client-a")); + auto policy_b = bounded_policy("client-b"); + policy_b.chain_id = 1; + policy_b.max_fee_per_gas = 99; + auto client_b = std::make_shared(provider, policy_b); + + BOOST_CHECK_NO_THROW( + client_a->execute_contract_tx_fn(exact_transaction(), no_argument_function("submit"))); + + auto transaction_b = exact_transaction(); + transaction_b.chain_id = 1; + expect_policy_rejection( + [&] { client_b->execute_contract_tx_fn(transaction_b, no_argument_function("submit")); }); + + BOOST_CHECK_EQUAL(sign_count.load(), 1u); + BOOST_CHECK_EQUAL(client_a->broadcast_count, 1u); + BOOST_CHECK_EQUAL(client_b->broadcast_count, 0u); +} + +BOOST_AUTO_TEST_CASE(default_transaction_uses_priority_fee_in_estimate_payload_and_local_chain_id) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto client = std::make_shared(provider, bounded_policy()); + + const auto transaction = client->create_default_tx( + std::string(contract_address), no_argument_function("submit")); + BOOST_CHECK_EQUAL(transaction.chain_id, 31337); + BOOST_CHECK_EQUAL(transaction.max_priority_fee_per_gas, 10); + BOOST_CHECK_EQUAL(transaction.max_fee_per_gas, 100); + BOOST_CHECK_EQUAL(transaction.gas_limit, 1000); + + const fc::uint256 wide_priority{"18446744073709551617"}; + const fc::uint256 wide_max_fee{"18446744073709551618"}; + client->estimate_gas(std::string(contract_address), + no_argument_function("submit"), + std::string{}, + ethereum_client::gas_config_t{ + .tip = wide_priority, + .max_fee_per_gas = wide_max_fee, + }); + + const auto estimate_params = client->estimate_gas_params.get_array(); + BOOST_REQUIRE_EQUAL(estimate_params.size(), 1u); + const auto estimate_transaction = estimate_params.front().get_object(); + BOOST_CHECK_EQUAL(estimate_transaction["maxPriorityFeePerGas"].as_string(), + "0x10000000000000001"); + BOOST_CHECK_EQUAL(estimate_transaction["maxFeePerGas"].as_string(), + "0x10000000000000002"); + BOOST_CHECK_EQUAL(std::ranges::count(client->methods, "eth_maxPriorityFeePerGas"), 1u); + BOOST_CHECK_EQUAL(std::ranges::count(client->methods, "eth_getBlockByNumber"), 1u); + BOOST_CHECK(std::ranges::find(client->methods, "eth_chainId") == client->methods.end()); + BOOST_CHECK_EQUAL(sign_count.load(), 0u); +} + +BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto policy = bounded_policy(); + policy.max_gas_limit = 999; + auto client = std::make_shared(provider, policy); + + sysio::opp_inbound_contract_client inbound{ + client, + std::string(contract_address), + {bytes_argument_function("epochIn"), no_argument_function("nextEpochIndex")}, + }; + std::string envelope = "01"; + expect_policy_rejection([&] { inbound.epoch_in(envelope); }); + + sysio::operator_registry_contract_client registry{ + client, + std::string(contract_address), + {bytes_argument_function("commit")}, + }; + std::string commitment = "01"; + expect_policy_rejection([&] { registry.commit(commitment); }); + + sysio::opp_contract_client opp{ + client, + std::string(contract_address), + {uint32_argument_function("emitOutboundEnvelope"), no_argument_function("getLatestOutboundEnvelope")}, + }; + uint32_t epoch = 1; + expect_policy_rejection([&] { opp.emit_outbound_envelope(epoch); }); + + BOOST_CHECK_EQUAL(sign_count.load(), 0u); + BOOST_CHECK_EQUAL(client->broadcast_count, 0u); +} + +BOOST_AUTO_TEST_CASE(policy_file_loads_two_independent_client_chain_entries) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [ + { + "client_id": "ethereum-mainnet", + "chain_id": "1", + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + }, + { + "client_id": "ethereum-sepolia", + "chain_id": "11155111", + "max_priority_fee_per_gas_wei": "3000000000", + "max_fee_per_gas_wei": "120000000000", + "max_gas_limit": "3000000", + "max_total_native_cost_wei": "500000000000000000" + } + ] + })json"); + + const auto policies = sysio::load_ethereum_transaction_policy_file(path); + BOOST_REQUIRE_EQUAL(policies.size(), 2u); + BOOST_CHECK_EQUAL(policies.at("ethereum-mainnet").chain_id, 1); + BOOST_CHECK_EQUAL(policies.at("ethereum-sepolia").chain_id, 11155111); + BOOST_CHECK(policies.at("ethereum-mainnet").max_fee_per_gas != + policies.at("ethereum-sepolia").max_fee_per_gas); +} + +BOOST_AUTO_TEST_CASE(policy_file_rejects_schema_duplicates_ranges_and_sensitive_unknown_fields) { + fc::temp_directory directory; + expect_policy_rejection([&] { + sysio::load_ethereum_transaction_policy_file(directory.path() / "missing.json"); + }); + + auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "client_id": "client-b", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }, { + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + try { + sysio::load_ethereum_transaction_policy_file(path); + BOOST_FAIL("expected duplicate policy rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == + ethereum_transaction_policy_reason::configuration_client_duplicate); + } + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "4294967296", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + + constexpr std::string_view sensitive_url = "https://user:password@example.invalid/rpc?token=secret"; + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [], + "https://user:password@example.invalid/rpc?token=secret": true + })json"); + try { + sysio::load_ethereum_transaction_policy_file(path); + BOOST_FAIL("expected unknown-field rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } +} + +BOOST_AUTO_TEST_CASE(plugin_startup_requires_exact_client_coverage_and_matching_chain) { + fc::temp_directory directory; + const std::vector client_specs{ + "client-a,signer-a,http://127.0.0.1:1,31337", + }; + + auto path = write_policy_file(directory, R"json({"version":1,"policies":[]})json"); + BOOST_CHECK(startup_rejection_reason(path, client_specs) == + ethereum_transaction_policy_reason::configuration_client_missing); + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "orphan-client", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + BOOST_CHECK(startup_rejection_reason(path, client_specs) == + ethereum_transaction_policy_reason::configuration_client_unknown); + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "1", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + BOOST_CHECK(startup_rejection_reason(path, client_specs) == + ethereum_transaction_policy_reason::configuration_chain_id_mismatch); +} + +BOOST_AUTO_TEST_CASE(plugin_startup_attaches_two_matching_client_chain_policies) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }, { + "client_id": "client-b", + "chain_id": "1", + "max_priority_fee_per_gas_wei": "20", + "max_fee_per_gas_wei": "200", + "max_gas_limit": "2000", + "max_total_native_cost_wei": "200000" + }] + })json"); + const auto clients = initialize_outpost_plugin( + path, + {"client-a,signer-a,http://127.0.0.1:1,31337", + "client-b,signer-a,http://127.0.0.1:1,1"}); + BOOST_REQUIRE_EQUAL(clients.size(), 2u); + BOOST_CHECK_EQUAL(clients.front()->id, "client-a"); + BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); + BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().max_total_native_cost, 100000); + BOOST_CHECK_EQUAL(clients.back()->id, "client-b"); + BOOST_CHECK_EQUAL(clients.back()->chain_id, 1); + BOOST_CHECK_EQUAL(clients.back()->client->transaction_policy().max_total_native_cost, 200000); +} + +BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + + with_initialized_outpost_plugin( + path, + {"client-a,signer-a,http://127.0.0.1:1,31337"}, + [&](auto& plugin) { + try { + plugin.create_outpost_client( + "client-a", + 1, + 1, + std::string(contract_address), + std::string(contract_address), + std::string(contract_address)); + BOOST_FAIL("expected client/outpost chain mismatch rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == + ethereum_transaction_policy_reason::configuration_chain_id_mismatch); + BOOST_CHECK_EQUAL(rejection.observed(), "1"); + BOOST_REQUIRE(rejection.allowed().has_value()); + BOOST_CHECK_EQUAL(*rejection.allowed(), "31337"); + } + }); +} + +BOOST_AUTO_TEST_CASE(plugin_startup_redacts_an_invalid_authenticated_rpc_url) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + constexpr std::string_view sensitive_url = + "http://user:password@localhost:not-a-port/rpc?token=secret"; + try { + initialize_outpost_plugin( + path, + {"client-a,signer-a," + std::string(sensitive_url) + ",31337"}); + BOOST_FAIL("expected invalid URL rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == + ethereum_transaction_policy_reason::configuration_schema_invalid); + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } +} + +BOOST_AUTO_TEST_SUITE_END() 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..a904e775df 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 @@ -187,6 +187,18 @@ 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); +ethereum_transaction_policy test_transaction_policy(std::string client_id, + uint32_t chain_id = test_evm_chain_id) { + return ethereum_transaction_policy{ + .client_id = std::move(client_id), + .chain_id = chain_id, + .max_priority_fee_per_gas = 100000000000, + .max_fee_per_gas = 1000000000000, + .max_gas_limit = 10000000, + .max_total_native_cost = fc::uint256{"1000000000000000000"}, + }; +} + 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())); @@ -359,14 +371,13 @@ BOOST_AUTO_TEST_CASE(read_inbound_envelope_validates_latest_slot) try { auto eth_client = std::make_shared( sig_provider, std::variant{rpc_url}, - fc::uint256{test_evm_chain_id}); + test_transaction_policy(std::string(latest_slot_test_entry_id))); auto abis = load_abi_fixture(opp_abi_fixture); const std::string opp_address{test_opp_address}; auto typed_opp = eth_client->get_contract(opp_address, abis); auto entry = std::make_shared(); entry->id = latest_slot_test_entry_id; - entry->url = rpc_url; entry->signature_provider = sig_provider; entry->client = eth_client; entry->chain_id = test_evm_chain_id; diff --git a/programs/examples/cranker-example/README.md b/programs/examples/cranker-example/README.md index f24d4889d0..8ed101e8a0 100644 --- a/programs/examples/cranker-example/README.md +++ b/programs/examples/cranker-example/README.md @@ -12,6 +12,7 @@ To run `cranker-example`, you need to provide at least one Ethereum signature pr cranker-example \ --signature-provider eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ --outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 \ + --outpost-ethereum-transaction-policy-file docs/ethereum-transaction-policy.example.json \ --ethereum-abi-file tests/fixtures/ethereum-abi-counter-01.json ``` @@ -34,7 +35,11 @@ Defines an Ethereum client connection. The format is: - **eth-client-id**: Unique identifier for this client. - **sig-provider-id**: The name of the signature provider to use (must match a name defined in `--signature-provider`). - **eth-node-url**: The URL of the Ethereum JSON-RPC endpoint. -- **eth-chain-id**: (Optional) The Ethereum chain ID. +- **eth-chain-id**: (Optional) A startup cross-check against the policy chain ID. The policy chain ID is authoritative. + +#### Transaction Policy (`--outpost-ethereum-transaction-policy-file`) + +Path to a versioned JSON file containing exactly one finite policy for every configured Ethereum client. See [`docs/ethereum-transaction-policy.example.json`](../../../docs/ethereum-transaction-policy.example.json) and the [outpost client configuration guide](../../../docs/outpost-client-plugins.md) for the schema and enforced invariants. #### Ethereum ABI File (`--ethereum-abi-file`) Path to an Ethereum contract ABI file (relative from current working directory or absolute path). The file should contain a JSON array of ABI-compliant contract definitions. @@ -43,4 +48,5 @@ Path to an Ethereum contract ABI file (relative from current working directory o To successfully start the application, the following are required: 1. At least **one** Ethereum signature provider. 2. At least **one** Ethereum outpost client. -3. At least **one** Ethereum ABI file reference. +3. One transaction-policy file covering every Ethereum client. +4. At least **one** Ethereum ABI file reference. From 05fe1fd0fcb62a8e5daea2410887190dd94b9d63 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 18:50:58 +0000 Subject: [PATCH 02/13] Address Ethereum policy review findings --- docs/outpost-client-plugins.md | 4 +- .../ethereum/ethereum_transaction_policy.hpp | 12 +- .../src/network/ethereum/ethereum_client.cpp | 4 +- .../ethereum/ethereum_transaction_policy.cpp | 207 ++++++++++-------- .../test_ethereum_transaction_policy.cpp | 22 ++ .../src/outpost_ethereum_client_plugin.cpp | 202 +++++++++-------- 6 files changed, 255 insertions(+), 196 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index cb35f4b5af..fda759c1bd 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -30,7 +30,7 @@ The policy file is required whenever the plugin is configured. A minimal file is } ``` -All policy integers are positive canonical decimal strings. Strings avoid JSON number precision loss for values up to `uint256`. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. +The chain id is a positive canonical decimal string in the external outpost domain `1..UINT32_MAX`. The four policy limits are positive canonical decimal strings up to `uint256`; strings avoid JSON number precision loss. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. Immediately before signing, the client requires: @@ -42,6 +42,8 @@ Immediately before signing, the client requires: All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. +Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded`, field `max_fee_per_gas_wei`, and numeric derived/configured values. + The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. 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 diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp index 84db9c72f2..7386d9d3a9 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -85,10 +86,17 @@ class ethereum_transaction_policy_exception : public fc::exception { std::optional _allowed; }; -/** Immutable local expenditure policy for one configured Ethereum client and chain. */ +/** Throw a structured transaction-policy exception from shared library or plugin validation. */ +[[noreturn]] void throw_transaction_policy_exception( + ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed = std::nullopt); + +/** Immutable local expenditure policy for one configured Ethereum client and uint32 outpost chain. */ struct ethereum_transaction_policy { std::string client_id; - fc::uint256 chain_id; + uint32_t chain_id; fc::uint256 max_priority_fee_per_gas; fc::uint256 max_fee_per_gas; fc::uint256 max_gas_limit; diff --git a/libraries/libfc/src/network/ethereum/ethereum_client.cpp b/libraries/libfc/src/network/ethereum/ethereum_client.cpp index 58f46e5428..2351d2903a 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_client.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_client.cpp @@ -112,7 +112,7 @@ void ethereum_client::log_policy_rejection(const ethereum_transaction_policy_exc "field={} observed={} allowed={}", reason_code_name(rejection.reason()), _transaction_policy.client_id, - _transaction_policy.chain_id.str(), + _transaction_policy.chain_id, safe_operation_type, rejection.field(), rejection.observed(), @@ -389,7 +389,7 @@ fc::variant ethereum_client::get_transaction_by_hash(const std::string& tx_hash) fc::uint256 ethereum_client::get_base_fee_per_gas() { auto block = get_block_by_number(block_tag_t::latest); if (!block.contains("baseFeePerGas")) { - throw ethereum_transaction_policy_exception( + throw_transaction_policy_exception( ethereum_transaction_policy_reason::rpc_quantity_invalid, "base_fee_per_gas", ""); diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 5beee48366..7b59494ead 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -36,15 +36,6 @@ std::string diagnostic_value(std::string_view value) { return std::string(value.substr(0, max_diagnostic_value_chars)) + "..."; } -/** Throw a structured policy exception. */ -[[noreturn]] void reject(ethereum_transaction_policy_reason reason, - std::string_view field, - std::string observed, - std::optional allowed = std::nullopt) { - throw ethereum_transaction_policy_exception( - reason, std::string(field), std::move(observed), std::move(allowed)); -} - /** Check whether every character is an ASCII decimal digit. */ bool is_decimal(std::string_view value) { return std::ranges::all_of(value, [](unsigned char c) { return c >= '0' && c <= '9'; }); @@ -95,14 +86,23 @@ void ethereum_transaction_policy_exception::rethrow() const { throw *this; } +void throw_transaction_policy_exception(ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed) { + throw ethereum_transaction_policy_exception( + reason, std::string(field), std::move(observed), std::move(allowed)); +} + fc::uint256 parse_canonical_uint256_decimal(std::string_view value, std::string_view field, bool allow_zero) { const bool numeric_value = is_decimal(value); const auto invalid = [&] { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, - field, - numeric_value ? diagnostic_value(value) : ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + field, + numeric_value ? diagnostic_value(value) : ""); }; if (value.empty() || !numeric_value || (value.size() > 1 && value.front() == '0')) invalid(); @@ -118,24 +118,29 @@ fc::uint256 parse_canonical_uint256_decimal(std::string_view value, fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field) { if (!value.is_string()) { - reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); } const std::string encoded = value.as_string(); if (!encoded.starts_with(hex_quantity_prefix)) { - reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); } const std::string_view digits{encoded.data() + hex_quantity_prefix.size(), encoded.size() - hex_quantity_prefix.size()}; const bool hexadecimal_value = is_hexadecimal(digits); if (digits.empty() || !hexadecimal_value || (digits.size() > 1 && digits.front() == '0')) { - reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, - field, - hexadecimal_value ? diagnostic_value(encoded) : ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, + field, + hexadecimal_value ? diagnostic_value(encoded) : ""); } if (digits.size() > max_uint256_hex_digits) { - reject(ethereum_transaction_policy_reason::rpc_quantity_out_of_range, field, diagnostic_value(encoded)); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::rpc_quantity_out_of_range, + field, + diagnostic_value(encoded)); } return fc::uint256(fc::from_hex(encoded)); @@ -147,32 +152,39 @@ std::string format_rpc_quantity(const fc::uint256& value) { void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy) { if (!is_safe_transaction_policy_identifier(policy.client_id)) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "client_id", ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, "client_id", ""); } if (policy.chain_id == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "chain_id", "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, "chain_id", "0"); } if (policy.max_priority_fee_per_gas == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, - "max_priority_fee_per_gas_wei", - "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + "max_priority_fee_per_gas_wei", + "0"); } if (policy.max_fee_per_gas == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_fee_per_gas_wei", "0"); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + "max_fee_per_gas_wei", + "0"); } if (policy.max_gas_limit == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_gas_limit", "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, "max_gas_limit", "0"); } if (policy.max_total_native_cost == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, - "max_total_native_cost_wei", - "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + "max_total_native_cost_wei", + "0"); } if (policy.max_priority_fee_per_gas > policy.max_fee_per_gas) { - reject(ethereum_transaction_policy_reason::fee_relationship_invalid, - "max_priority_fee_per_gas_wei", - policy.max_priority_fee_per_gas.str(), - policy.max_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_priority_fee_per_gas_wei", + policy.max_priority_fee_per_gas.str(), + policy.max_fee_per_gas.str()); } } @@ -180,34 +192,36 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, const fc::uint256& priority_fee, const fc::uint256& base_fee) { if (priority_fee > policy.max_priority_fee_per_gas) { - reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, - "max_priority_fee_per_gas", - priority_fee.str(), - policy.max_priority_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + priority_fee.str(), + policy.max_priority_fee_per_gas.str()); } const fc::uint256 maximum_base_fee = max_uint256() / max_fee_base_multiplier; if (base_fee > maximum_base_fee) { - reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, - "max_fee_per_gas", - "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), + max_uint256().str()); } const fc::uint256 doubled_base_fee = base_fee * max_fee_base_multiplier; const fc::uint256 remaining_fee_capacity = max_uint256() - doubled_base_fee; if (priority_fee > remaining_fee_capacity) { - reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, - "max_fee_per_gas", - "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), + max_uint256().str()); } const fc::uint256 max_fee = doubled_base_fee + priority_fee; if (max_fee > policy.max_fee_per_gas) { - reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas", - max_fee.str(), - policy.max_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas_wei", + max_fee.str(), + policy.max_fee_per_gas.str()); } return max_fee; } @@ -215,88 +229,91 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, const fc::uint256& estimated_gas) { if (estimated_gas > policy.max_gas_limit) { - reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "estimated_gas", - estimated_gas.str(), - policy.max_gas_limit.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "estimated_gas", + estimated_gas.str(), + policy.max_gas_limit.str()); } const fc::uint256 maximum_estimate = max_uint256() / gas_headroom_multiplier; if (estimated_gas > maximum_estimate) { - reject(ethereum_transaction_policy_reason::gas_limit_derivation_overflow, - "gas_limit", - "estimated_gas=" + estimated_gas.str() + - ",multiplier=" + std::to_string(gas_headroom_multiplier), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::gas_limit_derivation_overflow, + "gas_limit", + "estimated_gas=" + estimated_gas.str() + + ",multiplier=" + std::to_string(gas_headroom_multiplier), + max_uint256().str()); } const fc::uint256 gas_limit = (estimated_gas * gas_headroom_multiplier) / gas_headroom_divisor; if (gas_limit > policy.max_gas_limit) { - reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "gas_limit", - gas_limit.str(), - policy.max_gas_limit.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + gas_limit.str(), + policy.max_gas_limit.str()); } return gas_limit; } void validate_transaction_against_policy(const ethereum_transaction_policy& policy, const fc::crypto::ethereum::eip1559_tx& transaction) { - if (transaction.chain_id != policy.chain_id) { - reject(ethereum_transaction_policy_reason::chain_id_mismatch, - "chain_id", - transaction.chain_id.str(), - policy.chain_id.str()); + if (transaction.chain_id != fc::uint256{policy.chain_id}) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::chain_id_mismatch, + "chain_id", + transaction.chain_id.str(), + std::to_string(policy.chain_id)); } if (transaction.max_fee_per_gas < transaction.max_priority_fee_per_gas) { - reject(ethereum_transaction_policy_reason::fee_relationship_invalid, - "max_fee_per_gas", - transaction.max_fee_per_gas.str(), - transaction.max_priority_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + transaction.max_priority_fee_per_gas.str()); } if (transaction.max_priority_fee_per_gas > policy.max_priority_fee_per_gas) { - reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, - "max_priority_fee_per_gas", - transaction.max_priority_fee_per_gas.str(), - policy.max_priority_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + transaction.max_priority_fee_per_gas.str(), + policy.max_priority_fee_per_gas.str()); } if (transaction.max_fee_per_gas > policy.max_fee_per_gas) { - reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas", - transaction.max_fee_per_gas.str(), - policy.max_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + policy.max_fee_per_gas.str()); } if (transaction.gas_limit > policy.max_gas_limit) { - reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "gas_limit", - transaction.gas_limit.str(), - policy.max_gas_limit.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + transaction.gas_limit.str(), + policy.max_gas_limit.str()); } const fc::uint256 maximum_fee_without_overflow = transaction.gas_limit == 0 ? max_uint256() : fc::uint256{max_uint256() / transaction.gas_limit}; if (transaction.max_fee_per_gas > maximum_fee_without_overflow) { - reject(ethereum_transaction_policy_reason::total_cost_multiplication_overflow, - "max_total_native_cost", - "gas_limit=" + transaction.gas_limit.str() + - ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::total_cost_multiplication_overflow, + "max_total_native_cost", + "gas_limit=" + transaction.gas_limit.str() + + ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), + max_uint256().str()); } const fc::uint256 maximum_gas_cost = transaction.gas_limit * transaction.max_fee_per_gas; const fc::uint256 remaining_total_capacity = max_uint256() - maximum_gas_cost; if (transaction.value > remaining_total_capacity) { - reject(ethereum_transaction_policy_reason::total_cost_addition_overflow, - "max_total_native_cost", - "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::total_cost_addition_overflow, + "max_total_native_cost", + "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), + max_uint256().str()); } const fc::uint256 maximum_total_cost = maximum_gas_cost + transaction.value; if (maximum_total_cost > policy.max_total_native_cost) { - reject(ethereum_transaction_policy_reason::total_cost_cap_exceeded, - "max_total_native_cost", - maximum_total_cost.str(), - policy.max_total_native_cost.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::total_cost_cap_exceeded, + "max_total_native_cost", + maximum_total_cost.str(), + policy.max_total_native_cost.str()); } } diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index aa38f87ff2..810897812c 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -2,8 +2,10 @@ #include +#include #include #include +#include using namespace fc::crypto::ethereum; using namespace fc::network::ethereum; @@ -15,6 +17,8 @@ constexpr std::string_view max_uint256_decimal = constexpr std::string_view above_max_uint256_decimal = "115792089237316195423570985008687907853269984665640564039457584007913129639936"; +static_assert(std::is_same_v); + ethereum_transaction_policy bounded_policy() { return ethereum_transaction_policy{ .client_id = "client-a", @@ -163,6 +167,24 @@ BOOST_AUTO_TEST_CASE(policy_configuration_requires_positive_consistent_limits) { ethereum_transaction_policy_reason::fee_relationship_invalid); } +BOOST_AUTO_TEST_CASE(fee_caps_document_dynamic_base_fee_headroom) { + auto policy = bounded_policy(); + policy.max_priority_fee_per_gas = 3; + policy.max_fee_per_gas = 4; + BOOST_CHECK_NO_THROW(validate_transaction_policy_configuration(policy)); + + try { + derive_max_fee_per_gas(policy, 3, 1); + BOOST_FAIL("expected insufficient fee-cap headroom rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::max_fee_cap_exceeded); + BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas_wei"); + BOOST_CHECK_EQUAL(rejection.observed(), "5"); + BOOST_REQUIRE(rejection.allowed().has_value()); + BOOST_CHECK_EQUAL(*rejection.allowed(), "4"); + } +} + BOOST_AUTO_TEST_CASE(exact_caps_pass_and_each_cap_plus_one_rejects) { const auto policy = bounded_policy(); const auto exact = bounded_transaction(); 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 27962ab965..a247de3027 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 @@ -53,15 +53,6 @@ struct ethereum_client_spec { std::optional chain_id; }; -/** Throw a structured, sanitized plugin-configuration rejection. */ -[[noreturn]] void reject_configuration(ethereum_transaction_policy_reason reason, - std::string_view field, - std::string observed, - std::optional allowed = std::nullopt) { - throw ethereum_transaction_policy_exception( - reason, std::string(field), std::move(observed), std::move(allowed)); -} - /** Require an object to contain each expected field exactly once and no unknown fields. */ template void validate_exact_fields(const fc::variant_object& object, @@ -71,22 +62,25 @@ void validate_exact_fields(const fc::variant_object& object, for (const auto& entry : object) { const bool known = std::ranges::find(expected, entry.key()) != expected.end(); if (!known) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - context, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); } if (!observed_fields.insert(entry.key()).second) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - context, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); } } for (const auto field : expected) { if (!observed_fields.contains(std::string(field))) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - context, - "missing=" + std::string(field)); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + "missing=" + std::string(field)); } } } @@ -95,22 +89,21 @@ void validate_exact_fields(const fc::variant_object& object, std::string require_string_field(const fc::variant_object& object, std::string_view field) { const auto& value = object[std::string(field)]; if (!value.is_string()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - field, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, field, ""); } return value.as_string(); } /** Parse an externally registered chain id without truncation, then enforce its uint32 domain. */ -uint32_t require_external_chain_id(std::string_view value, std::string_view field) { +[[nodiscard]] uint32_t require_external_chain_id(std::string_view value, std::string_view field) { const fc::uint256 chain_id = parse_canonical_uint256_decimal(value, field); constexpr auto max_external_chain_id = std::numeric_limits::max(); if (chain_id > max_external_chain_id) { - reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, - field, - chain_id.str(), - std::to_string(max_external_chain_id)); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + field, + chain_id.str(), + std::to_string(max_external_chain_id)); } return chain_id.convert_to(); } @@ -119,20 +112,21 @@ uint32_t require_external_chain_id(std::string_view value, std::string_view fiel ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { auto parts = fc::split(encoded_spec, ','); if (parts.size() != 3 && parts.size() != 4) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_client, - "field_count=" + std::to_string(parts.size()), - "3 or 4"); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_client, + "field_count=" + std::to_string(parts.size()), + "3 or 4"); } if (parts[0].empty() || parts[1].empty() || parts[2].empty()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, - option_name_client, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + option_name_client, + ""); } if (!is_safe_transaction_policy_identifier(parts[0])) { - reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, - policy_field_client_id, - ""); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field_client_id, + ""); } ethereum_client_spec result{ @@ -157,65 +151,71 @@ ethereum_transaction_policy_map load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) { std::ifstream readable_policy_file{policy_file}; if (!readable_policy_file.is_open()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); } fc::variant document; try { document = fc::json::from_file(policy_file, fc::json::parse_type::strict_parser); } catch (const fc::exception&) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); } catch (const std::exception&) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); } if (!document.is_object()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "root", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, "root", ""); } const auto root = document.get_object(); validate_exact_fields(root, root_fields, "root"); const auto& version = root[root_field_version]; + // Accept either integer representation: parser inputs and test-built variants do not share one signedness tag. const bool supported_version = (version.is_uint64() && version.as_uint64() == transaction_policy_schema_version) || (version.is_int64() && version.as_int64() == transaction_policy_schema_version); if (!supported_version) { - reject_configuration(ethereum_transaction_policy_reason::configuration_version_unsupported, - root_field_version, - "", - std::to_string(transaction_policy_schema_version)); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_version_unsupported, + root_field_version, + "", + std::to_string(transaction_policy_schema_version)); } const auto& policies_value = root[root_field_policies]; if (!policies_value.is_array()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - root_field_policies, - ""); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, + root_field_policies, + ""); } ethereum_transaction_policy_map result; for (const auto& policy_value : policies_value.get_array()) { if (!policy_value.is_object()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "policy", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "policy", + ""); } const auto policy_object = policy_value.get_object(); validate_exact_fields(policy_object, policy_fields, "policy"); const auto client_id = require_string_field(policy_object, policy_field_client_id); + const auto chain_id = require_external_chain_id( + require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id); ethereum_transaction_policy policy{ .client_id = client_id, - .chain_id = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id), + .chain_id = chain_id, .max_priority_fee_per_gas = parse_canonical_uint256_decimal( require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), policy_field_max_priority_fee_per_gas_wei), @@ -229,12 +229,12 @@ load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) policy_field_max_total_native_cost_wei), }; validate_transaction_policy_configuration(policy); - require_external_chain_id(policy.chain_id.str(), policy_field_chain_id); if (!result.emplace(client_id, std::move(policy)).second) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, - policy_field_client_id, - client_id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + client_id); } } return result; @@ -299,14 +299,16 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti my->load_abi_files(abi_files); } if (!options.contains(option_name_client)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); } if (!options.contains(option_name_transaction_policy_file)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); } const auto policy_file = options.at(option_name_transaction_policy_file).as(); @@ -316,9 +318,10 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti auto& signature_manager = app().get_plugin(); const auto encoded_client_specs = options.at(option_name_client).as>(); if (encoded_client_specs.empty()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); } std::vector client_specs; @@ -327,61 +330,68 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti for (const auto& encoded_spec : encoded_client_specs) { auto spec = parse_client_spec(encoded_spec); if (!configured_client_ids.insert(spec.id).second) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, - policy_field_client_id, - spec.id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + spec.id); } client_specs.emplace_back(std::move(spec)); } for (const auto& [client_id, policy] : policies) { if (!configured_client_ids.contains(client_id)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_unknown, - policy_field_client_id, - client_id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_unknown, + policy_field_client_id, + client_id); } } for (const auto& spec : client_specs) { const auto policy_iterator = policies.find(spec.id); if (policy_iterator == policies.end()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, - policy_field_client_id, - spec.id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_missing, + policy_field_client_id, + spec.id); } const auto& policy = policy_iterator->second; - if (spec.chain_id && fc::uint256{*spec.chain_id} != policy.chain_id) { - reject_configuration(ethereum_transaction_policy_reason::configuration_chain_id_mismatch, - "client_spec.chain_id", - std::to_string(*spec.chain_id), - policy.chain_id.str()); + if (spec.chain_id && *spec.chain_id != policy.chain_id) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_chain_id_mismatch, + "client_spec.chain_id", + std::to_string(*spec.chain_id), + std::to_string(policy.chain_id)); } if (!signature_manager.has_provider(spec.signature_provider_id)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "signature_provider", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "signature_provider", + ""); } const auto signature_provider = signature_manager.get_provider(spec.signature_provider_id); - const auto chain_id = policy.chain_id.convert_to(); ethereum_client_ptr client; try { client = std::make_shared(signature_provider, spec.url, policy); } catch (const ethereum_transaction_policy_exception&) { throw; } catch (const std::exception&) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "client_spec.url", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "client_spec.url", + ""); } my->add_client(spec.id, std::make_shared( spec.id, signature_provider, std::move(client), - chain_id)); + policy.chain_id)); - ilog("Added policy-constrained ethereum client client_id={} chain_id={}", spec.id, chain_id); + ilog("Added policy-constrained ethereum client client_id={} chain_id={}", + spec.id, + policy.chain_id); } } catch (const ethereum_transaction_policy_exception& rejection) { elog("Ethereum transaction policy configuration rejected reason_code={} field={} observed={} allowed={}", From 7e59d9f7dbae7b6ad995e2e161c27ca62e062ec8 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 19:07:08 +0000 Subject: [PATCH 03/13] Clarify Ethereum fee cap diagnostics --- docs/outpost-client-plugins.md | 2 +- .../src/network/ethereum/ethereum_transaction_policy.cpp | 7 +++++-- .../network/ethereum/test_ethereum_transaction_policy.cpp | 8 +++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index fda759c1bd..9dcb4dfa6b 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -42,7 +42,7 @@ Immediately before signing, the client requires: All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. -Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded`, field `max_fee_per_gas_wei`, and numeric derived/configured values. +Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands and points to policy field `max_fee_per_gas_wei`; its allowed value is the configured cap. The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 7b59494ead..5484a8d899 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -219,8 +219,11 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, const fc::uint256 max_fee = doubled_base_fee + priority_fee; if (max_fee > policy.max_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas_wei", - max_fee.str(), + "max_fee_per_gas", + "derived_max_fee_per_gas=" + max_fee.str() + + ",formula=2*base_fee_per_gas(" + base_fee.str() + + ")+max_priority_fee_per_gas(" + priority_fee.str() + + "),policy_field=max_fee_per_gas_wei", policy.max_fee_per_gas.str()); } return max_fee; diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index 810897812c..027f84bff6 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -167,7 +167,7 @@ BOOST_AUTO_TEST_CASE(policy_configuration_requires_positive_consistent_limits) { ethereum_transaction_policy_reason::fee_relationship_invalid); } -BOOST_AUTO_TEST_CASE(fee_caps_document_dynamic_base_fee_headroom) { +BOOST_AUTO_TEST_CASE(insufficient_dynamic_base_fee_headroom_reports_formula) { auto policy = bounded_policy(); policy.max_priority_fee_per_gas = 3; policy.max_fee_per_gas = 4; @@ -178,8 +178,10 @@ BOOST_AUTO_TEST_CASE(fee_caps_document_dynamic_base_fee_headroom) { BOOST_FAIL("expected insufficient fee-cap headroom rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::max_fee_cap_exceeded); - BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas_wei"); - BOOST_CHECK_EQUAL(rejection.observed(), "5"); + BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas"); + BOOST_CHECK_EQUAL(rejection.observed(), + "derived_max_fee_per_gas=5,formula=2*base_fee_per_gas(1)+" + "max_priority_fee_per_gas(3),policy_field=max_fee_per_gas_wei"); BOOST_REQUIRE(rejection.allowed().has_value()); BOOST_CHECK_EQUAL(*rejection.allowed(), "4"); } From c7602ae76a9df22548e02d787da7499f74dc93ce Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 19:14:14 +0000 Subject: [PATCH 04/13] Simplify Ethereum fee diagnostics --- docs/outpost-client-plugins.md | 2 +- .../src/network/ethereum/ethereum_transaction_policy.cpp | 3 +-- .../network/ethereum/test_ethereum_transaction_policy.cpp | 7 ++++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index 9dcb4dfa6b..30037cabb0 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -42,7 +42,7 @@ Immediately before signing, the client requires: All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. -Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands and points to policy field `max_fee_per_gas_wei`; its allowed value is the configured cap. +Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands; its allowed value is the configured cap. The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 5484a8d899..fdf24140cb 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -222,8 +222,7 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, "max_fee_per_gas", "derived_max_fee_per_gas=" + max_fee.str() + ",formula=2*base_fee_per_gas(" + base_fee.str() + - ")+max_priority_fee_per_gas(" + priority_fee.str() + - "),policy_field=max_fee_per_gas_wei", + ")+max_priority_fee_per_gas(" + priority_fee.str() + ")", policy.max_fee_per_gas.str()); } return max_fee; diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index 027f84bff6..c9138a2134 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -179,9 +179,10 @@ BOOST_AUTO_TEST_CASE(insufficient_dynamic_base_fee_headroom_reports_formula) { } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::max_fee_cap_exceeded); BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas"); - BOOST_CHECK_EQUAL(rejection.observed(), - "derived_max_fee_per_gas=5,formula=2*base_fee_per_gas(1)+" - "max_priority_fee_per_gas(3),policy_field=max_fee_per_gas_wei"); + BOOST_CHECK(rejection.observed().find("derived_max_fee_per_gas=5") != std::string::npos); + BOOST_CHECK(rejection.observed().find("base_fee_per_gas(1)") != std::string::npos); + BOOST_CHECK(rejection.observed().find("max_priority_fee_per_gas(3)") != std::string::npos); + BOOST_CHECK(rejection.observed().find("policy_field=") == std::string::npos); BOOST_REQUIRE(rejection.allowed().has_value()); BOOST_CHECK_EQUAL(*rejection.allowed(), "4"); } From 25e32410cf859ade49387de69d6f6e2c4aa9e9c9 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 21:39:16 +0000 Subject: [PATCH 05/13] Unify Ethereum client configuration --- docs/ethereum-client-config.example.json | 17 + docs/ethereum-transaction-policy.example.json | 13 - docs/outpost-client-plugins.md | 55 ++- .../ethereum/ethereum_transaction_policy.hpp | 3 + .../ethereum/ethereum_transaction_policy.cpp | 37 +- .../sysio/outpost_ethereum_client_plugin.hpp | 29 +- .../src/outpost_ethereum_client_plugin.cpp | 426 ++++++++++++------ .../test/test_ethereum_transaction_policy.cpp | 338 +++++++------- programs/examples/cranker-example/README.md | 27 +- 9 files changed, 588 insertions(+), 357 deletions(-) create mode 100644 docs/ethereum-client-config.example.json delete mode 100644 docs/ethereum-transaction-policy.example.json diff --git a/docs/ethereum-client-config.example.json b/docs/ethereum-client-config.example.json new file mode 100644 index 0000000000..cbc041d7fd --- /dev/null +++ b/docs/ethereum-client-config.example.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "clients": [ + { + "client_id": "eth-anvil-local", + "signature_provider_id": "eth-01", + "rpc_url": "http://localhost:8545", + "chain_id": "31337", + "transaction_policy": { + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } + } + ] +} diff --git a/docs/ethereum-transaction-policy.example.json b/docs/ethereum-transaction-policy.example.json deleted file mode 100644 index 0045b72da3..0000000000 --- a/docs/ethereum-transaction-policy.example.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "policies": [ - { - "client_id": "eth-anvil-local", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "2000000000", - "max_fee_per_gas_wei": "100000000000", - "max_gas_limit": "2000000", - "max_total_native_cost_wei": "250000000000000000" - } - ] -} diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index 30037cabb0..88fb9b3350 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -4,33 +4,55 @@ ## Configuration -The Ethereum client plugin is configured via program options as follows: +The preferred configuration keeps each RPC connection, replay-protection domain, and transaction policy in one +versioned JSON file. Signature providers remain separate because they may use `KEY`, wallet, or KMS backends: ```sh --signature-provider "eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" ---outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 ---outpost-ethereum-transaction-policy-file /etc/wire/ethereum-transaction-policy.json +--outpost-ethereum-client-config-file /etc/wire/ethereum-clients.json ``` -The policy file is required whenever the plugin is configured. A minimal file is: +The unified file can configure one or more EVM outposts: ```json { "version": 1, - "policies": [ + "clients": [ { "client_id": "eth-anvil-local", + "signature_provider_id": "eth-01", + "rpc_url": "http://localhost:8545", "chain_id": "31337", - "max_priority_fee_per_gas_wei": "2000000000", - "max_fee_per_gas_wei": "100000000000", - "max_gas_limit": "2000000", - "max_total_native_cost_wei": "250000000000000000" + "transaction_policy": { + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } } ] } ``` -The chain id is a positive canonical decimal string in the external outpost domain `1..UINT32_MAX`. The four policy limits are positive canonical decimal strings up to `uint256`; strings avoid JSON number precision loss. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. +`transaction_policy` is optional. If omitted, all four caps are set to the maximum `uint256` value. This permissive +default preserves the behavior from before transaction policies were introduced; production operators should set +finite limits explicitly. + +The chain id is a positive canonical decimal string in the external outpost domain `1..UINT32_MAX`. The four policy +limits are positive canonical decimal strings up to `uint256`; strings avoid JSON number precision loss. The loader +rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, and duplicate client ids. + +For backward compatibility, `--outpost-ethereum-client` remains available and cannot be combined with the unified +file: + +```sh +--outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 +``` + +Legacy clients always receive the permissive maximum-value policy. A fourth chain-id field is preferred and remains +locally authoritative. If the original three-field form is used, startup obtains `eth_chainId` from the RPC endpoint +under one aggregate five-second deadline, so an unavailable endpoint fails startup instead of blocking it indefinitely. +The removed `--outpost-ethereum-transaction-policy-file` option is not supported. Immediately before signing, the client requires: @@ -40,13 +62,20 @@ Immediately before signing, the client requires: - `maxFeePerGas >= maxPriorityFeePerGas` - `gasLimit * maxFeePerGas + value <= max_total_native_cost_wei` -All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. +All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus +one is rejected. Rejected transactions are not clamped, signed, or broadcast. In the unified and four-field legacy +modes, the configured `chain_id` is authoritative for signing and the RPC endpoint cannot select the +replay-protection domain. Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands; its allowed value is the configured cap. -The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. +The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than +one `client_id` only when it serves multiple distinct EVM outposts or chains, or when callers explicitly select +different same-chain endpoints or signers. Multiple same-chain RPC endpoints are not automatic failover: chain-id +lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. -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 +With the above configuration and the appropriate `app` and plugin config, you can access the Ethereum client configured +with name/id `eth-anvil-local` as follows: ```cpp // GET `outpost_ethereum_client_plugin` diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp index 7386d9d3a9..fee3e25be6 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -103,6 +103,9 @@ struct ethereum_transaction_policy { fc::uint256 max_total_native_cost; }; +/** Return the maximum value accepted by any uint256 transaction-policy cap. */ +const fc::uint256& maximum_ethereum_transaction_policy_value(); + /** * Parse a canonical unsigned decimal uint256 configuration value. * diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index fdf24140cb..87611464de 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -24,12 +24,6 @@ constexpr uint64_t gas_headroom_multiplier = 6; constexpr uint64_t gas_headroom_divisor = 5; constexpr uint64_t max_fee_base_multiplier = 2; -/** Return the largest uint256 value without relying on a narrowing intermediate. */ -const fc::uint256& max_uint256() { - static const fc::uint256 value{std::string(max_uint256_decimal)}; - return value; -} - /** Bound untrusted diagnostic strings so malformed configuration cannot flood logs. */ std::string diagnostic_value(std::string_view value) { if (value.size() <= max_diagnostic_value_chars) return std::string(value); @@ -50,6 +44,11 @@ bool is_hexadecimal(std::string_view value) { } // namespace +const fc::uint256& maximum_ethereum_transaction_policy_value() { + static const fc::uint256 value{std::string(max_uint256_decimal)}; + return value; +} + bool is_safe_transaction_policy_identifier(std::string_view identifier) { return !identifier.empty() && identifier.size() <= max_client_id_chars && std::ranges::all_of(identifier, [](unsigned char c) { @@ -198,22 +197,24 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, policy.max_priority_fee_per_gas.str()); } - const fc::uint256 maximum_base_fee = max_uint256() / max_fee_base_multiplier; + const fc::uint256 maximum_base_fee = + maximum_ethereum_transaction_policy_value() / max_fee_base_multiplier; if (base_fee > maximum_base_fee) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::max_fee_derivation_overflow, "max_fee_per_gas", "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 doubled_base_fee = base_fee * max_fee_base_multiplier; - const fc::uint256 remaining_fee_capacity = max_uint256() - doubled_base_fee; + const fc::uint256 remaining_fee_capacity = + maximum_ethereum_transaction_policy_value() - doubled_base_fee; if (priority_fee > remaining_fee_capacity) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::max_fee_derivation_overflow, "max_fee_per_gas", "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 max_fee = doubled_base_fee + priority_fee; @@ -237,14 +238,15 @@ fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, policy.max_gas_limit.str()); } - const fc::uint256 maximum_estimate = max_uint256() / gas_headroom_multiplier; + const fc::uint256 maximum_estimate = + maximum_ethereum_transaction_policy_value() / gas_headroom_multiplier; if (estimated_gas > maximum_estimate) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::gas_limit_derivation_overflow, "gas_limit", "estimated_gas=" + estimated_gas.str() + ",multiplier=" + std::to_string(gas_headroom_multiplier), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 gas_limit = (estimated_gas * gas_headroom_multiplier) / gas_headroom_divisor; @@ -291,23 +293,26 @@ void validate_transaction_against_policy(const ethereum_transaction_policy& } const fc::uint256 maximum_fee_without_overflow = - transaction.gas_limit == 0 ? max_uint256() : fc::uint256{max_uint256() / transaction.gas_limit}; + transaction.gas_limit == 0 + ? maximum_ethereum_transaction_policy_value() + : fc::uint256{maximum_ethereum_transaction_policy_value() / transaction.gas_limit}; if (transaction.max_fee_per_gas > maximum_fee_without_overflow) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::total_cost_multiplication_overflow, "max_total_native_cost", "gas_limit=" + transaction.gas_limit.str() + ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 maximum_gas_cost = transaction.gas_limit * transaction.max_fee_per_gas; - const fc::uint256 remaining_total_capacity = max_uint256() - maximum_gas_cost; + const fc::uint256 remaining_total_capacity = + maximum_ethereum_transaction_policy_value() - maximum_gas_cost; if (transaction.value > remaining_total_capacity) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::total_cost_addition_overflow, "max_total_native_cost", "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 maximum_total_cost = maximum_gas_cost + transaction.value; 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 d69b4cc6ac..4bff6a461a 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 @@ -29,11 +29,30 @@ struct ethereum_client_entry_t { }; using ethereum_client_entry_ptr = std::shared_ptr; -using ethereum_transaction_policy_map = std::map; -/** Load and strictly validate a versioned per-client Ethereum transaction-policy file. */ -ethereum_transaction_policy_map -load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file); +/** One validated client connection and its optional explicitly configured transaction policy. */ +struct ethereum_client_configuration { + /** Stable identifier used by outpost plugins to select this client. */ + std::string id; + /** Name of an Ethereum signature provider configured separately. */ + std::string signature_provider_id; + /** Ethereum JSON-RPC endpoint. May contain credentials and must not be logged. */ + std::string url; + /** Authoritative EVM replay-protection domain. */ + uint32_t chain_id; + /** Immutable local expenditure policy for this client. */ + ethereum_transaction_policy policy; +}; + +using ethereum_client_configuration_map = std::map; + +/** Construct the permissive compatibility policy for a client and authoritative chain id. */ +ethereum_transaction_policy make_default_ethereum_transaction_policy(std::string client_id, + uint32_t chain_id); + +/** Load a versioned unified Ethereum client file with optional nested transaction policies. */ +ethereum_client_configuration_map +load_ethereum_client_configuration_file(const std::filesystem::path& configuration_file); /// Typed contract client for OPP.sol. State-changing calls go through /// `create_tx_and_confirm` — OPP writes are consensus-critical and must @@ -135,7 +154,7 @@ class outpost_ethereum_client_plugin : public appbase::plugin #include +#include +#include #include #include @@ -18,27 +20,42 @@ namespace sysio { // using namespace outpost_client::ethereum; namespace { -constexpr auto option_name_client = "outpost-ethereum-client"; -constexpr auto option_name_transaction_policy_file = "outpost-ethereum-transaction-policy-file"; -constexpr auto option_abi_file = "ethereum-abi-file"; +constexpr auto option_name_client = "outpost-ethereum-client"; +constexpr auto option_name_client_configuration_file = "outpost-ethereum-client-config-file"; +constexpr auto option_abi_file = "ethereum-abi-file"; +constexpr int64_t legacy_chain_id_resolution_timeout_seconds = 5; -constexpr auto root_field_version = "version"; -constexpr auto root_field_policies = "policies"; +constexpr auto root_field_version = "version"; +constexpr auto root_field_clients = "clients"; constexpr auto policy_field_client_id = "client_id"; +constexpr auto client_field_signature_provider_id = "signature_provider_id"; +constexpr auto client_field_rpc_url = "rpc_url"; constexpr auto policy_field_chain_id = "chain_id"; +constexpr auto client_field_transaction_policy = "transaction_policy"; constexpr auto policy_field_max_priority_fee_per_gas_wei = "max_priority_fee_per_gas_wei"; constexpr auto policy_field_max_fee_per_gas_wei = "max_fee_per_gas_wei"; constexpr auto policy_field_max_gas_limit = "max_gas_limit"; constexpr auto policy_field_max_total_native_cost_wei = "max_total_native_cost_wei"; constexpr uint64_t transaction_policy_schema_version = 1; -constexpr std::array root_fields{ +constexpr std::array client_configuration_root_fields{ root_field_version, - root_field_policies, + root_field_clients, }; -constexpr std::array policy_fields{ +constexpr std::array required_client_configuration_fields{ policy_field_client_id, + client_field_signature_provider_id, + client_field_rpc_url, policy_field_chain_id, +}; +constexpr std::array client_configuration_fields_with_policy{ + policy_field_client_id, + client_field_signature_provider_id, + client_field_rpc_url, + policy_field_chain_id, + client_field_transaction_policy, +}; +constexpr std::array transaction_policy_fields{ policy_field_max_priority_fee_per_gas_wei, policy_field_max_fee_per_gas_wei, policy_field_max_gas_limit, @@ -108,6 +125,79 @@ std::string require_string_field(const fc::variant_object& object, std::string_v return chain_id.convert_to(); } +/** Load a strict versioned JSON object without exposing file contents in diagnostics. */ +template +fc::variant_object load_versioned_configuration_file( + const std::filesystem::path& configuration_file, + std::string_view option_name, + const std::array& expected_root_fields) { + std::ifstream readable_configuration_file{configuration_file}; + if (!readable_configuration_file.is_open()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name, + ""); + } + + fc::variant document; + try { + document = fc::json::from_file(configuration_file, fc::json::parse_type::strict_parser); + } catch (const fc::exception&) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name, + ""); + } catch (const std::exception&) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name, + ""); + } + + if (!document.is_object()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, "root", ""); + } + const auto root = document.get_object(); + validate_exact_fields(root, expected_root_fields, "root"); + + const auto& version = root[root_field_version]; + // fc's strict parser stores non-negative JSON integers in the uint64 alternative. + const bool supported_version = + version.is_uint64() && version.as_uint64() == transaction_policy_schema_version; + if (!supported_version) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_version_unsupported, + root_field_version, + "", + std::to_string(transaction_policy_schema_version)); + } + return root; +} + +/** Parse the four finite policy limits after their enclosing object is schema-validated. */ +ethereum_transaction_policy parse_transaction_policy(const fc::variant_object& policy_object, + std::string client_id, + uint32_t chain_id) { + ethereum_transaction_policy policy{ + .client_id = std::move(client_id), + .chain_id = chain_id, + .max_priority_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), + policy_field_max_priority_fee_per_gas_wei), + .max_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_fee_per_gas_wei), + policy_field_max_fee_per_gas_wei), + .max_gas_limit = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_gas_limit), policy_field_max_gas_limit), + .max_total_native_cost = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_total_native_cost_wei), + policy_field_max_total_native_cost_wei), + }; + validate_transaction_policy_configuration(policy); + return policy; +} + /** Parse a client specification while keeping its credential-bearing URL out of diagnostics. */ ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { auto parts = fc::split(encoded_spec, ','); @@ -147,99 +237,168 @@ ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { } } -ethereum_transaction_policy_map -load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) { - std::ifstream readable_policy_file{policy_file}; - if (!readable_policy_file.is_open()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, - ""); +ethereum_transaction_policy make_default_ethereum_transaction_policy(std::string client_id, + uint32_t chain_id) { + const auto& maximum = maximum_ethereum_transaction_policy_value(); + ethereum_transaction_policy policy{ + .client_id = std::move(client_id), + .chain_id = chain_id, + .max_priority_fee_per_gas = maximum, + .max_fee_per_gas = maximum, + .max_gas_limit = maximum, + .max_total_native_cost = maximum, + }; + validate_transaction_policy_configuration(policy); + return policy; +} + +ethereum_client_configuration_map +load_ethereum_client_configuration_file(const std::filesystem::path& configuration_file) { + const auto root = load_versioned_configuration_file( + configuration_file, option_name_client_configuration_file, client_configuration_root_fields); + + const auto& clients_value = root[root_field_clients]; + if (!clients_value.is_array()) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, + root_field_clients, + ""); } - fc::variant document; + ethereum_client_configuration_map result; + for (const auto& client_value : clients_value.get_array()) { + if (!client_value.is_object()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "client", + ""); + } + const auto client_object = client_value.get_object(); + const bool has_explicit_policy = client_object.contains(client_field_transaction_policy); + if (has_explicit_policy) { + validate_exact_fields(client_object, client_configuration_fields_with_policy, "client"); + } else { + validate_exact_fields(client_object, required_client_configuration_fields, "client"); + } + + const auto client_id = require_string_field(client_object, policy_field_client_id); + if (!is_safe_transaction_policy_identifier(client_id)) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field_client_id, + ""); + } + const auto signature_provider_id = + require_string_field(client_object, client_field_signature_provider_id); + if (signature_provider_id.empty()) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + client_field_signature_provider_id, + ""); + } + const auto rpc_url = require_string_field(client_object, client_field_rpc_url); + if (rpc_url.empty()) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + client_field_rpc_url, + ""); + } + const auto chain_id = require_external_chain_id( + require_string_field(client_object, policy_field_chain_id), policy_field_chain_id); + + auto policy = make_default_ethereum_transaction_policy(client_id, chain_id); + if (has_explicit_policy) { + const auto& policy_value = client_object[client_field_transaction_policy]; + if (!policy_value.is_object()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + client_field_transaction_policy, + ""); + } + const auto policy_object = policy_value.get_object(); + validate_exact_fields(policy_object, transaction_policy_fields, client_field_transaction_policy); + policy = parse_transaction_policy(policy_object, client_id, chain_id); + } + + ethereum_client_configuration configuration{ + .id = client_id, + .signature_provider_id = signature_provider_id, + .url = rpc_url, + .chain_id = chain_id, + .policy = std::move(policy), + }; + if (!result.emplace(client_id, std::move(configuration)).second) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + client_id); + } + } + return result; +} + +namespace { + +/** Resolve the legacy three-field client form exactly as it behaved before local policy binding. */ +uint32_t resolve_legacy_chain_id(const ethereum_client_spec& spec) { + if (spec.chain_id) return *spec.chain_id; + try { - document = fc::json::from_file(policy_file, fc::json::parse_type::strict_parser); + auto rpc_client = fc::network::json_rpc::json_rpc_client::create(spec.url); + const auto chain_id = parse_rpc_quantity(rpc_client.call("eth_chainId", fc::variants{}), "eth_chainId"); + constexpr auto max_external_chain_id = std::numeric_limits::max(); + if (chain_id == 0 || chain_id > max_external_chain_id) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + "eth_chainId", + chain_id.str(), + "1.." + std::to_string(max_external_chain_id)); + } + return chain_id.convert_to(); + } catch (const ethereum_transaction_policy_exception&) { + throw; } catch (const fc::exception&) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + "client_spec.url", + ""); } catch (const std::exception&) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + "client_spec.url", + ""); } +} - if (!document.is_object()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_schema_invalid, "root", ""); - } - const auto root = document.get_object(); - validate_exact_fields(root, root_fields, "root"); - - const auto& version = root[root_field_version]; - // Accept either integer representation: parser inputs and test-built variants do not share one signedness tag. - const bool supported_version = - (version.is_uint64() && version.as_uint64() == transaction_policy_schema_version) || - (version.is_int64() && version.as_int64() == transaction_policy_schema_version); - if (!supported_version) { +/** Convert backward-compatible command-line client specs to unified internal configurations. */ +ethereum_client_configuration_map +load_legacy_client_configurations(const std::vector& encoded_client_specs) { + if (encoded_client_specs.empty()) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_version_unsupported, - root_field_version, - "", - std::to_string(transaction_policy_schema_version)); - } - - const auto& policies_value = root[root_field_policies]; - if (!policies_value.is_array()) { - throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, - root_field_policies, - ""); + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); } - ethereum_transaction_policy_map result; - for (const auto& policy_value : policies_value.get_array()) { - if (!policy_value.is_object()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_schema_invalid, - "policy", - ""); - } - const auto policy_object = policy_value.get_object(); - validate_exact_fields(policy_object, policy_fields, "policy"); - - const auto client_id = require_string_field(policy_object, policy_field_client_id); - const auto chain_id = require_external_chain_id( - require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id); - ethereum_transaction_policy policy{ - .client_id = client_id, + ethereum_client_configuration_map result; + for (const auto& encoded_spec : encoded_client_specs) { + auto spec = parse_client_spec(encoded_spec); + const auto chain_id = resolve_legacy_chain_id(spec); + ethereum_client_configuration configuration{ + .id = spec.id, + .signature_provider_id = spec.signature_provider_id, + .url = spec.url, .chain_id = chain_id, - .max_priority_fee_per_gas = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), - policy_field_max_priority_fee_per_gas_wei), - .max_fee_per_gas = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_fee_per_gas_wei), - policy_field_max_fee_per_gas_wei), - .max_gas_limit = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_gas_limit), policy_field_max_gas_limit), - .max_total_native_cost = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_total_native_cost_wei), - policy_field_max_total_native_cost_wei), + .policy = make_default_ethereum_transaction_policy(spec.id, chain_id), }; - validate_transaction_policy_configuration(policy); - - if (!result.emplace(client_id, std::move(policy)).second) { + if (!result.emplace(spec.id, std::move(configuration)).second) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_client_duplicate, policy_field_client_id, - client_id); + spec.id); } } return result; } +} // namespace + class outpost_ethereum_client_plugin_impl { std::map _clients{}; using file_abi_contracts_t = std::pair>; @@ -298,82 +457,61 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti const auto abi_files = options.at(option_abi_file).as>(); my->load_abi_files(abi_files); } - if (!options.contains(option_name_client)) { + const bool has_legacy_clients = options.contains(option_name_client); + const bool has_configuration_file = options.contains(option_name_client_configuration_file); + if (has_legacy_clients && has_configuration_file) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, - ""); + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_client_configuration_file, + ""); } - if (!options.contains(option_name_transaction_policy_file)) { + if (!has_legacy_clients && !has_configuration_file) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, ""); } - const auto policy_file = options.at(option_name_transaction_policy_file).as(); - auto policies = load_ethereum_transaction_policy_file(policy_file); + // The legacy path resolves each omitted chain id and then constructs the permanent clients, + // whose JSON-RPC transports may resolve DNS again. Keep one aggregate deadline alive across + // both phases so no legacy endpoint can stall plugin initialization indefinitely. + std::optional legacy_chain_id_deadline; + if (!has_configuration_file) { + legacy_chain_id_deadline.emplace( + fc::time_point::now() + fc::seconds(legacy_chain_id_resolution_timeout_seconds)); + } - // This required plugin has already initialized every configured provider, independent of `--plugin` ordering. - auto& signature_manager = app().get_plugin(); - const auto encoded_client_specs = options.at(option_name_client).as>(); - if (encoded_client_specs.empty()) { + ethereum_client_configuration_map configurations; + if (has_configuration_file) { + const auto configuration_file = + options.at(option_name_client_configuration_file).as(); + configurations = load_ethereum_client_configuration_file(configuration_file); + } else { + configurations = load_legacy_client_configurations( + options.at(option_name_client).as>()); + } + if (configurations.empty()) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, + has_configuration_file ? root_field_clients : option_name_client, ""); } - std::vector client_specs; - std::set configured_client_ids; - client_specs.reserve(encoded_client_specs.size()); - for (const auto& encoded_spec : encoded_client_specs) { - auto spec = parse_client_spec(encoded_spec); - if (!configured_client_ids.insert(spec.id).second) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_duplicate, - policy_field_client_id, - spec.id); - } - client_specs.emplace_back(std::move(spec)); - } - - for (const auto& [client_id, policy] : policies) { - if (!configured_client_ids.contains(client_id)) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_unknown, - policy_field_client_id, - client_id); - } - } - - for (const auto& spec : client_specs) { - const auto policy_iterator = policies.find(spec.id); - if (policy_iterator == policies.end()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_missing, - policy_field_client_id, - spec.id); - } - const auto& policy = policy_iterator->second; - if (spec.chain_id && *spec.chain_id != policy.chain_id) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_chain_id_mismatch, - "client_spec.chain_id", - std::to_string(*spec.chain_id), - std::to_string(policy.chain_id)); - } - - if (!signature_manager.has_provider(spec.signature_provider_id)) { + // This required plugin has already initialized every configured provider, independent of `--plugin` ordering. + auto& signature_manager = app().get_plugin(); + for (auto& [client_id, configuration] : configurations) { + if (!signature_manager.has_provider(configuration.signature_provider_id)) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_schema_invalid, "signature_provider", ""); } - const auto signature_provider = signature_manager.get_provider(spec.signature_provider_id); + const auto signature_provider = + signature_manager.get_provider(configuration.signature_provider_id); ethereum_client_ptr client; try { - client = std::make_shared(signature_provider, spec.url, policy); + client = std::make_shared( + signature_provider, configuration.url, configuration.policy); } catch (const ethereum_transaction_policy_exception&) { throw; } catch (const std::exception&) { @@ -382,16 +520,16 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti "client_spec.url", ""); } - my->add_client(spec.id, + my->add_client(client_id, std::make_shared( - spec.id, + client_id, signature_provider, std::move(client), - policy.chain_id)); + configuration.chain_id)); ilog("Added policy-constrained ethereum client client_id={} chain_id={}", - spec.id, - policy.chain_id); + client_id, + configuration.chain_id); } } catch (const ethereum_transaction_policy_exception& rejection) { elog("Ethereum transaction policy configuration rejected reason_code={} field={} observed={} allowed={}", @@ -415,12 +553,12 @@ 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" - "`,,[,]`")( - option_name_transaction_policy_file, + "Backward-compatible Ethereum client spec. Each client receives the maximum-value default transaction " + "policy: `,,[,]`")( + option_name_client_configuration_file, boost::program_options::value(), - "Versioned JSON file containing exactly one finite transaction expenditure policy per configured " - "Ethereum client id and chain id")( + "Versioned JSON file containing Ethereum clients and optional per-client transaction policies. " + "Cannot be combined with --outpost-ethereum-client")( 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." diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp index 70c26a7984..f147825454 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -9,11 +9,15 @@ #include +#include + #include #include #include #include +#include #include +#include #include #include @@ -33,8 +37,60 @@ constexpr std::string_view signer_public_key = "47f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5"; constexpr std::string_view signer_private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; -constexpr std::string_view max_uint256_decimal = - "115792089237316195423570985008687907853269984665640564039457584007913129639935"; +using tcp = boost::asio::ip::tcp; + +/** One-shot JSON-RPC endpoint used to preserve coverage of the legacy three-field client form. */ +class chain_id_rpc_server { +public: + chain_id_rpc_server() + : _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) + , _port(_acceptor.local_endpoint().port()) + , _worker([this] { serve(); }) {} + + chain_id_rpc_server(const chain_id_rpc_server&) = delete; + chain_id_rpc_server& operator=(const chain_id_rpc_server&) = delete; + + ~chain_id_rpc_server() { + boost::system::error_code error; + _acceptor.close(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); + if (_worker.joinable()) _worker.join(); + } + + std::string url() const { + return "http://127.0.0.1:" + std::to_string(_port); + } + +private: + void serve() { + boost::system::error_code error; + tcp::socket socket(_io); + _acceptor.accept(socket, error); + if (error) return; + + boost::asio::streambuf request; + boost::asio::read_until(socket, request, "\r\n\r\n", error); + if (error) return; + + constexpr std::string_view response_body = + R"json({"jsonrpc":"2.0","id":1,"result":"0x7a69"})json"; + 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); + } + + boost::asio::io_context _io; + tcp::acceptor _acceptor; + uint16_t _port; + std::thread _worker; +}; ethereum_transaction_policy bounded_policy(std::string client_id = "client-a") { return ethereum_transaction_policy{ @@ -138,8 +194,9 @@ void expect_policy_rejection(const std::function& operation) { BOOST_CHECK_THROW(operation(), ethereum_transaction_policy_exception); } -std::filesystem::path write_policy_file(fc::temp_directory& directory, std::string_view contents) { - const auto path = directory.path() / "ethereum-transaction-policy.json"; +std::filesystem::path write_client_configuration_file(fc::temp_directory& directory, + std::string_view contents) { + const auto path = directory.path() / "ethereum-client-config.json"; std::ofstream output{path}; output << contents; output.close(); @@ -147,8 +204,7 @@ std::filesystem::path write_policy_file(fc::temp_directory& directory, std::stri } void with_initialized_outpost_plugin( - const std::filesystem::path& policy_file, - const std::vector& client_specs, + const std::vector& configuration_arguments, const std::function& inspect_plugin) { auto reset_application = gsl_lite::finally([] { appbase::application::reset_app_singleton(); }); appbase::scoped_app test_application{}; @@ -160,13 +216,8 @@ void with_initialized_outpost_plugin( "test_outpost_ethereum_transaction_policy", "--signature-provider", signature_spec, - "--outpost-ethereum-transaction-policy-file", - policy_file.string(), }; - for (const auto& client_spec : client_specs) { - arguments.emplace_back("--outpost-ethereum-client"); - arguments.emplace_back(client_spec); - } + arguments.insert(arguments.end(), configuration_arguments.begin(), configuration_arguments.end()); std::vector argv; argv.reserve(arguments.size()); @@ -179,21 +230,18 @@ void with_initialized_outpost_plugin( } std::vector -initialize_outpost_plugin(const std::filesystem::path& policy_file, - const std::vector& client_specs) { +initialize_outpost_plugin(const std::vector& configuration_arguments) { std::vector clients; with_initialized_outpost_plugin( - policy_file, - client_specs, + configuration_arguments, [&](auto& plugin) { clients = plugin.get_clients(); }); return clients; } ethereum_transaction_policy_reason startup_rejection_reason( - const std::filesystem::path& policy_file, - const std::vector& client_specs) { + const std::vector& configuration_arguments) { try { - initialize_outpost_plugin(policy_file, client_specs); + initialize_outpost_plugin(configuration_arguments); BOOST_FAIL("expected startup policy rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { return rejection.reason(); @@ -237,7 +285,7 @@ BOOST_AUTO_TEST_CASE(final_signing_boundary_rejects_without_signing_or_broadcast } auto wide_policy = bounded_policy("wide-client"); - const fc::uint256 maximum{std::string(max_uint256_decimal)}; + const auto& maximum = maximum_ethereum_transaction_policy_value(); wide_policy.max_priority_fee_per_gas = maximum; wide_policy.max_fee_per_gas = maximum; wide_policy.max_gas_limit = maximum; @@ -364,105 +412,107 @@ BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { BOOST_CHECK_EQUAL(client->broadcast_count, 0u); } -BOOST_AUTO_TEST_CASE(policy_file_loads_two_independent_client_chain_entries) { +BOOST_AUTO_TEST_CASE(default_policy_uses_the_maximum_value_for_every_expenditure_cap) { + const auto policy = sysio::make_default_ethereum_transaction_policy("client-a", 31337); + const auto& maximum = maximum_ethereum_transaction_policy_value(); + BOOST_CHECK_EQUAL(policy.chain_id, 31337); + BOOST_CHECK_EQUAL(policy.max_priority_fee_per_gas, maximum); + BOOST_CHECK_EQUAL(policy.max_fee_per_gas, maximum); + BOOST_CHECK_EQUAL(policy.max_gas_limit, maximum); + BOOST_CHECK_EQUAL(policy.max_total_native_cost, maximum); +} + +BOOST_AUTO_TEST_CASE(unified_file_loads_explicit_and_default_policies) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [ - { - "client_id": "ethereum-mainnet", - "chain_id": "1", + "clients": [{ + "client_id": "ethereum-mainnet", + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "1", + "transaction_policy": { "max_priority_fee_per_gas_wei": "2000000000", "max_fee_per_gas_wei": "100000000000", "max_gas_limit": "2000000", "max_total_native_cost_wei": "250000000000000000" - }, - { - "client_id": "ethereum-sepolia", - "chain_id": "11155111", - "max_priority_fee_per_gas_wei": "3000000000", - "max_fee_per_gas_wei": "120000000000", - "max_gas_limit": "3000000", - "max_total_native_cost_wei": "500000000000000000" } - ] + }, { + "client_id": "ethereum-sepolia", + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "11155111" + }] })json"); - const auto policies = sysio::load_ethereum_transaction_policy_file(path); - BOOST_REQUIRE_EQUAL(policies.size(), 2u); - BOOST_CHECK_EQUAL(policies.at("ethereum-mainnet").chain_id, 1); - BOOST_CHECK_EQUAL(policies.at("ethereum-sepolia").chain_id, 11155111); - BOOST_CHECK(policies.at("ethereum-mainnet").max_fee_per_gas != - policies.at("ethereum-sepolia").max_fee_per_gas); + const auto configurations = sysio::load_ethereum_client_configuration_file(path); + const auto& maximum = maximum_ethereum_transaction_policy_value(); + BOOST_REQUIRE_EQUAL(configurations.size(), 2u); + BOOST_CHECK_EQUAL(configurations.at("ethereum-mainnet").chain_id, 1); + BOOST_CHECK_EQUAL(configurations.at("ethereum-mainnet").policy.max_fee_per_gas, 100000000000ULL); + BOOST_CHECK_EQUAL(configurations.at("ethereum-sepolia").chain_id, 11155111); + BOOST_CHECK_EQUAL(configurations.at("ethereum-sepolia").policy.max_fee_per_gas, maximum); } -BOOST_AUTO_TEST_CASE(policy_file_rejects_schema_duplicates_ranges_and_sensitive_unknown_fields) { +BOOST_AUTO_TEST_CASE(unified_file_rejects_schema_duplicates_ranges_and_sensitive_unknown_fields) { fc::temp_directory directory; expect_policy_rejection([&] { - sysio::load_ethereum_transaction_policy_file(directory.path() / "missing.json"); + sysio::load_ethereum_client_configuration_file(directory.path() / "missing.json"); }); - auto path = write_policy_file(directory, R"json({ + auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", "client_id": "client-b", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "31337" }] })json"); - expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + expect_policy_rejection([&] { sysio::load_ethereum_client_configuration_file(path); }); - path = write_policy_file(directory, R"json({ + path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "31337" }, { "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:2", + "chain_id": "31337" }] })json"); try { - sysio::load_ethereum_transaction_policy_file(path); - BOOST_FAIL("expected duplicate policy rejection"); + sysio::load_ethereum_client_configuration_file(path); + BOOST_FAIL("expected duplicate client rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::configuration_client_duplicate); } - path = write_policy_file(directory, R"json({ + path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "4294967296", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "4294967296" }] })json"); - expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + expect_policy_rejection([&] { sysio::load_ethereum_client_configuration_file(path); }); constexpr std::string_view sensitive_url = "https://user:password@example.invalid/rpc?token=secret"; - path = write_policy_file(directory, R"json({ + path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [], + "clients": [], "https://user:password@example.invalid/rpc?token=secret": true })json"); try { - sysio::load_ethereum_transaction_policy_file(path); + sysio::load_ethereum_client_configuration_file(path); BOOST_FAIL("expected unknown-field rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); @@ -470,95 +520,84 @@ BOOST_AUTO_TEST_CASE(policy_file_rejects_schema_duplicates_ranges_and_sensitive_ } } -BOOST_AUTO_TEST_CASE(plugin_startup_requires_exact_client_coverage_and_matching_chain) { - fc::temp_directory directory; - const std::vector client_specs{ - "client-a,signer-a,http://127.0.0.1:1,31337", - }; - - auto path = write_policy_file(directory, R"json({"version":1,"policies":[]})json"); - BOOST_CHECK(startup_rejection_reason(path, client_specs) == - ethereum_transaction_policy_reason::configuration_client_missing); - - path = write_policy_file(directory, R"json({ - "version": 1, - "policies": [{ - "client_id": "orphan-client", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" - }] - })json"); - BOOST_CHECK(startup_rejection_reason(path, client_specs) == - ethereum_transaction_policy_reason::configuration_client_unknown); - - path = write_policy_file(directory, R"json({ - "version": 1, - "policies": [{ - "client_id": "client-a", - "chain_id": "1", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" - }] - })json"); - BOOST_CHECK(startup_rejection_reason(path, client_specs) == - ethereum_transaction_policy_reason::configuration_chain_id_mismatch); -} - -BOOST_AUTO_TEST_CASE(plugin_startup_attaches_two_matching_client_chain_policies) { +BOOST_AUTO_TEST_CASE(plugin_startup_attaches_unified_client_policies) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "transaction_policy": { + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + } }, { "client_id": "client-b", - "chain_id": "1", - "max_priority_fee_per_gas_wei": "20", - "max_fee_per_gas_wei": "200", - "max_gas_limit": "2000", - "max_total_native_cost_wei": "200000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "1" }] })json"); const auto clients = initialize_outpost_plugin( - path, - {"client-a,signer-a,http://127.0.0.1:1,31337", - "client-b,signer-a,http://127.0.0.1:1,1"}); + {"--outpost-ethereum-client-config-file", path.string()}); + const auto& maximum = maximum_ethereum_transaction_policy_value(); BOOST_REQUIRE_EQUAL(clients.size(), 2u); BOOST_CHECK_EQUAL(clients.front()->id, "client-a"); BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().max_total_native_cost, 100000); BOOST_CHECK_EQUAL(clients.back()->id, "client-b"); BOOST_CHECK_EQUAL(clients.back()->chain_id, 1); - BOOST_CHECK_EQUAL(clients.back()->client->transaction_policy().max_total_native_cost, 200000); + BOOST_CHECK_EQUAL(clients.back()->client->transaction_policy().max_total_native_cost, maximum); +} + +BOOST_AUTO_TEST_CASE(legacy_client_option_uses_default_policy_with_explicit_chain_id) { + const auto clients = initialize_outpost_plugin( + {"--outpost-ethereum-client", "client-a,signer-a,http://127.0.0.1:1,31337"}); + const auto& maximum = maximum_ethereum_transaction_policy_value(); + BOOST_REQUIRE_EQUAL(clients.size(), 1u); + BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); + BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().max_fee_per_gas, maximum); +} + +BOOST_AUTO_TEST_CASE(legacy_three_field_client_resolves_chain_id_from_rpc) { + chain_id_rpc_server rpc_server; + const auto clients = initialize_outpost_plugin( + {"--outpost-ethereum-client", "client-a,signer-a," + rpc_server.url()}); + BOOST_REQUIRE_EQUAL(clients.size(), 1u); + BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); + BOOST_CHECK_EQUAL(clients.front()->client->get_chain_id(), 31337); +} + +BOOST_AUTO_TEST_CASE(plugin_startup_rejects_mixed_unified_and_legacy_modes) { + fc::temp_directory directory; + const auto path = write_client_configuration_file( + directory, R"json({"version":1,"clients":[]})json"); + BOOST_CHECK(startup_rejection_reason( + {"--outpost-ethereum-client-config-file", + path.string(), + "--outpost-ethereum-client", + "client-a,signer-a,http://127.0.0.1:1,31337"}) == + ethereum_transaction_policy_reason::configuration_schema_invalid); } BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "31337" }] })json"); with_initialized_outpost_plugin( - path, - {"client-a,signer-a,http://127.0.0.1:1,31337"}, + {"--outpost-ethereum-client-config-file", path.string()}, [&](auto& plugin) { try { plugin.create_outpost_client( @@ -581,23 +620,20 @@ BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { BOOST_AUTO_TEST_CASE(plugin_startup_redacts_an_invalid_authenticated_rpc_url) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + constexpr std::string_view sensitive_url = + "http://user:password@localhost:not-a-port/rpc?token=secret"; + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://user:password@localhost:not-a-port/rpc?token=secret", + "chain_id": "31337" }] })json"); - constexpr std::string_view sensitive_url = - "http://user:password@localhost:not-a-port/rpc?token=secret"; try { initialize_outpost_plugin( - path, - {"client-a,signer-a," + std::string(sensitive_url) + ",31337"}); + {"--outpost-ethereum-client-config-file", path.string()}); BOOST_FAIL("expected invalid URL rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == diff --git a/programs/examples/cranker-example/README.md b/programs/examples/cranker-example/README.md index 8ed101e8a0..f8d324264b 100644 --- a/programs/examples/cranker-example/README.md +++ b/programs/examples/cranker-example/README.md @@ -11,8 +11,7 @@ To run `cranker-example`, you need to provide at least one Ethereum signature pr ```shell cranker-example \ --signature-provider eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ - --outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 \ - --outpost-ethereum-transaction-policy-file docs/ethereum-transaction-policy.example.json \ + --outpost-ethereum-client-config-file docs/ethereum-client-config.example.json \ --ethereum-abi-file tests/fixtures/ethereum-abi-counter-01.json ``` @@ -28,18 +27,17 @@ Defines a signature provider. The format is: - **public-key**: The public key string. - **private-key-provider-spec**: Specifier for the private key, typically `KEY:`. -#### Outpost Ethereum Client (`--outpost-ethereum-client`) -Defines an Ethereum client connection. The format is: -`,,[,]` +#### Outpost Ethereum Clients (`--outpost-ethereum-client-config-file`) -- **eth-client-id**: Unique identifier for this client. -- **sig-provider-id**: The name of the signature provider to use (must match a name defined in `--signature-provider`). -- **eth-node-url**: The URL of the Ethereum JSON-RPC endpoint. -- **eth-chain-id**: (Optional) A startup cross-check against the policy chain ID. The policy chain ID is authoritative. +Path to a versioned JSON file containing client ids, signature-provider references, RPC URLs, authoritative chain ids, +and optional per-client transaction policies. See +[`docs/ethereum-client-config.example.json`](../../../docs/ethereum-client-config.example.json) and the +[outpost client configuration guide](../../../docs/outpost-client-plugins.md). -#### Transaction Policy (`--outpost-ethereum-transaction-policy-file`) - -Path to a versioned JSON file containing exactly one finite policy for every configured Ethereum client. See [`docs/ethereum-transaction-policy.example.json`](../../../docs/ethereum-transaction-policy.example.json) and the [outpost client configuration guide](../../../docs/outpost-client-plugins.md) for the schema and enforced invariants. +The legacy +`--outpost-ethereum-client ,,[,]` option remains +available. It assigns maximum-value policy defaults and cannot be combined with +`--outpost-ethereum-client-config-file`. #### Ethereum ABI File (`--ethereum-abi-file`) Path to an Ethereum contract ABI file (relative from current working directory or absolute path). The file should contain a JSON array of ABI-compliant contract definitions. @@ -47,6 +45,5 @@ Path to an Ethereum contract ABI file (relative from current working directory o ## Minimum Configuration To successfully start the application, the following are required: 1. At least **one** Ethereum signature provider. -2. At least **one** Ethereum outpost client. -3. One transaction-policy file covering every Ethereum client. -4. At least **one** Ethereum ABI file reference. +2. At least **one** Ethereum outpost client, from the unified file or legacy option. +3. At least **one** Ethereum ABI file reference. From b8df393641d8238ce7d7efaab9d3bc963ff92ea7 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Mon, 3 Aug 2026 20:29:29 +0000 Subject: [PATCH 06/13] Simplify SEC-131 ProtoJSON configuration parsing Change-Id: Ide86fccb3cda8ea3f777199bddee1e7c705064af --- docs/outpost-client-plugins.md | 9 +- .../fc/network/ethereum/ethereum_client.hpp | 4 +- .../sysio/opp/config/client_config_loader.hpp | 16 +- .../src/client_config_loader.cpp | 298 ------------------ .../test/test_client_config_loader.cpp | 63 ++-- 5 files changed, 37 insertions(+), 353 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index 4d4f170326..c5ca384516 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -12,10 +12,11 @@ The preferred Ethereum client configuration is a versioned protobuf-JSON file: ``` See [ethereum-client-config.example.json](ethereum-client-config.example.json). The root -`schema_version` must be `1`. Each `clients` entry has a signing `connection`, an unquoted positive -32-bit `chain_id`, and an optional complete `transaction_policy` containing canonical decimal-string -caps for priority fee, maximum fee, gas limit, and total native cost. When `transaction_policy` is -omitted all four caps default to `UINT256_MAX` for backward compatibility. This maximum default is +`schema_version` must be `1`. Each `clients` entry has a signing `connection`, a positive 32-bit +`chain_id` using standard ProtoJSON numeric forms, and an optional complete `transaction_policy` +containing canonical decimal-string caps for priority fee, maximum fee, gas limit, and total native +cost. When `transaction_policy` is omitted or `null`, ProtoJSON leaves it unset and all four caps +default to `UINT256_MAX` for backward compatibility. This maximum default is compatibility-only: it provides no finite economic boundary, is not a production recommendation, and production operators must configure reviewed finite `transaction_policy` values. diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp index 928850ae15..d04ef5834b 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp @@ -529,8 +529,8 @@ class ethereum_client : public std::enable_shared_from_this { const block_number_or_tag_t& block = block_tag_t::pending); /** - * @brief Retrieves the chain ID of the connected Ethereum network. - * @return The chain ID. + * @brief Returns the locally configured authoritative chain ID without querying the RPC endpoint. + * @return The chain ID used for transaction replay protection and policy validation. */ fc::uint256 get_chain_id() const; diff --git a/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp b/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp index d5184a5109..21cb425985 100644 --- a/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp +++ b/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp @@ -20,21 +20,10 @@ namespace sysio::opp::config { /** Maximum accepted size of a host client-configuration JSON document. */ inline constexpr std::size_t max_client_configuration_file_size = 1024 * 1024; -/** Maximum accepted object/array nesting depth in a host client-configuration document. */ -inline constexpr std::size_t max_client_configuration_nesting_depth = 32; - /** Stable reason codes for sanitized client-configuration failures. */ enum class client_config_reason { file_unreadable, file_too_large, - json_invalid, - root_type_invalid, - nesting_depth_exceeded, - duplicate_field, - null_value, - unknown_field, - field_type_invalid, - numeric_token_invalid, proto_json_invalid, schema_version_unsupported, client_missing, @@ -87,10 +76,9 @@ class client_config_exception : public fc::exception { }; /** - * Load raw JSON into a generated protobuf message after bounded structural and descriptor-driven validation. + * Load bounded JSON into a generated protobuf message using strict ProtoJSON conversion. * - * The raw pass rejects duplicate keys, duplicate protobuf aliases, explicit nulls, unknown fields, invalid JSON - * types, noncanonical uint32 tokens, excessive size, and excessive nesting before ProtoJSON conversion. + * Unknown fields and malformed ProtoJSON are rejected. Other values follow the standard protobuf JSON mapping. */ void load_client_configuration_json(const std::filesystem::path& configuration_file, google::protobuf::Message& destination); diff --git a/libraries/opp/client_config/src/client_config_loader.cpp b/libraries/opp/client_config/src/client_config_loader.cpp index fdc769c8ea..378ffd8aa2 100644 --- a/libraries/opp/client_config/src/client_config_loader.cpp +++ b/libraries/opp/client_config/src/client_config_loader.cpp @@ -4,21 +4,14 @@ #include #include -#include #include #include #include -#include -#include -#include #include -#include -#include #include #include #include -#include namespace sysio::opp::config { @@ -26,13 +19,6 @@ namespace { constexpr uint32_t supported_schema_version = 1; -struct raw_validation_failure { - client_config_reason reason; - std::string field; - std::string observed; - std::optional allowed; -}; - [[noreturn]] void throw_client_config_failure(client_config_reason reason, std::string field, std::string observed, @@ -43,13 +29,6 @@ struct raw_validation_failure { std::move(allowed)); } -[[noreturn]] void throw_client_config_failure(raw_validation_failure failure) { - throw_client_config_failure(failure.reason, - std::move(failure.field), - std::move(failure.observed), - std::move(failure.allowed)); -} - /** Read a complete configuration document without exposing its path in failures. */ std::string read_bounded_configuration_file(const std::filesystem::path& configuration_file) { std::ifstream input(configuration_file, std::ios::binary | std::ios::ate); @@ -83,264 +62,6 @@ std::string read_bounded_configuration_file(const std::filesystem::path& configu return contents; } -/** Streaming structural guard that retains duplicate keys before a DOM can collapse them. */ -class raw_json_safety_handler - : public rapidjson::BaseReaderHandler, raw_json_safety_handler> { -public: - bool Null() { - return fail(client_config_reason::null_value, "document", ""); - } - - bool Bool(bool) { return accept_scalar_root(); } - bool Int(int) { return accept_scalar_root(); } - bool Uint(unsigned) { return accept_scalar_root(); } - bool Int64(std::int64_t) { return accept_scalar_root(); } - bool Uint64(std::uint64_t) { return accept_scalar_root(); } - bool Double(double) { return accept_scalar_root(); } - - bool RawNumber(const char* value, rapidjson::SizeType length, bool) { - _number_tokens.emplace_back(value, length); - return accept_scalar_root(); - } - bool String(const char*, rapidjson::SizeType, bool) { return accept_scalar_root(); } - - bool StartObject() { - if (_scopes.empty()) { - if (_root_seen) return fail(client_config_reason::json_invalid, "root", ""); - _root_seen = true; - } - if (_scopes.size() >= max_client_configuration_nesting_depth) { - return fail(client_config_reason::nesting_depth_exceeded, - "document", - std::to_string(_scopes.size() + 1), - std::to_string(max_client_configuration_nesting_depth)); - } - _scopes.emplace_back(std::set{}); - return true; - } - - bool Key(const char* value, rapidjson::SizeType length, bool) { - if (_scopes.empty() || !_scopes.back()) { - return fail(client_config_reason::json_invalid, "document", ""); - } - if (!_scopes.back()->emplace(value, length).second) { - return fail(client_config_reason::duplicate_field, "document", ""); - } - return true; - } - - bool EndObject(rapidjson::SizeType) { - if (_scopes.empty() || !_scopes.back()) { - return fail(client_config_reason::json_invalid, "document", ""); - } - _scopes.pop_back(); - return true; - } - - bool StartArray() { - if (_scopes.empty()) { - _root_seen = true; - return fail(client_config_reason::root_type_invalid, "root", ""); - } - if (_scopes.size() >= max_client_configuration_nesting_depth) { - return fail(client_config_reason::nesting_depth_exceeded, - "document", - std::to_string(_scopes.size() + 1), - std::to_string(max_client_configuration_nesting_depth)); - } - _scopes.emplace_back(std::nullopt); - return true; - } - - bool EndArray(rapidjson::SizeType) { - if (_scopes.empty() || _scopes.back()) { - return fail(client_config_reason::json_invalid, "document", ""); - } - _scopes.pop_back(); - return true; - } - - const std::optional& failure() const { return _failure; } - const std::vector& number_tokens() const { return _number_tokens; } - -private: - bool accept_scalar_root() { - if (!_scopes.empty()) return true; - _root_seen = true; - return fail(client_config_reason::root_type_invalid, "root", ""); - } - - bool fail(client_config_reason reason, - std::string field, - std::string observed, - std::optional allowed = std::nullopt) { - if (!_failure) { - _failure = raw_validation_failure{ - reason, - std::move(field), - std::move(observed), - std::move(allowed), - }; - } - return false; - } - - bool _root_seen = false; - std::vector>> _scopes; - std::optional _failure; - std::vector _number_tokens; -}; - -/** Resolve either a proto field name or its canonical ProtoJSON alias. */ -const google::protobuf::FieldDescriptor* -find_json_field(const google::protobuf::Descriptor& descriptor, std::string_view name) { - if (const auto* field = descriptor.FindFieldByName(std::string(name))) return field; - for (int index = 0; index < descriptor.field_count(); ++index) { - const auto* field = descriptor.field(index); - if (field->json_name() == name) return field; - } - return nullptr; -} - -std::string child_field_path(std::string_view parent, - const google::protobuf::FieldDescriptor& field) { - const std::string field_name(field.name().data(), field.name().size()); - if (parent.empty()) return field_name; - return std::string(parent) + "." + field_name; -} - -void validate_json_value(const rapidjson::Value& value, - const google::protobuf::FieldDescriptor& field, - std::string_view path, - const std::vector& number_tokens, - std::size_t& number_index); - -/** Validate a JSON object from the generated protobuf descriptor rather than a parallel schema. */ -void validate_json_message(const rapidjson::Value& value, - const google::protobuf::Descriptor& descriptor, - std::string_view path, - const std::vector& number_tokens, - std::size_t& number_index) { - if (!value.IsObject()) { - throw_client_config_failure(client_config_reason::field_type_invalid, - path.empty() ? "root" : std::string(path), - ""); - } - - std::set observed_fields; - for (auto member = value.MemberBegin(); member != value.MemberEnd(); ++member) { - const std::string_view member_name{member->name.GetString(), member->name.GetStringLength()}; - const auto* field = find_json_field(descriptor, member_name); - if (!field) { - throw_client_config_failure(client_config_reason::unknown_field, - path.empty() ? "root" : std::string(path), - ""); - } - const auto field_path = child_field_path(path, *field); - if (!observed_fields.insert(field).second) { - throw_client_config_failure(client_config_reason::duplicate_field, - field_path, - ""); - } - - if (field->is_repeated()) { - if (!member->value.IsArray()) { - throw_client_config_failure(client_config_reason::field_type_invalid, - field_path, - ""); - } - for (const auto& element : member->value.GetArray()) { - validate_json_value(element, *field, field_path, number_tokens, number_index); - } - } else { - validate_json_value(member->value, *field, field_path, number_tokens, number_index); - } - } -} - -void validate_json_value(const rapidjson::Value& value, - const google::protobuf::FieldDescriptor& field, - std::string_view path, - const std::vector& number_tokens, - std::size_t& number_index) { - const std::string* number_token = nullptr; - if (value.IsNumber()) { - if (number_index >= number_tokens.size()) { - throw_client_config_failure(client_config_reason::json_invalid, - "document", - ""); - } - number_token = &number_tokens[number_index++]; - } - - using field_descriptor = google::protobuf::FieldDescriptor; - switch (field.type()) { - case field_descriptor::TYPE_MESSAGE: - validate_json_message(value, *field.message_type(), path, number_tokens, number_index); - return; - case field_descriptor::TYPE_STRING: - case field_descriptor::TYPE_BYTES: - if (value.IsString()) return; - break; - case field_descriptor::TYPE_BOOL: - if (value.IsBool()) return; - break; - case field_descriptor::TYPE_UINT32: - case field_descriptor::TYPE_FIXED32: - if (value.IsUint() && number_token && !number_token->empty() && - (number_token->size() == 1 || number_token->front() != '0') && - std::ranges::all_of(*number_token, [](unsigned char character) { - return character >= '0' && character <= '9'; - })) { - return; - } - throw_client_config_failure(client_config_reason::numeric_token_invalid, - std::string(path), - ""); - case field_descriptor::TYPE_INT32: - case field_descriptor::TYPE_SINT32: - case field_descriptor::TYPE_SFIXED32: - if (value.IsInt()) return; - break; - case field_descriptor::TYPE_UINT64: - case field_descriptor::TYPE_FIXED64: - if (value.IsUint64() || value.IsString()) return; - break; - case field_descriptor::TYPE_INT64: - case field_descriptor::TYPE_SINT64: - case field_descriptor::TYPE_SFIXED64: - if (value.IsInt64() || value.IsString()) return; - break; - case field_descriptor::TYPE_FLOAT: - case field_descriptor::TYPE_DOUBLE: - if (value.IsNumber() || value.IsString()) return; - break; - case field_descriptor::TYPE_ENUM: - if (value.IsString() || value.IsInt()) return; - break; - case field_descriptor::TYPE_GROUP: - break; - } - - throw_client_config_failure(client_config_reason::field_type_invalid, - std::string(path), - ""); -} - -/** Run the duplicate/null/depth guard before constructing a JSON DOM. */ -std::vector validate_raw_json_safety(std::string_view json) { - rapidjson::Reader reader; - rapidjson::MemoryStream stream(json.data(), json.size()); - raw_json_safety_handler handler; - const bool valid = reader.Parse(stream, handler); - if (!valid || stream.Tell() != json.size()) { - if (handler.failure()) throw_client_config_failure(*handler.failure()); - throw_client_config_failure(client_config_reason::json_invalid, "document", ""); - } - return handler.number_tokens(); -} - fc::uint256 parse_policy_value(std::string_view value, std::string_view field) { try { return fc::network::ethereum::parse_canonical_uint256_decimal(value, field); @@ -404,25 +125,6 @@ void client_config_exception::rethrow() const { void load_client_configuration_json(const std::filesystem::path& configuration_file, google::protobuf::Message& destination) { const auto contents = read_bounded_configuration_file(configuration_file); - const auto number_tokens = validate_raw_json_safety(contents); - - rapidjson::Document document; - document.Parse(contents.data(), contents.size()); - if (document.HasParseError()) { - throw_client_config_failure(client_config_reason::json_invalid, "document", ""); - } - if (!document.IsObject()) { - throw_client_config_failure(client_config_reason::root_type_invalid, "root", ""); - } - - std::size_t number_index = 0; - validate_json_message(document, *destination.GetDescriptor(), "", number_tokens, number_index); - if (number_index != number_tokens.size()) { - throw_client_config_failure(client_config_reason::json_invalid, - "document", - ""); - } destination.Clear(); google::protobuf::util::JsonParseOptions options; diff --git a/libraries/opp/client_config/test/test_client_config_loader.cpp b/libraries/opp/client_config/test/test_client_config_loader.cpp index 6a1855864f..0529e9dc8c 100644 --- a/libraries/opp/client_config/test/test_client_config_loader.cpp +++ b/libraries/opp/client_config/test/test_client_config_loader.cpp @@ -31,7 +31,7 @@ config::client_config_reason rejection_reason(std::string_view contents) { } catch (const config::client_config_exception& rejection) { return rejection.reason(); } - return config::client_config_reason::json_invalid; + return config::client_config_reason::proto_json_invalid; } constexpr std::string_view valid_configuration = R"json({ @@ -90,7 +90,7 @@ BOOST_AUTO_TEST_CASE(accepts_transport_valid_bracketed_ipv6_host) { BOOST_CHECK_NO_THROW(config::load_evm_client_configuration_file(path)); } -BOOST_AUTO_TEST_CASE(rejects_file_bounds_and_raw_json_hazards) { +BOOST_AUTO_TEST_CASE(rejects_file_bounds_and_invalid_proto_json) { fc::temp_directory directory; try { (void) config::load_evm_client_configuration_file(directory.path() / "missing.json"); @@ -108,44 +108,37 @@ BOOST_AUTO_TEST_CASE(rejects_file_bounds_and_raw_json_hazards) { BOOST_CHECK(rejection.reason() == config::client_config_reason::file_too_large); } - std::string too_deep = R"json({"unknown":)json"; - too_deep.append(config::max_client_configuration_nesting_depth + 1, '['); - too_deep += "0"; - too_deep.append(config::max_client_configuration_nesting_depth + 1, ']'); - too_deep += "}"; - - BOOST_CHECK(rejection_reason("[]") == config::client_config_reason::root_type_invalid); - BOOST_CHECK(rejection_reason("{") == config::client_config_reason::json_invalid); - BOOST_CHECK(rejection_reason(R"json({"schema_version":1,"schema_version":1})json") == - config::client_config_reason::duplicate_field); - BOOST_CHECK(rejection_reason(R"json({"schema_version":null,"clients":[]})json") == - config::client_config_reason::null_value); - BOOST_CHECK(rejection_reason(too_deep) == config::client_config_reason::nesting_depth_exceeded); + BOOST_CHECK(rejection_reason("[]") == config::client_config_reason::proto_json_invalid); + BOOST_CHECK(rejection_reason("{") == config::client_config_reason::proto_json_invalid); std::string embedded_nul = std::string(valid_configuration) + '\0' + "{}"; - BOOST_CHECK(rejection_reason(embedded_nul) == config::client_config_reason::json_invalid); + BOOST_CHECK(rejection_reason(embedded_nul) == config::client_config_reason::proto_json_invalid); } -BOOST_AUTO_TEST_CASE(rejects_unknown_alias_duplicate_types_and_noncanonical_numeric_tokens) { +BOOST_AUTO_TEST_CASE(rejects_unknown_fields_and_proto_json_invalid_values) { BOOST_CHECK(rejection_reason(R"json({"schema_version":1,"clients":[],"unexpected":true})json") == - config::client_config_reason::unknown_field); - BOOST_CHECK(rejection_reason(R"json({"schema_version":1,"schemaVersion":1,"clients":[]})json") == - config::client_config_reason::duplicate_field); - BOOST_CHECK(rejection_reason(R"json({"schema_version":"1","clients":[]})json") == - config::client_config_reason::numeric_token_invalid); - BOOST_CHECK(rejection_reason(R"json({"schema_version":1.0,"clients":[]})json") == - config::client_config_reason::numeric_token_invalid); - BOOST_CHECK(rejection_reason(R"json({"schema_version":-0,"clients":[]})json") == - config::client_config_reason::numeric_token_invalid); - BOOST_CHECK(rejection_reason(R"json({"schema_version":1,"clients":{}})json") == - config::client_config_reason::field_type_invalid); - BOOST_CHECK(rejection_reason(R"json({ - "schema_version":1, - "clients":[{ - "connection":{"client_id":"a","signature_provider_id":"s","rpc_url":"http://localhost"}, - "chain_id":"1" - }] - })json") == config::client_config_reason::numeric_token_invalid); + config::client_config_reason::proto_json_invalid); + BOOST_CHECK(rejection_reason(R"json({"schema_version":1,"clients":true})json") == + config::client_config_reason::proto_json_invalid); +} + +BOOST_AUTO_TEST_CASE(follows_standard_proto_json_value_semantics) { + fc::temp_directory directory; + const auto configuration = config::load_evm_client_configuration_file( + write_configuration(directory, R"json({ + "schema_version":"1", + "clients":[{ + "connection":{"client_id":"a","signature_provider_id":"s","rpc_url":"http://localhost"}, + "chain_id":1.0, + "transaction_policy":null + }] + })json")); + + BOOST_REQUIRE(configuration.has_schema_version()); + BOOST_CHECK_EQUAL(configuration.schema_version(), 1u); + BOOST_REQUIRE_EQUAL(configuration.clients_size(), 1); + BOOST_CHECK_EQUAL(configuration.clients(0).chain_id(), 1u); + BOOST_CHECK(!configuration.clients(0).has_transaction_policy()); } BOOST_AUTO_TEST_CASE(rejects_semantically_invalid_configuration) { From 6d0d02021d5f8f155fe74b3ee7105cd50fdcab39 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Mon, 3 Aug 2026 20:55:51 +0000 Subject: [PATCH 07/13] Address SEC-131 project compliance Change-Id: Iea03b91f06f9cff21432e44267594404a3445fef --- .../src/network/ethereum/ethereum_client.cpp | 45 ++++-- .../ethereum/ethereum_transaction_policy.cpp | 132 ++++++++++++------ .../test_ethereum_transaction_policy.cpp | 4 + .../src/client_config_loader.cpp | 117 +++++++++++----- .../test/test_client_config_loader.cpp | 7 +- .../src/outpost_ethereum_client_plugin.cpp | 50 +++++-- .../test/test_ethereum_transaction_policy.cpp | 19 +++ 7 files changed, 272 insertions(+), 102 deletions(-) diff --git a/libraries/libfc/src/network/ethereum/ethereum_client.cpp b/libraries/libfc/src/network/ethereum/ethereum_client.cpp index 24f8ed0d53..0a1a8ec7fc 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_client.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_client.cpp @@ -19,6 +19,24 @@ using namespace fc::crypto; using namespace fc::crypto::ethereum; using namespace fc::network::json_rpc; constexpr std::string_view invalid_log_identifier = ""; +constexpr auto missing_rpc_value = ""; +constexpr auto unavailable_policy_limit = "n/a"; + +namespace transaction_policy_field { +constexpr std::string_view nonce = "nonce"; +constexpr std::string_view base_fee_per_gas = "base_fee_per_gas"; +constexpr std::string_view max_priority_fee_per_gas = "max_priority_fee_per_gas"; +constexpr std::string_view estimated_gas = "estimated_gas"; +constexpr std::string_view gas_price = "gas_price"; +} // namespace transaction_policy_field + +namespace ethereum_rpc_field { +constexpr std::string_view base_fee_per_gas = "baseFeePerGas"; +constexpr std::string_view max_priority_fee_per_gas = "maxPriorityFeePerGas"; +constexpr std::string_view max_fee_per_gas = "maxFeePerGas"; +} // namespace ethereum_rpc_field + +constexpr std::string_view gas_configuration_operation = "get_gas_config"; } // namespace /** @@ -119,7 +137,7 @@ void ethereum_client::log_policy_rejection(const ethereum_transaction_policy_exc safe_operation_type, rejection.field(), rejection.observed(), - rejection.allowed().value_or("n/a")); + rejection.allowed().value_or(unavailable_policy_limit)); } /** @@ -208,7 +226,7 @@ fc::uint256 ethereum_client::get_transaction_count(const address_compat_type& ad auto from_addr_hex = to_hex(from_addr, true); fc::variants params{from_addr_hex, to_block_tag(block)}; auto res = execute_idempotent("eth_getTransactionCount", params); - return parse_rpc_quantity(res, "nonce"); + return parse_rpc_quantity(res, transaction_policy_field::nonce); } /** @@ -389,12 +407,13 @@ fc::variant ethereum_client::get_transaction_by_hash(const std::string& tx_hash) */ fc::uint256 ethereum_client::get_base_fee_per_gas() { auto block = get_block_by_number(block_tag_t::latest); - if (!block.contains("baseFeePerGas")) { + const std::string base_fee_field{ethereum_rpc_field::base_fee_per_gas}; + if (!block.contains(base_fee_field)) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::rpc_quantity_invalid, - "base_fee_per_gas", - ""); + transaction_policy_field::base_fee_per_gas, + missing_rpc_value); } - return parse_rpc_quantity(block["baseFeePerGas"], "base_fee_per_gas"); + return parse_rpc_quantity(block[base_fee_field], transaction_policy_field::base_fee_per_gas); } /** @@ -409,7 +428,7 @@ fc::uint256 ethereum_client::get_base_fee_per_gas() { fc::uint256 ethereum_client::get_max_priority_fee_per_gas() { fc::variants params; // empty auto resp = execute_idempotent("eth_maxPriorityFeePerGas", params); - return parse_rpc_quantity(resp, "max_priority_fee_per_gas"); + return parse_rpc_quantity(resp, transaction_policy_field::max_priority_fee_per_gas); } /** @@ -429,7 +448,7 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const s tx("from", get_address())("to", to_address(to))("value", value); fc::variants params{fc::variant(tx)}; auto resp = execute_idempotent("eth_estimateGas", params); - return parse_rpc_quantity(resp, "estimated_gas"); + return parse_rpc_quantity(resp, transaction_policy_field::estimated_gas); } /** @@ -446,7 +465,7 @@ ethereum_client::gas_config_t ethereum_client::get_gas_config() { try { return get_gas_config_unlogged(); } catch (const ethereum_transaction_policy_exception& rejection) { - log_policy_rejection(rejection, "get_gas_config"); + log_policy_rejection(rejection, gas_configuration_operation); throw; } } @@ -488,13 +507,13 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const a tx("from", to_hex(get_address(), true)) ("to", to_hex(to_address(to), true)) - ("maxPriorityFeePerGas", format_rpc_quantity(gc.tip)) - ("maxFeePerGas", format_rpc_quantity(gc.max_fee_per_gas)) + (std::string(ethereum_rpc_field::max_priority_fee_per_gas), format_rpc_quantity(gc.tip)) + (std::string(ethereum_rpc_field::max_fee_per_gas), format_rpc_quantity(gc.max_fee_per_gas)) ("data", data) ("input", data); auto resp = execute_idempotent("eth_estimateGas", fc::variants{tx}); - return parse_rpc_quantity(resp, "estimated_gas"); + return parse_rpc_quantity(resp, transaction_policy_field::estimated_gas); } /** @@ -509,7 +528,7 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const a fc::uint256 ethereum_client::get_gas_price() { fc::variants params; // empty auto resp = execute_idempotent("eth_gasPrice", params); - return parse_rpc_quantity(resp, "gas_price"); + return parse_rpc_quantity(resp, transaction_policy_field::gas_price); } /** diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 87611464de..2e4e60497e 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -23,11 +23,48 @@ constexpr std::string_view hex_quantity_prefix = "0x"; constexpr uint64_t gas_headroom_multiplier = 6; constexpr uint64_t gas_headroom_divisor = 5; constexpr uint64_t max_fee_base_multiplier = 2; +constexpr auto truncated_diagnostic_suffix = "..."; +constexpr auto unavailable_allowed_value = "n/a"; + +namespace policy_field { +constexpr std::string_view client_id = "client_id"; +constexpr std::string_view chain_id = "chain_id"; +constexpr std::string_view max_priority_fee_per_gas_wei = "max_priority_fee_per_gas_wei"; +constexpr std::string_view max_fee_per_gas_wei = "max_fee_per_gas_wei"; +constexpr std::string_view max_gas_limit = "max_gas_limit"; +constexpr std::string_view max_total_native_cost_wei = "max_total_native_cost_wei"; +constexpr std::string_view max_priority_fee_per_gas = "max_priority_fee_per_gas"; +constexpr std::string_view max_fee_per_gas = "max_fee_per_gas"; +constexpr std::string_view estimated_gas = "estimated_gas"; +constexpr std::string_view gas_limit = "gas_limit"; +constexpr std::string_view max_total_native_cost = "max_total_native_cost"; +} // namespace policy_field + +namespace diagnostic_observation { +constexpr auto invalid = ""; +constexpr auto non_string = ""; +constexpr auto zero = "0"; +} // namespace diagnostic_observation + +namespace diagnostic_operand { +constexpr auto base_fee = "base_fee="; +constexpr auto multiplier = ",multiplier="; +constexpr auto doubled_base_fee = "doubled_base_fee="; +constexpr auto priority_fee = ",priority_fee="; +constexpr auto derived_max_fee_per_gas = "derived_max_fee_per_gas="; +constexpr auto fee_formula = ",formula=2*base_fee_per_gas("; +constexpr auto priority_fee_formula = ")+max_priority_fee_per_gas("; +constexpr auto estimated_gas = "estimated_gas="; +constexpr auto gas_limit = "gas_limit="; +constexpr auto max_fee_per_gas = ",max_fee_per_gas="; +constexpr auto maximum_gas_cost = "maximum_gas_cost="; +constexpr auto value = ",value="; +} // namespace diagnostic_operand /** Bound untrusted diagnostic strings so malformed configuration cannot flood logs. */ std::string diagnostic_value(std::string_view value) { if (value.size() <= max_diagnostic_value_chars) return std::string(value); - return std::string(value.substr(0, max_diagnostic_value_chars)) + "..."; + return std::string(value.substr(0, max_diagnostic_value_chars)) + truncated_diagnostic_suffix; } /** Check whether every character is an ASCII decimal digit. */ @@ -68,7 +105,7 @@ ethereum_transaction_policy_exception::ethereum_transaction_policy_exception( reason_code_name(reason), field, observed, - allowed.value_or("n/a")), + allowed.value_or(unavailable_allowed_value)), fc::invalid_arg_exception_code, "ethereum_transaction_policy_exception", "Ethereum transaction policy rejected") @@ -101,7 +138,7 @@ fc::uint256 parse_canonical_uint256_decimal(std::string_view value, throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_value_invalid, field, - numeric_value ? diagnostic_value(value) : ""); + numeric_value ? diagnostic_value(value) : diagnostic_observation::invalid); }; if (value.empty() || !numeric_value || (value.size() > 1 && value.front() == '0')) invalid(); @@ -118,13 +155,17 @@ fc::uint256 parse_canonical_uint256_decimal(std::string_view value, fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field) { if (!value.is_string()) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + ethereum_transaction_policy_reason::rpc_quantity_invalid, + field, + diagnostic_observation::non_string); } const std::string encoded = value.as_string(); if (!encoded.starts_with(hex_quantity_prefix)) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + ethereum_transaction_policy_reason::rpc_quantity_invalid, + field, + diagnostic_observation::invalid); } const std::string_view digits{encoded.data() + hex_quantity_prefix.size(), @@ -134,7 +175,7 @@ fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field) throw_transaction_policy_exception( ethereum_transaction_policy_reason::rpc_quantity_invalid, field, - hexadecimal_value ? diagnostic_value(encoded) : ""); + hexadecimal_value ? diagnostic_value(encoded) : diagnostic_observation::invalid); } if (digits.size() > max_uint256_hex_digits) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::rpc_quantity_out_of_range, @@ -152,36 +193,42 @@ std::string format_rpc_quantity(const fc::uint256& value) { void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy) { if (!is_safe_transaction_policy_identifier(policy.client_id)) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_value_invalid, "client_id", ""); + ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field::client_id, + diagnostic_observation::invalid); } if (policy.chain_id == 0) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_value_invalid, "chain_id", "0"); + ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field::chain_id, + diagnostic_observation::zero); } if (policy.max_priority_fee_per_gas == 0) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_value_invalid, - "max_priority_fee_per_gas_wei", - "0"); + policy_field::max_priority_fee_per_gas_wei, + diagnostic_observation::zero); } if (policy.max_fee_per_gas == 0) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, - "max_fee_per_gas_wei", - "0"); + policy_field::max_fee_per_gas_wei, + diagnostic_observation::zero); } if (policy.max_gas_limit == 0) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_value_invalid, "max_gas_limit", "0"); + ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field::max_gas_limit, + diagnostic_observation::zero); } if (policy.max_total_native_cost == 0) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_value_invalid, - "max_total_native_cost_wei", - "0"); + policy_field::max_total_native_cost_wei, + diagnostic_observation::zero); } if (policy.max_priority_fee_per_gas > policy.max_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::fee_relationship_invalid, - "max_priority_fee_per_gas_wei", + policy_field::max_priority_fee_per_gas_wei, policy.max_priority_fee_per_gas.str(), policy.max_fee_per_gas.str()); } @@ -192,7 +239,7 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, const fc::uint256& base_fee) { if (priority_fee > policy.max_priority_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, - "max_priority_fee_per_gas", + policy_field::max_priority_fee_per_gas, priority_fee.str(), policy.max_priority_fee_per_gas.str()); } @@ -202,8 +249,9 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, if (base_fee > maximum_base_fee) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::max_fee_derivation_overflow, - "max_fee_per_gas", - "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), + policy_field::max_fee_per_gas, + diagnostic_operand::base_fee + base_fee.str() + diagnostic_operand::multiplier + + std::to_string(max_fee_base_multiplier), maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 doubled_base_fee = base_fee * max_fee_base_multiplier; @@ -212,18 +260,19 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, if (priority_fee > remaining_fee_capacity) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::max_fee_derivation_overflow, - "max_fee_per_gas", - "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), + policy_field::max_fee_per_gas, + diagnostic_operand::doubled_base_fee + doubled_base_fee.str() + + diagnostic_operand::priority_fee + priority_fee.str(), maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 max_fee = doubled_base_fee + priority_fee; if (max_fee > policy.max_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas", - "derived_max_fee_per_gas=" + max_fee.str() + - ",formula=2*base_fee_per_gas(" + base_fee.str() + - ")+max_priority_fee_per_gas(" + priority_fee.str() + ")", + policy_field::max_fee_per_gas, + diagnostic_operand::derived_max_fee_per_gas + max_fee.str() + + diagnostic_operand::fee_formula + base_fee.str() + + diagnostic_operand::priority_fee_formula + priority_fee.str() + ")", policy.max_fee_per_gas.str()); } return max_fee; @@ -233,7 +282,7 @@ fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, const fc::uint256& estimated_gas) { if (estimated_gas > policy.max_gas_limit) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "estimated_gas", + policy_field::estimated_gas, estimated_gas.str(), policy.max_gas_limit.str()); } @@ -243,16 +292,16 @@ fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, if (estimated_gas > maximum_estimate) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::gas_limit_derivation_overflow, - "gas_limit", - "estimated_gas=" + estimated_gas.str() + - ",multiplier=" + std::to_string(gas_headroom_multiplier), + policy_field::gas_limit, + diagnostic_operand::estimated_gas + estimated_gas.str() + diagnostic_operand::multiplier + + std::to_string(gas_headroom_multiplier), maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 gas_limit = (estimated_gas * gas_headroom_multiplier) / gas_headroom_divisor; if (gas_limit > policy.max_gas_limit) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "gas_limit", + policy_field::gas_limit, gas_limit.str(), policy.max_gas_limit.str()); } @@ -263,31 +312,31 @@ void validate_transaction_against_policy(const ethereum_transaction_policy& const fc::crypto::ethereum::eip1559_tx& transaction) { if (transaction.chain_id != fc::uint256{policy.chain_id}) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::chain_id_mismatch, - "chain_id", + policy_field::chain_id, transaction.chain_id.str(), std::to_string(policy.chain_id)); } if (transaction.max_fee_per_gas < transaction.max_priority_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::fee_relationship_invalid, - "max_fee_per_gas", + policy_field::max_fee_per_gas, transaction.max_fee_per_gas.str(), transaction.max_priority_fee_per_gas.str()); } if (transaction.max_priority_fee_per_gas > policy.max_priority_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, - "max_priority_fee_per_gas", + policy_field::max_priority_fee_per_gas, transaction.max_priority_fee_per_gas.str(), policy.max_priority_fee_per_gas.str()); } if (transaction.max_fee_per_gas > policy.max_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas", + policy_field::max_fee_per_gas, transaction.max_fee_per_gas.str(), policy.max_fee_per_gas.str()); } if (transaction.gas_limit > policy.max_gas_limit) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "gas_limit", + policy_field::gas_limit, transaction.gas_limit.str(), policy.max_gas_limit.str()); } @@ -299,9 +348,9 @@ void validate_transaction_against_policy(const ethereum_transaction_policy& if (transaction.max_fee_per_gas > maximum_fee_without_overflow) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::total_cost_multiplication_overflow, - "max_total_native_cost", - "gas_limit=" + transaction.gas_limit.str() + - ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), + policy_field::max_total_native_cost, + diagnostic_operand::gas_limit + transaction.gas_limit.str() + + diagnostic_operand::max_fee_per_gas + transaction.max_fee_per_gas.str(), maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 maximum_gas_cost = transaction.gas_limit * transaction.max_fee_per_gas; @@ -310,15 +359,16 @@ void validate_transaction_against_policy(const ethereum_transaction_policy& if (transaction.value > remaining_total_capacity) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::total_cost_addition_overflow, - "max_total_native_cost", - "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), + policy_field::max_total_native_cost, + diagnostic_operand::maximum_gas_cost + maximum_gas_cost.str() + diagnostic_operand::value + + transaction.value.str(), maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 maximum_total_cost = maximum_gas_cost + transaction.value; if (maximum_total_cost > policy.max_total_native_cost) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::total_cost_cap_exceeded, - "max_total_native_cost", + policy_field::max_total_native_cost, maximum_total_cost.str(), policy.max_total_native_cost.str()); } diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index c9138a2134..3f97c3c8d0 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -19,6 +19,7 @@ constexpr std::string_view above_max_uint256_decimal = static_assert(std::is_same_v); +/** Return a compact positive policy used by validation and arithmetic tests. */ ethereum_transaction_policy bounded_policy() { return ethereum_transaction_policy{ .client_id = "client-a", @@ -30,6 +31,7 @@ ethereum_transaction_policy bounded_policy() { }; } +/** Return a policy whose limits cover the complete uint256 domain. */ ethereum_transaction_policy uint256_wide_policy() { const fc::uint256 maximum{std::string(max_uint256_decimal)}; return ethereum_transaction_policy{ @@ -42,6 +44,7 @@ ethereum_transaction_policy uint256_wide_policy() { }; } +/** Return a transaction that exactly matches the compact bounded policy. */ eip1559_tx bounded_transaction() { return eip1559_tx{ .chain_id = 31337, @@ -56,6 +59,7 @@ eip1559_tx bounded_transaction() { }; } +/** Assert that an operation fails with the expected stable policy reason. */ void check_rejection_reason(const std::function& operation, ethereum_transaction_policy_reason expected_reason) { try { diff --git a/libraries/opp/client_config/src/client_config_loader.cpp b/libraries/opp/client_config/src/client_config_loader.cpp index 378ffd8aa2..27602cbb17 100644 --- a/libraries/opp/client_config/src/client_config_loader.cpp +++ b/libraries/opp/client_config/src/client_config_loader.cpp @@ -19,12 +19,45 @@ namespace { constexpr uint32_t supported_schema_version = 1; +namespace configuration_field { +constexpr std::string_view configuration_file = "configuration_file"; +constexpr std::string_view document = "document"; +constexpr std::string_view schema_version = "schema_version"; +constexpr std::string_view clients = "clients"; +constexpr std::string_view connection = "connection"; +constexpr std::string_view client_id = "client_id"; +constexpr std::string_view signature_provider_id = "signature_provider_id"; +constexpr std::string_view rpc_url = "rpc_url"; +constexpr std::string_view chain_id = "chain_id"; +constexpr std::string_view transaction_policy = "transaction_policy"; +constexpr std::string_view max_priority_fee_per_gas_wei = "max_priority_fee_per_gas_wei"; +constexpr std::string_view max_fee_per_gas_wei = "max_fee_per_gas_wei"; +constexpr std::string_view max_gas_limit = "max_gas_limit"; +constexpr std::string_view max_total_native_cost_wei = "max_total_native_cost_wei"; +} // namespace configuration_field + +namespace diagnostic_observation { +constexpr auto unreadable = ""; +constexpr auto invalid = ""; +constexpr auto invalid_proto_json = ""; +constexpr auto missing = ""; +constexpr auto empty = ""; +constexpr auto incomplete = ""; +constexpr auto zero = "0"; +} // namespace diagnostic_observation + +constexpr std::string_view http_url_scheme = "http"; +constexpr std::string_view https_url_scheme = "https"; +constexpr auto positive_uint32_range = "1..UINT32_MAX"; +constexpr auto unavailable_allowed_value = "n/a"; + +/** Throw a structured configuration rejection while centralizing string ownership conversion. */ [[noreturn]] void throw_client_config_failure(client_config_reason reason, - std::string field, + std::string_view field, std::string observed, std::optional allowed = std::nullopt) { throw client_config_exception(reason, - std::move(field), + std::string(field), std::move(observed), std::move(allowed)); } @@ -34,20 +67,20 @@ std::string read_bounded_configuration_file(const std::filesystem::path& configu std::ifstream input(configuration_file, std::ios::binary | std::ios::ate); if (!input.is_open()) { throw_client_config_failure(client_config_reason::file_unreadable, - "configuration_file", - ""); + configuration_field::configuration_file, + diagnostic_observation::unreadable); } const auto end = input.tellg(); if (end < 0) { throw_client_config_failure(client_config_reason::file_unreadable, - "configuration_file", - ""); + configuration_field::configuration_file, + diagnostic_observation::unreadable); } const auto size = static_cast(end); if (size > max_client_configuration_file_size) { throw_client_config_failure(client_config_reason::file_too_large, - "configuration_file", + configuration_field::configuration_file, std::to_string(size), std::to_string(max_client_configuration_file_size)); } @@ -56,12 +89,13 @@ std::string read_bounded_configuration_file(const std::filesystem::path& configu input.seekg(0); if (!contents.empty() && !input.read(contents.data(), static_cast(contents.size()))) { throw_client_config_failure(client_config_reason::file_unreadable, - "configuration_file", - ""); + configuration_field::configuration_file, + diagnostic_observation::unreadable); } return contents; } +/** Parse a configured policy cap and translate failures to the configuration error vocabulary. */ fc::uint256 parse_policy_value(std::string_view value, std::string_view field) { try { return fc::network::ethereum::parse_canonical_uint256_decimal(value, field); @@ -73,21 +107,24 @@ fc::uint256 parse_policy_value(std::string_view value, std::string_view field) { } } +/** Validate an RPC URL without reflecting credentials, query parameters, or fragments. */ void validate_rpc_url(std::string_view raw_url) { if (raw_url.contains('#')) { throw_client_config_failure(client_config_reason::rpc_url_invalid, - "rpc_url", - ""); + configuration_field::rpc_url, + diagnostic_observation::invalid); } try { const fc::url parsed{std::string(raw_url)}; - const bool supported_scheme = parsed.proto() == "http" || parsed.proto() == "https"; + const bool supported_scheme = parsed.proto() == http_url_scheme || parsed.proto() == https_url_scheme; if (supported_scheme && parsed.host() && fc::http::is_safe_network_host(*parsed.host())) { return; } } catch (const std::exception&) { } - throw_client_config_failure(client_config_reason::rpc_url_invalid, "rpc_url", ""); + throw_client_config_failure(client_config_reason::rpc_url_invalid, + configuration_field::rpc_url, + diagnostic_observation::invalid); } } // namespace @@ -105,7 +142,7 @@ client_config_exception::client_config_exception(client_config_reason reas client_config_reason_name(reason), field, observed, - allowed.value_or("n/a")), + allowed.value_or(unavailable_allowed_value)), fc::invalid_arg_exception_code, "client_config_exception", "Client configuration rejected") @@ -132,8 +169,8 @@ void load_client_configuration_json(const std::filesystem::path& configuration_f const auto status = google::protobuf::util::JsonStringToMessage(contents, &destination, options); if (!status.ok()) { throw_client_config_failure(client_config_reason::proto_json_invalid, - "document", - ""); + configuration_field::document, + diagnostic_observation::invalid_proto_json); } } @@ -141,52 +178,55 @@ void validate_evm_client_configuration(const EvmClientConfigurationFile& configu if (!configuration.has_schema_version() || configuration.schema_version() != supported_schema_version) { throw_client_config_failure(client_config_reason::schema_version_unsupported, - "schema_version", + configuration_field::schema_version, configuration.has_schema_version() ? std::to_string(configuration.schema_version()) - : "", + : diagnostic_observation::missing, std::to_string(supported_schema_version)); } if (configuration.clients().empty()) { - throw_client_config_failure(client_config_reason::client_missing, "clients", ""); + throw_client_config_failure(client_config_reason::client_missing, + configuration_field::clients, + diagnostic_observation::empty); } std::set client_ids; for (const auto& client : configuration.clients()) { if (!client.has_connection()) { throw_client_config_failure(client_config_reason::connection_missing, - "connection", - ""); + configuration_field::connection, + diagnostic_observation::missing); } const auto& connection = client.connection(); if (!connection.has_client_id() || !fc::network::ethereum::is_safe_transaction_policy_identifier(connection.client_id())) { throw_client_config_failure(client_config_reason::client_identifier_invalid, - "client_id", - ""); + configuration_field::client_id, + diagnostic_observation::invalid); } if (!client_ids.insert(connection.client_id()).second) { throw_client_config_failure(client_config_reason::client_duplicate, - "client_id", + configuration_field::client_id, connection.client_id()); } if (!connection.has_signature_provider_id() || connection.signature_provider_id().empty()) { throw_client_config_failure(client_config_reason::signature_provider_identifier_invalid, - "signature_provider_id", - ""); + configuration_field::signature_provider_id, + diagnostic_observation::invalid); } if (!connection.has_rpc_url() || connection.rpc_url().empty()) { throw_client_config_failure(client_config_reason::rpc_url_invalid, - "rpc_url", - ""); + configuration_field::rpc_url, + diagnostic_observation::invalid); } validate_rpc_url(connection.rpc_url()); if (!client.has_chain_id() || client.chain_id() == 0) { throw_client_config_failure(client_config_reason::chain_id_invalid, - "chain_id", - client.has_chain_id() ? "0" : "", - "1..UINT32_MAX"); + configuration_field::chain_id, + client.has_chain_id() ? diagnostic_observation::zero + : diagnostic_observation::missing, + positive_uint32_range); } if (!client.has_transaction_policy()) continue; @@ -202,19 +242,20 @@ void validate_evm_client_configuration(const EvmClientConfigurationFile& configu !policy.max_total_native_cost_wei().empty(); if (!complete) { throw_client_config_failure(client_config_reason::policy_incomplete, - "transaction_policy", - ""); + configuration_field::transaction_policy, + diagnostic_observation::incomplete); } const auto maximum_priority_fee = parse_policy_value( - policy.max_priority_fee_per_gas_wei(), "max_priority_fee_per_gas_wei"); + policy.max_priority_fee_per_gas_wei(), configuration_field::max_priority_fee_per_gas_wei); const auto maximum_fee = parse_policy_value( - policy.max_fee_per_gas_wei(), "max_fee_per_gas_wei"); - (void) parse_policy_value(policy.max_gas_limit(), "max_gas_limit"); - (void) parse_policy_value(policy.max_total_native_cost_wei(), "max_total_native_cost_wei"); + policy.max_fee_per_gas_wei(), configuration_field::max_fee_per_gas_wei); + (void) parse_policy_value(policy.max_gas_limit(), configuration_field::max_gas_limit); + (void) parse_policy_value(policy.max_total_native_cost_wei(), + configuration_field::max_total_native_cost_wei); if (maximum_priority_fee > maximum_fee) { throw_client_config_failure(client_config_reason::policy_fee_relationship_invalid, - "max_priority_fee_per_gas_wei", + configuration_field::max_priority_fee_per_gas_wei, maximum_priority_fee.str(), maximum_fee.str()); } diff --git a/libraries/opp/client_config/test/test_client_config_loader.cpp b/libraries/opp/client_config/test/test_client_config_loader.cpp index 0529e9dc8c..cbf9ac34e5 100644 --- a/libraries/opp/client_config/test/test_client_config_loader.cpp +++ b/libraries/opp/client_config/test/test_client_config_loader.cpp @@ -12,9 +12,12 @@ namespace config = sysio::opp::config; namespace { +constexpr std::string_view default_configuration_name = "client-config.json"; + +/** Write one test configuration document to an isolated temporary file. */ std::filesystem::path write_configuration(fc::temp_directory& directory, std::string_view contents, - std::string_view name = "client-config.json") { + std::string_view name = default_configuration_name) { const auto path = directory.path() / std::string(name); std::ofstream output(path, std::ios::binary); output.write(contents.data(), static_cast(contents.size())); @@ -22,6 +25,7 @@ std::filesystem::path write_configuration(fc::temp_directory& directory, return path; } +/** Load a test document and return its structured rejection reason. */ config::client_config_reason rejection_reason(std::string_view contents) { fc::temp_directory directory; const auto path = write_configuration(directory, contents); @@ -34,6 +38,7 @@ config::client_config_reason rejection_reason(std::string_view contents) { return config::client_config_reason::proto_json_invalid; } +/** Representative valid configuration with both bounded and compatibility policy modes. */ constexpr std::string_view valid_configuration = R"json({ "schema_version": 1, "clients": [{ 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 430622eb55..de08c50324 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 @@ -21,6 +21,21 @@ constexpr auto option_name_client = "outpost-ethereum-client"; constexpr auto option_name_client_config_file = "outpost-ethereum-client-config-file"; constexpr auto option_abi_file = "ethereum-abi-file"; constexpr auto legacy_chain_id_resolution_timeout = fc::seconds(5); +constexpr std::string_view legacy_transaction_policy_client_id = "legacy-client"; +constexpr auto unavailable_policy_limit = "n/a"; + +namespace transaction_policy_field { +constexpr std::string_view chain_id = "chain_id"; +constexpr std::string_view max_priority_fee_per_gas_wei = "max_priority_fee_per_gas_wei"; +constexpr std::string_view max_fee_per_gas_wei = "max_fee_per_gas_wei"; +constexpr std::string_view max_gas_limit = "max_gas_limit"; +constexpr std::string_view max_total_native_cost_wei = "max_total_native_cost_wei"; +} // namespace transaction_policy_field + +namespace ethereum_rpc_method { +constexpr std::string_view chain_id = "eth_chainId"; +} // namespace ethereum_rpc_method + constexpr outbound_http::transport_option_names transport_option_names{ .additional_ca_file = "outpost-ethereum-additional-ca-file", .additional_ca_path = "outpost-ethereum-additional-ca-path", @@ -70,6 +85,7 @@ std::optional parse_legacy_chain_id(std::string_view text) { return value == 0 ? std::nullopt : std::optional{value}; } +/** Construct the compatibility policy used when a client has no explicit expenditure limits. */ ethereum_transaction_policy maximum_policy(std::string client_id, uint32_t chain_id) { const auto& maximum = fc::network::ethereum::maximum_ethereum_transaction_policy_value(); return ethereum_transaction_policy{ @@ -87,9 +103,10 @@ std::string transaction_policy_client_label(std::string_view client_id) { if (fc::network::ethereum::is_safe_transaction_policy_identifier(client_id)) { return std::string(client_id); } - return "legacy-client"; + return std::string(legacy_transaction_policy_client_id); } +/** Convert a validated protobuf client policy to the runtime transaction-policy model. */ ethereum_transaction_policy policy_from_configuration(const EvmClientConfiguration& client) { const auto& connection = client.connection(); if (!client.has_transaction_policy()) { @@ -101,28 +118,31 @@ ethereum_transaction_policy policy_from_configuration(const EvmClientConfigurati .client_id = connection.client_id(), .chain_id = client.chain_id(), .max_priority_fee_per_gas = fc::network::ethereum::parse_canonical_uint256_decimal( - policy.max_priority_fee_per_gas_wei(), "max_priority_fee_per_gas_wei"), + policy.max_priority_fee_per_gas_wei(), transaction_policy_field::max_priority_fee_per_gas_wei), .max_fee_per_gas = fc::network::ethereum::parse_canonical_uint256_decimal( - policy.max_fee_per_gas_wei(), "max_fee_per_gas_wei"), + policy.max_fee_per_gas_wei(), transaction_policy_field::max_fee_per_gas_wei), .max_gas_limit = fc::network::ethereum::parse_canonical_uint256_decimal( - policy.max_gas_limit(), "max_gas_limit"), + policy.max_gas_limit(), transaction_policy_field::max_gas_limit), .max_total_native_cost = fc::network::ethereum::parse_canonical_uint256_decimal( - policy.max_total_native_cost_wei(), "max_total_native_cost_wei"), + policy.max_total_native_cost_wei(), transaction_policy_field::max_total_native_cost_wei), }; } +/** Report a sanitized client-construction failure without exposing the endpoint or credentials. */ [[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 sanitized legacy chain-id lookup failure. */ [[noreturn]] void throw_legacy_chain_id_resolution_failure(const std::string& client_id) { FC_THROW_EXCEPTION(chain::plugin_config_exception, "Unable to resolve chain id for legacy outpost Ethereum client '{}'", client_id); } +/** Resolve and bound the chain id required by the legacy three-field client specification. */ uint32_t resolve_legacy_chain_id( const std::string& client_id, const std::string& url, @@ -130,7 +150,8 @@ uint32_t resolve_legacy_chain_id( try { auto rpc = fc::network::json_rpc::json_rpc_client::create(url, rpc_options); const auto chain_id = fc::network::ethereum::parse_rpc_quantity( - rpc.call_idempotent("eth_chainId", fc::variants{}), "chain_id"); + rpc.call_idempotent(std::string(ethereum_rpc_method::chain_id), fc::variants{}), + transaction_policy_field::chain_id); if (chain_id == 0 || chain_id > std::numeric_limits::max()) { throw_legacy_chain_id_resolution_failure(client_id); } @@ -144,6 +165,7 @@ uint32_t resolve_legacy_chain_id( } } +/** Resolve an explicitly named Ethereum signature provider for one configured client. */ fc::crypto::signature_provider_ptr resolve_signature_provider( signature_provider_manager_plugin& signature_provider_manager, const std::string& client_id, @@ -162,6 +184,7 @@ fc::crypto::signature_provider_ptr resolve_signature_provider( return provider; } +/** Construct a policy-enforcing Ethereum client and sanitize construction failures. */ ethereum_client_ptr create_client( const fc::crypto::signature_provider_ptr& signature_provider, const std::string& url, @@ -177,6 +200,7 @@ ethereum_client_ptr create_client( } } +/** Add one fully initialized client to a temporary map before it is published by the plugin. */ void add_client(client_map& clients, const std::string& client_id, const std::string& url, @@ -199,6 +223,7 @@ void add_client(client_map& clients, chain_id); } +/** Load every protobuf-configured client into a map that is published only on complete success. */ client_map load_file_clients( const std::filesystem::path& configuration_file, signature_provider_manager_plugin& signature_provider_manager, @@ -219,6 +244,7 @@ client_map load_file_clients( return clients; } +/** Load legacy command-line client specifications with maximum compatibility policies. */ client_map load_legacy_clients( const std::vector& client_specs, signature_provider_manager_plugin& signature_provider_manager, @@ -285,6 +311,7 @@ client_map load_legacy_clients( } // namespace +/** Private plugin state and lookup operations for fully initialized Ethereum clients. */ class outpost_ethereum_client_plugin_impl { std::map _clients{}; using file_abi_contracts_t = @@ -307,14 +334,18 @@ class outpost_ethereum_client_plugin_impl { return _abi_files; } + /** Atomically replace the published client map after initialization succeeds. */ void set_clients(client_map clients) { _clients = std::move(clients); } + /** Return every published client in deterministic identifier order. */ std::vector get_clients() { return std::views::values(_clients) | std::ranges::to(); } + /** Return the published client identified by @p id. */ ethereum_client_entry_ptr get_client(const std::string& id) { return _clients.at(id); } + /** Return the unique client for @p chain_id, or null when the id is ambiguous. */ ethereum_client_entry_ptr get_client_by_chain_id(uint32_t chain_id) { ethereum_client_entry_ptr match; for (const auto& entry : std::views::values(_clients)) { @@ -325,6 +356,7 @@ class outpost_ethereum_client_plugin_impl { return match; } + /** Return all loaded ABI files and their parsed contracts. */ const std::vector& get_abi_files() const { return _abi_files; } }; @@ -361,14 +393,14 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti client_config::client_config_reason_name(rejection.reason()), rejection.field(), rejection.observed(), - rejection.allowed().value_or("n/a")); + rejection.allowed().value_or(unavailable_policy_limit)); throw; } catch (const fc::network::ethereum::ethereum_transaction_policy_exception& rejection) { elog("Rejected Ethereum client policy (reason_code={},field={},observed={},allowed={})", fc::network::ethereum::reason_code_name(rejection.reason()), rejection.field(), rejection.observed(), - rejection.allowed().value_or("n/a")); + rejection.allowed().value_or(unavailable_policy_limit)); throw; } } @@ -434,7 +466,7 @@ outpost_ethereum_client_plugin::create_outpost_client(const std::string& eth_cli if (entry->chain_id != chain_id) { fc::network::ethereum::throw_transaction_policy_exception( fc::network::ethereum::ethereum_transaction_policy_reason::configuration_chain_id_mismatch, - "chain_id", + transaction_policy_field::chain_id, std::to_string(chain_id), std::to_string(entry->chain_id)); } diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp index a20e6392a1..6ea445f4ff 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -44,6 +44,7 @@ using tcp = boost::asio::ip::tcp; /** One-shot JSON-RPC endpoint used to preserve coverage of the legacy three-field client form. */ class chain_id_rpc_server { public: + /** Start a loopback server that returns the test chain id once. */ chain_id_rpc_server() : _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) , _port(_acceptor.local_endpoint().port()) @@ -52,6 +53,7 @@ class chain_id_rpc_server { chain_id_rpc_server(const chain_id_rpc_server&) = delete; chain_id_rpc_server& operator=(const chain_id_rpc_server&) = delete; + /** Stop the loopback server and join its worker thread. */ ~chain_id_rpc_server() { boost::system::error_code error; _acceptor.close(error); @@ -61,11 +63,13 @@ class chain_id_rpc_server { if (_worker.joinable()) _worker.join(); } + /** Return the loopback URL selected for this server instance. */ std::string url() const { return "http://127.0.0.1:" + std::to_string(_port); } private: + /** Serve one fixed JSON-RPC response and then return. */ void serve() { boost::system::error_code error; tcp::socket socket(_io); @@ -94,6 +98,7 @@ class chain_id_rpc_server { std::thread _worker; }; +/** Return a compact positive policy used by plugin enforcement tests. */ ethereum_transaction_policy bounded_policy(std::string client_id = "client-a") { return ethereum_transaction_policy{ .client_id = std::move(client_id), @@ -105,6 +110,7 @@ ethereum_transaction_policy bounded_policy(std::string client_id = "client-a") { }; } +/** Build a function ABI without input arguments. */ ethabi::contract no_argument_function(std::string name) { return ethabi::contract{ .name = std::move(name), @@ -114,6 +120,7 @@ ethabi::contract no_argument_function(std::string name) { }; } +/** Build a function ABI with one dynamic-bytes argument. */ ethabi::contract bytes_argument_function(std::string name) { return ethabi::contract{ .name = std::move(name), @@ -123,6 +130,7 @@ ethabi::contract bytes_argument_function(std::string name) { }; } +/** Build a function ABI with one uint32 argument. */ ethabi::contract uint32_argument_function(std::string name) { return ethabi::contract{ .name = std::move(name), @@ -132,6 +140,7 @@ ethabi::contract uint32_argument_function(std::string name) { }; } +/** Create an Ethereum signer that records every private-key operation. */ signature_provider_ptr make_recording_signer(std::atomic& sign_count) { auto private_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); auto ethereum_key = private_key.get(); @@ -149,12 +158,15 @@ signature_provider_ptr make_recording_signer(std::atomic& sign_count) { return provider; } +/** Deterministic Ethereum client that records RPC and broadcast activity. */ class recording_ethereum_client final : public ethereum_client { public: + /** Construct a recording client with the supplied signer and local policy. */ recording_ethereum_client(const signature_provider_ptr& provider, ethereum_transaction_policy policy) : ethereum_client(provider, std::string(fake_rpc_url), std::move(policy)) {} + /** Return deterministic responses for the RPC methods used by transaction construction. */ fc::variant execute(const std::string& method, const fc::variant& params) override { methods.emplace_back(method); if (method == "eth_maxPriorityFeePerGas") return fc::variant("0xa"); @@ -173,6 +185,7 @@ class recording_ethereum_client final : public ethereum_client { FC_THROW_EXCEPTION(fc::invalid_arg_exception, "unexpected fake RPC method {}", method); } + /** Route idempotent calls through the same deterministic recorder. */ fc::variant execute_idempotent(const std::string& method, const fc::variant& params) override { return execute(method, params); } @@ -182,6 +195,7 @@ class recording_ethereum_client final : public ethereum_client { size_t broadcast_count = 0; }; +/** Return a transaction that exactly reaches the bounded policy values. */ eip1559_tx exact_transaction() { return eip1559_tx{ .chain_id = 31337, @@ -196,10 +210,12 @@ eip1559_tx exact_transaction() { }; } +/** Assert that an operation is rejected by transaction-policy enforcement. */ void expect_policy_rejection(const std::function& operation) { BOOST_CHECK_THROW(operation(), ethereum_transaction_policy_exception); } +/** Write a plugin configuration fixture to an isolated temporary file. */ std::filesystem::path write_client_configuration_file(fc::temp_directory& directory, std::string_view contents) { const auto path = directory.path() / "ethereum-client-config.json"; @@ -209,6 +225,7 @@ std::filesystem::path write_client_configuration_file(fc::temp_directory& direct return path; } +/** Initialize an isolated plugin instance and expose it to a test callback. */ void with_initialized_outpost_plugin( const std::vector& configuration_arguments, const std::function& inspect_plugin, @@ -238,6 +255,7 @@ void with_initialized_outpost_plugin( inspect_plugin(test_application->get_plugin()); } +/** Initialize an isolated plugin instance and return its published clients. */ std::vector initialize_outpost_plugin(const std::vector& configuration_arguments, std::string_view signature_provider_id = "signer-a", @@ -251,6 +269,7 @@ initialize_outpost_plugin(const std::vector& configuration_argument return clients; } +/** Return the client count visible after a file configuration fails during initialization. */ std::size_t initialize_rejected_file_and_observe_published_clients( const std::filesystem::path& configuration_file) { auto reset_application = gsl_lite::finally([] { appbase::application::reset_app_singleton(); }); From 270f03e2c0ce63b23bc5c79f5c51157d42524b93 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Mon, 3 Aug 2026 21:06:23 +0000 Subject: [PATCH 08/13] Document SEC-131 ABI loader Change-Id: I7f67f6d889805c959b6d87a3c316c517dd682955 --- .../src/outpost_ethereum_client_plugin.cpp | 1 + 1 file changed, 1 insertion(+) 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 de08c50324..b16d098c22 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 @@ -319,6 +319,7 @@ class outpost_ethereum_client_plugin_impl { std::vector _abi_files{}; public: + /** Load and de-duplicate ABI files while preserving their parsed contract definitions. */ std::vector load_abi_files(const std::vector& file_names) { for (const auto& filename : file_names) { From ba0b3eb94d5541f3094b5c2b182ddaf9cddf1a02 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 4 Aug 2026 16:52:19 +0000 Subject: [PATCH 09/13] Address PR review feedback Change-Id: Ibaf6c5e63b642ac9e2fb42539ea35a774fe099a3 --- .../fc/network/ethereum/ethereum_client.hpp | 6 +- .../ethereum/ethereum_transaction_policy.hpp | 3 + .../src/network/ethereum/ethereum_client.cpp | 24 +++-- .../ethereum/ethereum_transaction_policy.cpp | 5 + .../test_ethereum_transaction_policy.cpp | 9 ++ .../sysio/opp/config/client_config_loader.hpp | 1 + .../src/client_config_loader.cpp | 6 ++ .../test/test_client_config_loader.cpp | 7 ++ .../src/outpost_ethereum_client_plugin.cpp | 99 +++++++++++++------ .../test/test_ethereum_transaction_policy.cpp | 99 ++++++++++++++----- .../test_outpost_ethereum_client_plugin.cpp | 91 ++++++++++++++++- .../underwriter_plugin/routing_detail.hpp | 23 ++--- .../src/underwriter_plugin.cpp | 37 +++---- .../test/test_underwriter_routing.cpp | 33 ++++--- 14 files changed, 336 insertions(+), 107 deletions(-) diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp index d04ef5834b..fd0b28928e 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp @@ -600,9 +600,9 @@ class ethereum_client : public std::enable_shared_from_this { /** Fetch and validate fee suggestions without logging a duplicate rejection. */ gas_config_t get_gas_config_unlogged(); - /** Emit one sanitized high-severity record for a policy rejection. */ - void log_policy_rejection(const ethereum_transaction_policy_exception& rejection, - std::string_view operation_type) const; + /** Emit one sanitized record with policy and RPC decode faults classified separately. */ + void log_transaction_rejection(const ethereum_transaction_policy_exception& rejection, + std::string_view operation_type) const; /** * @brief Signature provider for signing transactions diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp index dee98111c5..f22e1bb17c 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -37,6 +37,9 @@ inline std::string_view reason_code_name(ethereum_transaction_policy_reason reas return magic_enum::enum_name(reason); } +/** Return whether a rejection describes malformed or oversized JSON-RPC response data. */ +bool is_rpc_quantity_rejection(ethereum_transaction_policy_reason reason); + /** Return whether an identifier is bounded and ASCII-safe for policy logs and lookups. */ bool is_safe_transaction_policy_identifier(std::string_view identifier); diff --git a/libraries/libfc/src/network/ethereum/ethereum_client.cpp b/libraries/libfc/src/network/ethereum/ethereum_client.cpp index 0a1a8ec7fc..d940d04e02 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_client.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_client.cpp @@ -124,11 +124,23 @@ fc::variant ethereum_client::execute_idempotent(const std::string& method, const return _client.call_idempotent(method, params); } -/** Emit a sanitized policy-rejection record without endpoint, key, calldata, or signature data. */ -void ethereum_client::log_policy_rejection(const ethereum_transaction_policy_exception& rejection, - std::string_view operation_type) const { +/** Emit a sanitized rejection record without endpoint, key, calldata, or signature data. */ +void ethereum_client::log_transaction_rejection( + const ethereum_transaction_policy_exception& rejection, + std::string_view operation_type) const { const std::string_view safe_operation_type = is_safe_transaction_policy_identifier(operation_type) ? operation_type : invalid_log_identifier; + if (is_rpc_quantity_rejection(rejection.reason())) { + elog("Ethereum RPC response rejected reason_code={} client_id={} chain_id={} operation={} " + "field={} observed={}", + reason_code_name(rejection.reason()), + _transaction_policy.client_id, + _transaction_policy.chain_id, + safe_operation_type, + rejection.field(), + rejection.observed()); + return; + } elog("Ethereum transaction policy rejected reason_code={} client_id={} chain_id={} operation={} " "field={} observed={} allowed={}", reason_code_name(rejection.reason()), @@ -183,7 +195,7 @@ fc::variant ethereum_client::execute_contract_tx_fn(const eip1559_tx& source_tx, try { validate_transaction_against_policy(_transaction_policy, tx); } catch (const ethereum_transaction_policy_exception& rejection) { - log_policy_rejection(rejection, abi.name); + log_transaction_rejection(rejection, abi.name); throw; } @@ -302,7 +314,7 @@ eip1559_tx ethereum_client::create_default_tx(const address_compat_type& to, con .data = from_hex(data), .access_list = {}}; } catch (const ethereum_transaction_policy_exception& rejection) { - log_policy_rejection(rejection, contract.name); + log_transaction_rejection(rejection, contract.name); throw; } } @@ -465,7 +477,7 @@ ethereum_client::gas_config_t ethereum_client::get_gas_config() { try { return get_gas_config_unlogged(); } catch (const ethereum_transaction_policy_exception& rejection) { - log_policy_rejection(rejection, gas_configuration_operation); + log_transaction_rejection(rejection, gas_configuration_operation); throw; } } diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 2e4e60497e..f673146b2a 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -95,6 +95,11 @@ bool is_safe_transaction_policy_identifier(std::string_view identifier) { }); } +bool is_rpc_quantity_rejection(ethereum_transaction_policy_reason reason) { + return reason == ethereum_transaction_policy_reason::rpc_quantity_invalid || + reason == ethereum_transaction_policy_reason::rpc_quantity_out_of_range; +} + ethereum_transaction_policy_exception::ethereum_transaction_policy_exception( ethereum_transaction_policy_reason reason, std::string field, diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index 3f97c3c8d0..8a9e24d3ae 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -112,6 +112,15 @@ BOOST_AUTO_TEST_CASE(rpc_quantities_are_canonical_and_non_truncating) { ethereum_transaction_policy_reason::rpc_quantity_invalid); } +BOOST_AUTO_TEST_CASE(rpc_quantity_reasons_are_classified_as_response_faults) { + BOOST_CHECK(is_rpc_quantity_rejection( + ethereum_transaction_policy_reason::rpc_quantity_invalid)); + BOOST_CHECK(is_rpc_quantity_rejection( + ethereum_transaction_policy_reason::rpc_quantity_out_of_range)); + BOOST_CHECK(!is_rpc_quantity_rejection( + ethereum_transaction_policy_reason::max_fee_cap_exceeded)); +} + BOOST_AUTO_TEST_CASE(malformed_values_do_not_reappear_in_policy_diagnostics) { constexpr std::string_view sensitive_url = "https://user:password@example.invalid/rpc?token=secret"; try { diff --git a/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp b/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp index 21cb425985..c7166a95e1 100644 --- a/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp +++ b/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp @@ -36,6 +36,7 @@ enum class client_config_reason { policy_incomplete, policy_value_invalid, policy_fee_relationship_invalid, + chain_id_duplicate, }; /** Return the stable log spelling of a client-configuration reason. */ diff --git a/libraries/opp/client_config/src/client_config_loader.cpp b/libraries/opp/client_config/src/client_config_loader.cpp index 27602cbb17..8040ae67d3 100644 --- a/libraries/opp/client_config/src/client_config_loader.cpp +++ b/libraries/opp/client_config/src/client_config_loader.cpp @@ -191,6 +191,7 @@ void validate_evm_client_configuration(const EvmClientConfigurationFile& configu } std::set client_ids; + std::set chain_ids; for (const auto& client : configuration.clients()) { if (!client.has_connection()) { throw_client_config_failure(client_config_reason::connection_missing, @@ -228,6 +229,11 @@ void validate_evm_client_configuration(const EvmClientConfigurationFile& configu : diagnostic_observation::missing, positive_uint32_range); } + if (!chain_ids.insert(client.chain_id()).second) { + throw_client_config_failure(client_config_reason::chain_id_duplicate, + configuration_field::chain_id, + std::to_string(client.chain_id())); + } if (!client.has_transaction_policy()) continue; const auto& policy = client.transaction_policy(); diff --git a/libraries/opp/client_config/test/test_client_config_loader.cpp b/libraries/opp/client_config/test/test_client_config_loader.cpp index cbf9ac34e5..a7e5adb7b1 100644 --- a/libraries/opp/client_config/test/test_client_config_loader.cpp +++ b/libraries/opp/client_config/test/test_client_config_loader.cpp @@ -209,6 +209,13 @@ BOOST_AUTO_TEST_CASE(rejects_semantically_invalid_configuration) { {"connection":{"client_id":"a","signature_provider_id":"t","rpc_url":"http://localhost"},"chain_id":2} ] })json") == config::client_config_reason::client_duplicate); + BOOST_CHECK(rejection_reason(R"json({ + "schema_version":1, + "clients":[ + {"connection":{"client_id":"a","signature_provider_id":"s","rpc_url":"http://localhost"},"chain_id":1}, + {"connection":{"client_id":"b","signature_provider_id":"t","rpc_url":"http://localhost"},"chain_id":1} + ] + })json") == config::client_config_reason::chain_id_duplicate); } BOOST_AUTO_TEST_CASE(rejects_incomplete_noncanonical_and_inconsistent_policy) { 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 b16d098c22..49656ade2c 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 @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -20,9 +21,12 @@ namespace { constexpr auto option_name_client = "outpost-ethereum-client"; constexpr auto option_name_client_config_file = "outpost-ethereum-client-config-file"; constexpr auto option_abi_file = "ethereum-abi-file"; -constexpr auto legacy_chain_id_resolution_timeout = fc::seconds(5); +constexpr auto chain_id_resolution_timeout = fc::seconds(5); +constexpr auto chain_id_resolution_initial_backoff = fc::milliseconds(200); +constexpr auto chain_id_resolution_max_backoff = fc::seconds(1); constexpr std::string_view legacy_transaction_policy_client_id = "legacy-client"; constexpr auto unavailable_policy_limit = "n/a"; +constexpr std::string_view chain_id_resolution_operation = "ethereum-client:eth_chainId"; namespace transaction_policy_field { constexpr std::string_view chain_id = "chain_id"; @@ -52,6 +56,12 @@ namespace client_config = opp::config; using client_config::EvmClientConfiguration; using fc::network::ethereum::ethereum_transaction_policy; +/** Whether startup must verify a configured local chain id against `eth_chainId`. */ +enum class rpc_chain_id_validation { + not_required, + required, +}; + /** Parse a positive decimal or Ethereum hex quantity without fixed-width wraparound. */ std::optional parse_legacy_chain_id(std::string_view text) { if (text.empty()) return std::nullopt; @@ -135,33 +145,58 @@ ethereum_transaction_policy policy_from_configuration(const EvmClientConfigurati client_id); } -/** Report a sanitized legacy chain-id lookup failure. */ -[[noreturn]] void throw_legacy_chain_id_resolution_failure(const std::string& client_id) { +/** Report a sanitized startup chain-id lookup failure. */ +[[noreturn]] void throw_chain_id_resolution_failure(const std::string& client_id) { FC_THROW_EXCEPTION(chain::plugin_config_exception, - "Unable to resolve chain id for legacy outpost Ethereum client '{}'", + "Unable to resolve or validate chain id for outpost Ethereum client '{}' " + "within the bounded RPC startup grace", client_id); } -/** Resolve and bound the chain id required by the legacy three-field client specification. */ -uint32_t resolve_legacy_chain_id( +/** Return the retry envelope used independently by each startup chain-id probe. */ +fc::task::retry_options chain_id_resolution_retry_options() { + fc::task::retry_options options; + options.initial_backoff = chain_id_resolution_initial_backoff; + options.max_backoff = chain_id_resolution_max_backoff; + options.total_timeout = chain_id_resolution_timeout; + return options; +} + +/** Resolve and bound one RPC chain id after retrying transient transport failures. */ +uint32_t resolve_rpc_chain_id( const std::string& client_id, const std::string& url, const fc::network::json_rpc::client_options& rpc_options) { try { - auto rpc = fc::network::json_rpc::json_rpc_client::create(url, rpc_options); - const auto chain_id = fc::network::ethereum::parse_rpc_quantity( - rpc.call_idempotent(std::string(ethereum_rpc_method::chain_id), fc::variants{}), - transaction_policy_field::chain_id); - if (chain_id == 0 || chain_id > std::numeric_limits::max()) { - throw_legacy_chain_id_resolution_failure(client_id); - } - return chain_id.convert_to(); + fc::task::deadline_scope deadline(fc::time_point::now() + chain_id_resolution_timeout); + return fc::task::retry_until( + chain_id_resolution_operation, + chain_id_resolution_retry_options(), + [&]() -> std::optional { + fc::variant response; + try { + auto rpc = fc::network::json_rpc::json_rpc_client::create(url, rpc_options); + response = rpc.call_idempotent( + std::string(ethereum_rpc_method::chain_id), fc::variants{}); + } catch (const fc::exception&) { + return std::nullopt; + } catch (const std::exception&) { + return std::nullopt; + } + + const auto chain_id = fc::network::ethereum::parse_rpc_quantity( + response, transaction_policy_field::chain_id); + if (chain_id == 0 || chain_id > std::numeric_limits::max()) { + throw_chain_id_resolution_failure(client_id); + } + return chain_id.convert_to(); + }); } catch (const chain::plugin_config_exception&) { throw; } catch (const fc::exception&) { - throw_legacy_chain_id_resolution_failure(client_id); + throw_chain_id_resolution_failure(client_id); } catch (const std::exception&) { - throw_legacy_chain_id_resolution_failure(client_id); + throw_chain_id_resolution_failure(client_id); } } @@ -200,15 +235,25 @@ ethereum_client_ptr create_client( } } -/** Add one fully initialized client to a temporary map before it is published by the plugin. */ +/** Add one fully initialized and optionally RPC-validated client before publication. */ void add_client(client_map& clients, const std::string& client_id, const std::string& url, const fc::crypto::signature_provider_ptr& signature_provider, ethereum_transaction_policy policy, - const fc::network::json_rpc::client_options& rpc_options) { + const fc::network::json_rpc::client_options& rpc_options, + rpc_chain_id_validation chain_id_validation) { const auto chain_id = policy.chain_id; auto client = create_client(signature_provider, url, std::move(policy), rpc_options); + if (chain_id_validation == rpc_chain_id_validation::required) { + const auto remote_chain_id = resolve_rpc_chain_id(client_id, url, rpc_options); + SYS_ASSERT(remote_chain_id == chain_id, + chain::plugin_config_exception, + "Chain id mismatch for outpost Ethereum client '{}': configured {}, RPC endpoint reports {}", + client_id, + chain_id, + remote_chain_id); + } const bool inserted = clients.emplace( client_id, std::make_shared( @@ -239,7 +284,8 @@ client_map load_file_clients( connection.rpc_url(), provider, policy_from_configuration(configured_client), - rpc_options); + rpc_options, + rpc_chain_id_validation::required); } return clients; } @@ -249,15 +295,6 @@ client_map load_legacy_clients( const std::vector& client_specs, signature_provider_manager_plugin& signature_provider_manager, const fc::network::json_rpc::client_options& rpc_options) { - const bool resolves_chain_id = std::ranges::any_of(client_specs, [](const auto& client_spec) { - return fc::split(client_spec, ',').size() == 3; - }); - std::optional chain_id_resolution_deadline; - if (resolves_chain_id) { - chain_id_resolution_deadline.emplace( - fc::time_point::now() + legacy_chain_id_resolution_timeout); - } - client_map clients; for (const auto& client_spec : client_specs) { const auto parts = fc::split(client_spec, ','); @@ -294,7 +331,7 @@ client_map load_legacy_clients( client_id); chain_id = *parsed; } else { - chain_id = resolve_legacy_chain_id(client_id, url, rpc_options); + chain_id = resolve_rpc_chain_id(client_id, url, rpc_options); } auto provider = resolve_signature_provider( @@ -304,7 +341,9 @@ client_map load_legacy_clients( url, provider, maximum_policy(transaction_policy_client_label(client_id), chain_id), - rpc_options); + rpc_options, + parts.size() == 4 ? rpc_chain_id_validation::required + : rpc_chain_id_validation::not_required); } return clients; } diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp index 6ea445f4ff..139575bbce 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -39,14 +39,20 @@ constexpr std::string_view signer_public_key = "47f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5"; constexpr std::string_view signer_private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +constexpr std::string_view rpc_url_placeholder_a = "RPC_URL_A"; +constexpr std::string_view rpc_url_placeholder_b = "RPC_URL_B"; +constexpr std::string_view test_chain_id_quantity = "0x7a69"; +constexpr std::string_view ethereum_mainnet_chain_id_quantity = "0x1"; using tcp = boost::asio::ip::tcp; /** One-shot JSON-RPC endpoint used to preserve coverage of the legacy three-field client form. */ class chain_id_rpc_server { public: - /** Start a loopback server that returns the test chain id once. */ - chain_id_rpc_server() - : _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) + /** Start a loopback server that returns the supplied chain id once. */ + explicit chain_id_rpc_server(std::string_view chain_id_quantity = test_chain_id_quantity) + : _response_body(R"json({"jsonrpc":"2.0","id":1,"result":")json" + + std::string(chain_id_quantity) + R"json("})json") + , _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) , _port(_acceptor.local_endpoint().port()) , _worker([this] { serve(); }) {} @@ -80,18 +86,17 @@ class chain_id_rpc_server { boost::asio::read_until(socket, request, "\r\n\r\n", error); if (error) return; - constexpr std::string_view response_body = - R"json({"jsonrpc":"2.0","id":1,"result":"0x7a69"})json"; std::ostringstream response; response << "HTTP/1.1 200 OK\r\n" << "Content-Type: application/json\r\n" - << "Content-Length: " << response_body.size() << "\r\n" + << "Content-Length: " << _response_body.size() << "\r\n" << "Connection: close\r\n\r\n" - << response_body; + << _response_body; const auto response_text = response.str(); boost::asio::write(socket, boost::asio::buffer(response_text), error); } + std::string _response_body; boost::asio::io_context _io; tcp::acceptor _acceptor; uint16_t _port; @@ -225,6 +230,18 @@ std::filesystem::path write_client_configuration_file(fc::temp_directory& direct return path; } +/** Replace one named RPC URL placeholder in a test configuration document. */ +void bind_rpc_url(std::string& configuration, + std::string_view placeholder, + std::string_view rpc_url) { + const auto position = configuration.find(placeholder); + if (position == std::string::npos) { + FC_THROW_EXCEPTION(fc::invalid_arg_exception, + "missing RPC URL placeholder in test configuration"); + } + configuration.replace(position, placeholder.size(), rpc_url); +} + /** Initialize an isolated plugin instance and expose it to a test callback. */ void with_initialized_outpost_plugin( const std::vector& configuration_arguments, @@ -466,13 +483,15 @@ BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { BOOST_AUTO_TEST_CASE(plugin_startup_attaches_unified_client_policies) { fc::temp_directory directory; - const auto path = write_client_configuration_file(directory, R"json({ + chain_id_rpc_server rpc_server_a; + chain_id_rpc_server rpc_server_b{ethereum_mainnet_chain_id_quantity}; + std::string configuration = R"json({ "schema_version": 1, "clients": [{ "connection": { "client_id": "client-a", "signature_provider_id": "signer-a", - "rpc_url": "http://127.0.0.1:1" + "rpc_url": "RPC_URL_A" }, "chain_id": 31337, "transaction_policy": { @@ -485,11 +504,14 @@ BOOST_AUTO_TEST_CASE(plugin_startup_attaches_unified_client_policies) { "connection": { "client_id": "client-b", "signature_provider_id": "signer-a", - "rpc_url": "http://127.0.0.1:1" + "rpc_url": "RPC_URL_B" }, "chain_id": 1 }] - })json"); + })json"; + bind_rpc_url(configuration, rpc_url_placeholder_a, rpc_server_a.url()); + bind_rpc_url(configuration, rpc_url_placeholder_b, rpc_server_b.url()); + const auto path = write_client_configuration_file(directory, configuration); const auto clients = initialize_outpost_plugin( {"--outpost-ethereum-client-config-file", path.string()}); const auto& maximum = maximum_ethereum_transaction_policy_value(); @@ -504,23 +526,48 @@ BOOST_AUTO_TEST_CASE(plugin_startup_attaches_unified_client_policies) { BOOST_AUTO_TEST_CASE(file_configuration_accepts_nonempty_provider_identifier) { fc::temp_directory directory; - const auto path = write_client_configuration_file(directory, R"json({ + chain_id_rpc_server rpc_server; + std::string configuration = R"json({ "schema_version":1, "clients":[{ "connection":{ "client_id":"client-a", "signature_provider_id":"prod/signing", - "rpc_url":"http://127.0.0.1:1" + "rpc_url":"RPC_URL_A" }, "chain_id":31337 }] - })json"); + })json"; + bind_rpc_url(configuration, rpc_url_placeholder_a, rpc_server.url()); + const auto path = write_client_configuration_file(directory, configuration); const auto clients = initialize_outpost_plugin( {"--outpost-ethereum-client-config-file", path.string()}, "prod/signing"); BOOST_REQUIRE_EQUAL(clients.size(), 1u); BOOST_CHECK_EQUAL(clients.front()->id, "client-a"); } +BOOST_AUTO_TEST_CASE(file_configuration_rejects_rpc_chain_id_mismatch) { + fc::temp_directory directory; + chain_id_rpc_server rpc_server; + std::string configuration = R"json({ + "schema_version":1, + "clients":[{ + "connection":{ + "client_id":"client-a", + "signature_provider_id":"signer-a", + "rpc_url":"RPC_URL_A" + }, + "chain_id":1 + }] + })json"; + bind_rpc_url(configuration, rpc_url_placeholder_a, rpc_server.url()); + const auto path = write_client_configuration_file(directory, configuration); + + BOOST_CHECK_THROW( + initialize_outpost_plugin({"--outpost-ethereum-client-config-file", path.string()}), + sysio::chain::plugin_config_exception); +} + BOOST_AUTO_TEST_CASE(file_configuration_rejects_wrong_chain_provider) { fc::temp_directory directory; const auto path = write_client_configuration_file(directory, R"json({ @@ -542,13 +589,14 @@ BOOST_AUTO_TEST_CASE(file_configuration_rejects_wrong_chain_provider) { BOOST_AUTO_TEST_CASE(file_configuration_does_not_publish_a_partial_client_map) { fc::temp_directory directory; - const auto path = write_client_configuration_file(directory, R"json({ + chain_id_rpc_server rpc_server; + std::string configuration = R"json({ "schema_version":1, "clients":[{ "connection":{ "client_id":"valid-first", "signature_provider_id":"signer-a", - "rpc_url":"http://127.0.0.1:1" + "rpc_url":"RPC_URL_A" }, "chain_id":31337 },{ @@ -559,13 +607,16 @@ BOOST_AUTO_TEST_CASE(file_configuration_does_not_publish_a_partial_client_map) { }, "chain_id":1 }] - })json"); + })json"; + bind_rpc_url(configuration, rpc_url_placeholder_a, rpc_server.url()); + const auto path = write_client_configuration_file(directory, configuration); BOOST_CHECK_EQUAL(initialize_rejected_file_and_observe_published_clients(path), 0u); } BOOST_AUTO_TEST_CASE(legacy_client_option_uses_default_policy_with_explicit_chain_id) { + chain_id_rpc_server rpc_server; const auto clients = initialize_outpost_plugin( - {"--outpost-ethereum-client", "client-a,signer-a,http://127.0.0.1:1,31337"}); + {"--outpost-ethereum-client", "client-a,signer-a," + rpc_server.url() + ",31337"}); const auto& maximum = maximum_ethereum_transaction_policy_value(); BOOST_REQUIRE_EQUAL(clients.size(), 1u); BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); @@ -573,8 +624,9 @@ BOOST_AUTO_TEST_CASE(legacy_client_option_uses_default_policy_with_explicit_chai } BOOST_AUTO_TEST_CASE(legacy_client_option_preserves_nonempty_identifier_compatibility) { + chain_id_rpc_server rpc_server; const auto clients = initialize_outpost_plugin( - {"--outpost-ethereum-client", "legacy/client,prod/signing,http://127.0.0.1:1,31337"}, + {"--outpost-ethereum-client", "legacy/client,prod/signing," + rpc_server.url() + ",31337"}, "prod/signing"); BOOST_REQUIRE_EQUAL(clients.size(), 1u); BOOST_CHECK_EQUAL(clients.front()->id, "legacy/client"); @@ -604,17 +656,20 @@ BOOST_AUTO_TEST_CASE(plugin_startup_rejects_mixed_unified_and_legacy_modes) { BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { fc::temp_directory directory; - const auto path = write_client_configuration_file(directory, R"json({ + chain_id_rpc_server rpc_server; + std::string configuration = R"json({ "schema_version": 1, "clients": [{ "connection": { "client_id": "client-a", "signature_provider_id": "signer-a", - "rpc_url": "http://127.0.0.1:1" + "rpc_url": "RPC_URL_A" }, "chain_id": 31337 }] - })json"); + })json"; + bind_rpc_url(configuration, rpc_url_placeholder_a, rpc_server.url()); + const auto path = write_client_configuration_file(directory, configuration); with_initialized_outpost_plugin( {"--outpost-ethereum-client-config-file", path.string()}, 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 e1fe7195c6..951ff30a5d 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 @@ -2,6 +2,8 @@ #include #include +#include +#include #include @@ -180,6 +182,7 @@ constexpr size_t emit_outbound_envelope_call_hex_chars = evm_function_selector_hex_chars + evm_abi_word_hex_chars; constexpr uint64_t test_outpost_chain_code = 1; constexpr uint32_t test_evm_chain_id = 31337; +constexpr size_t transient_chain_id_failures = 1; constexpr uint32_t test_wire_epoch = 7; constexpr uint32_t test_stale_wire_epoch = test_wire_epoch - 1; constexpr uint32_t test_different_wire_epoch = test_wire_epoch + 1; @@ -195,6 +198,76 @@ fc::test::one_shot_http_server chain_id_rpc_server(std::string result_json = "\" "eth_chainId"}; } +/** Loopback RPC fixture that resets one connection before returning a valid chain id. */ +class transient_chain_id_rpc_server { +public: + /** Start the two-attempt fixture on an ephemeral loopback port. */ + transient_chain_id_rpc_server() + : _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) + , _port(_acceptor.local_endpoint().port()) + , _worker([this] { serve(); }) {} + + transient_chain_id_rpc_server(const transient_chain_id_rpc_server&) = delete; + transient_chain_id_rpc_server& operator=(const transient_chain_id_rpc_server&) = delete; + + /** Unblock any outstanding accepts and join the worker. */ + ~transient_chain_id_rpc_server() { + for (size_t attempt = 0; attempt <= transient_chain_id_failures; ++attempt) { + 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(); + } + + /** Return the loopback URL selected for the fixture. */ + std::string url() const { + return "http://127.0.0.1:" + std::to_string(_port); + } + +private: + using tcp = boost::asio::ip::tcp; + + /** Reset the first request, then serve a valid `eth_chainId` response. */ + void serve() { + for (size_t attempt = 0; attempt < transient_chain_id_failures; ++attempt) { + 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::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; + + constexpr std::string_view response_body = + R"json({"jsonrpc":"2.0","id":1,"result":"0x7a69"})json"; + 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); + } + + boost::asio::io_context _io; + tcp::acceptor _acceptor; + uint16_t _port; + std::thread _worker; +}; + /** 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") { @@ -354,18 +427,28 @@ BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_with_wrong_key_type) { }), sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(startup_does_not_probe_rpc_for_explicit_chain_id) { +BOOST_AUTO_TEST_CASE(startup_rejects_explicit_chain_id_mismatch) { auto rpc_server = chain_id_rpc_server(); - BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ + 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_accepts_unavailable_rpc_when_chain_id_is_explicit) { +BOOST_AUTO_TEST_CASE(startup_rejects_unavailable_rpc_after_bounded_grace) { 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_retries_transient_chain_id_transport_failure) { + transient_chain_id_rpc_server rpc_server; BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ "--signature-provider", named_ethereum_signature_provider(), diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp index 036a6d2a0f..9c4055c02c 100644 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp +++ b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp @@ -97,21 +97,14 @@ struct commit_key { friend auto operator<=>(const commit_key&, const commit_key&) = default; }; -/// One registered outpost chain the operator config fails to serve correctly: -/// either no endpoint was configured for it, or the configured endpoint's VM -/// family does not match the registry. Returned by `find_endpoint_coverage_gap`. +/// One outpost chain whose active registry row and configured endpoint disagree. struct endpoint_coverage_gap { - /// Sentinel `config_kind` meaning "no endpoint configured for this chain". - static constexpr int unconfigured = -1; - - uint64_t chain_code = 0; ///< `fc::slug_name::value` of the registered chain. - int registry_kind = 0; ///< raw `ChainKind` integer from the registry. - int config_kind = unconfigured; ///< configured kind, or `unconfigured`. + uint64_t chain_code = 0; ///< `fc::slug_name::value` of the offending chain. + std::optional registry_kind; ///< Registry kind, or empty when inactive/unregistered. + std::optional config_kind; ///< Configured kind, or empty when unconfigured. }; -/// Verify that every registered outpost chain has a configured endpoint of the -/// MATCHING VM family. Returns the first offending chain, or `nullopt` when the -/// config covers the whole registered set exactly. +/// Verify a one-to-one match between active registry chains and configured endpoints. /// /// SEC-13 / WSA-027: the underwriter derives its served set from the on-chain /// registry (`sysio.chains`) but builds its outpost_client handles only from @@ -127,10 +120,14 @@ find_endpoint_coverage_gap(const std::map& registered_kinds, for (const auto& [chain_code, reg_kind] : registered_kinds) { auto it = configured_kinds.find(chain_code); if (it == configured_kinds.end()) - return endpoint_coverage_gap{chain_code, reg_kind, endpoint_coverage_gap::unconfigured}; + return endpoint_coverage_gap{chain_code, reg_kind, std::nullopt}; if (it->second != reg_kind) return endpoint_coverage_gap{chain_code, reg_kind, it->second}; } + for (const auto& [chain_code, config_kind] : configured_kinds) { + if (!registered_kinds.contains(chain_code)) + return endpoint_coverage_gap{chain_code, std::nullopt, config_kind}; + } return std::nullopt; } diff --git a/plugins/underwriter_plugin/src/underwriter_plugin.cpp b/plugins/underwriter_plugin/src/underwriter_plugin.cpp index b9992301ac..908a5a499b 100644 --- a/plugins/underwriter_plugin/src/underwriter_plugin.cpp +++ b/plugins/underwriter_plugin/src/underwriter_plugin.cpp @@ -468,12 +468,6 @@ struct underwriter_plugin::impl { // the link + balance coverage checks know what to look for. read_outpost_registry(); - if (outpost_chain_kinds.empty()) { - elog("underwriter preflight: no outposts registered in sysio.chains::chains — " - "nothing to commit against"); - return false; - } - // -- Check 2: outpost-client wiring covers every active chain -- // // The served set is `outpost_chain_kinds` (ACTIVE non-depot chains only, @@ -496,11 +490,18 @@ struct underwriter_plugin::impl { if (auto gap = underwriter_detail::find_endpoint_coverage_gap( registered_kinds, configured_kinds)) { const auto code_str = fc::slug_name{gap->chain_code}.to_string(); + if (!gap->registry_kind) { + elog("underwriter preflight: configured outpost chain {} has no active " + "sysio.chains::chains row; run activchain for this chain or remove its " + "--underwriter-*-outpost flag", + code_str); + return false; + } // Re-derive the typed ChainKind names from the source maps rather // than reverse-casting the raw ints; the generated `_Name` helper // is the CLAUDE.md-mandated spelling for proto enums. const ChainKind reg_kind = outpost_chain_kinds.at(gap->chain_code); - if (gap->config_kind == underwriter_detail::endpoint_coverage_gap::unconfigured) { + if (!gap->config_kind) { elog("underwriter preflight: active outpost chain {} (kind={}) has no " "--underwriter-eth-outpost / --underwriter-sol-outpost entry; configure " "one endpoint for every active outpost chain", @@ -517,6 +518,12 @@ struct underwriter_plugin::impl { } } + if (outpost_chain_kinds.empty()) { + elog("underwriter preflight: no outposts registered in sysio.chains::chains — " + "nothing to commit against"); + return false; + } + // -- Check 3: authex link coverage per outpost chain -- std::set linked_chains; { @@ -865,17 +872,14 @@ struct underwriter_plugin::impl { // OPP / OPPInbound addresses are left empty. // * SOL client carries the opp-outpost program id; the typed wrapper // exposes `commit_underwrite` directly. - // `external_chain_id` comes from `sysio.chains` (read here so the registry - // caches are warm); a chain configured but not yet in the registry builds - // with id 0 (harmless — no leg references it until it is active). + // `external_chain_id` comes from the matching ACTIVE `sysio.chains` row. + // The preflight above enforces that inverse coverage for both EVM and SVM + // endpoints before either client plugin is asked to build a handle. read_outpost_registry(); try { for (const auto& [chain_code, ep] : outpost_endpoints) { const auto code_str = fc::slug_name{chain_code}.to_string(); - const uint32_t ext_id = [&] { - auto it = outpost_external_chain_ids.find(chain_code); - return it != outpost_external_chain_ids.end() ? it->second : 0u; - }(); + const uint32_t ext_id = outpost_external_chain_ids.at(chain_code); if (ep.kind == ChainKind::CHAIN_KIND_EVM) { outpost_by_chain[chain_code] = eth_plug->create_outpost_client(ep.client_id, chain_code, ext_id, @@ -1097,9 +1101,8 @@ struct underwriter_plugin::impl { // round-trip — the variant carries the symbolic name and `.as()` // recovers the typed value without a string switch. outpost_chain_kinds[chain_code] = obj["kind"].as(); - if (obj.contains("external_chain_id")) - outpost_external_chain_ids[chain_code] = - static_cast(obj["external_chain_id"].as_uint64()); + outpost_external_chain_ids[chain_code] = + static_cast(obj["external_chain_id"].as_uint64()); } } diff --git a/plugins/underwriter_plugin/test/test_underwriter_routing.cpp b/plugins/underwriter_plugin/test/test_underwriter_routing.cpp index 044c980eef..090ba11d13 100644 --- a/plugins/underwriter_plugin/test/test_underwriter_routing.cpp +++ b/plugins/underwriter_plugin/test/test_underwriter_routing.cpp @@ -187,8 +187,9 @@ BOOST_AUTO_TEST_CASE(endpoint_coverage_flags_unconfigured_chain) { const auto gap = find_endpoint_coverage_gap(registered, configured); BOOST_REQUIRE(gap.has_value()); BOOST_CHECK_EQUAL(gap->chain_code, EVM2); - BOOST_CHECK_EQUAL(gap->registry_kind, KIND_EVM); - BOOST_CHECK_EQUAL(gap->config_kind, endpoint_coverage_gap::unconfigured); + BOOST_REQUIRE(gap->registry_kind.has_value()); + BOOST_CHECK_EQUAL(*gap->registry_kind, KIND_EVM); + BOOST_CHECK(!gap->config_kind.has_value()); } BOOST_AUTO_TEST_CASE(endpoint_coverage_flags_wrong_family) { @@ -200,24 +201,32 @@ BOOST_AUTO_TEST_CASE(endpoint_coverage_flags_wrong_family) { const auto gap = find_endpoint_coverage_gap(registered, configured); BOOST_REQUIRE(gap.has_value()); BOOST_CHECK_EQUAL(gap->chain_code, ETH); - BOOST_CHECK_EQUAL(gap->registry_kind, KIND_EVM); - BOOST_CHECK_EQUAL(gap->config_kind, KIND_SVM); + BOOST_REQUIRE(gap->registry_kind.has_value()); + BOOST_REQUIRE(gap->config_kind.has_value()); + BOOST_CHECK_EQUAL(*gap->registry_kind, KIND_EVM); + BOOST_CHECK_EQUAL(*gap->config_kind, KIND_SVM); } -BOOST_AUTO_TEST_CASE(endpoint_coverage_extra_config_is_ok) { - // The operator configured an endpoint for a chain not (yet) registered; - // harmless: no leg references it, so it must NOT be reported as a gap. +BOOST_AUTO_TEST_CASE(endpoint_coverage_flags_configured_inactive_chain) { const std::map registered{{ETH, KIND_EVM}}; const std::map configured{{ETH, KIND_EVM}, {EVM2, KIND_EVM}}; - BOOST_CHECK(!find_endpoint_coverage_gap(registered, configured).has_value()); + const auto gap = find_endpoint_coverage_gap(registered, configured); + BOOST_REQUIRE(gap.has_value()); + BOOST_CHECK_EQUAL(gap->chain_code, EVM2); + BOOST_CHECK(!gap->registry_kind.has_value()); + BOOST_REQUIRE(gap->config_kind.has_value()); + BOOST_CHECK_EQUAL(*gap->config_kind, KIND_EVM); } -BOOST_AUTO_TEST_CASE(endpoint_coverage_empty_registry_has_no_gap) { - // Degenerate: nothing registered -> nothing to cover. (Preflight rejects the - // empty-registry case separately, before this check runs.) +BOOST_AUTO_TEST_CASE(endpoint_coverage_empty_registry_flags_configured_chain) { const std::map registered; const std::map configured{{ETH, KIND_EVM}}; - BOOST_CHECK(!find_endpoint_coverage_gap(registered, configured).has_value()); + const auto gap = find_endpoint_coverage_gap(registered, configured); + BOOST_REQUIRE(gap.has_value()); + BOOST_CHECK_EQUAL(gap->chain_code, ETH); + BOOST_CHECK(!gap->registry_kind.has_value()); + BOOST_REQUIRE(gap->config_kind.has_value()); + BOOST_CHECK_EQUAL(*gap->config_kind, KIND_EVM); } BOOST_AUTO_TEST_SUITE_END() From 7145d9a71b351ac9e0ba73f2fb5f9061cab8af8a Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Thu, 6 Aug 2026 17:25:04 +0000 Subject: [PATCH 10/13] Improve Ethereum startup diagnostics Change-Id: I02eac918ea7fac5376d673e2c45dedb6ad3b3d48 --- .../ethereum/ethereum_transaction_policy.hpp | 1 - .../src/outpost_ethereum_client_plugin.cpp | 86 +++++++++++++++---- .../test/test_ethereum_transaction_policy.cpp | 20 +++-- .../test_outpost_ethereum_client_plugin.cpp | 27 ++++-- 4 files changed, 104 insertions(+), 30 deletions(-) diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp index f22e1bb17c..1cdc853588 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -16,7 +16,6 @@ namespace fc::network::ethereum { /** Stable reason codes emitted when Ethereum transaction policy validation fails. */ enum class ethereum_transaction_policy_reason { - configuration_chain_id_mismatch, configuration_value_invalid, rpc_quantity_invalid, rpc_quantity_out_of_range, 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 49656ade2c..b44762d5c9 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 @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include @@ -27,6 +29,14 @@ constexpr auto chain_id_resolution_max_backoff = fc::seconds(1); constexpr std::string_view legacy_transaction_policy_client_id = "legacy-client"; constexpr auto unavailable_policy_limit = "n/a"; constexpr std::string_view chain_id_resolution_operation = "ethereum-client:eth_chainId"; +constexpr std::string_view outbound_http_failure_prefix = "Outbound HTTP "; +constexpr std::string_view http_status_detail_prefix = "failed with status "; +constexpr size_t http_status_code_width = 3; +constexpr std::string_view retry_budget_exhausted_cause = "retry_budget_exhausted"; +constexpr std::string_view standard_exception_cause = "std_exception"; +constexpr std::string_view invalid_rpc_response_cause = "invalid_rpc_response"; +constexpr std::string_view fc_exception_cause_prefix = "fc_exception:"; +constexpr std::string_view configuration_chain_id_mismatch_reason = "configuration_chain_id_mismatch"; namespace transaction_policy_field { constexpr std::string_view chain_id = "chain_id"; @@ -145,12 +155,44 @@ ethereum_transaction_policy policy_from_configuration(const EvmClientConfigurati client_id); } -/** Report a sanitized startup chain-id lookup failure. */ -[[noreturn]] void throw_chain_id_resolution_failure(const std::string& client_id) { +/** Return a bounded transport category without reflecting response bodies or endpoint credentials. */ +std::string sanitized_chain_id_failure_cause(const fc::exception& failure) { + const auto message = failure.top_message(); + for (const auto failure_kind : magic_enum::enum_values()) { + const auto failure_name = fc::http::failure_kind_name(failure_kind); + const auto marker = std::string(outbound_http_failure_prefix) + std::string(failure_name) + ":"; + const auto marker_position = message.find(marker); + if (marker_position == std::string::npos) continue; + + std::string cause(failure_name); + if (failure_kind == fc::http::failure_kind::http_status) { + const auto status_position = message.find( + http_status_detail_prefix, marker_position + marker.size()); + if (status_position != std::string::npos) { + const auto code_begin = status_position + http_status_detail_prefix.size(); + const auto code_end = std::min(code_begin + http_status_code_width, message.size()); + if (code_end - code_begin == http_status_code_width && + std::ranges::all_of(message.substr(code_begin, http_status_code_width), + [](char digit) { return digit >= '0' && digit <= '9'; })) { + cause += ":" + message.substr(code_begin, http_status_code_width); + } + } + } + return cause; + } + return std::string{fc_exception_cause_prefix} + failure.name(); +} + +/** Report a startup chain-id lookup failure with only sanitized diagnostic context. */ +[[noreturn]] void throw_chain_id_resolution_failure(const std::string& client_id, + const std::string& endpoint, + std::string_view last_failure) { FC_THROW_EXCEPTION(chain::plugin_config_exception, "Unable to resolve or validate chain id for outpost Ethereum client '{}' " - "within the bounded RPC startup grace", - client_id); + "within the bounded RPC startup grace (endpoint={},last_failure={})", + client_id, + endpoint, + last_failure); } /** Return the retry envelope used independently by each startup chain-id probe. */ @@ -167,6 +209,8 @@ uint32_t resolve_rpc_chain_id( const std::string& client_id, const std::string& url, const fc::network::json_rpc::client_options& rpc_options) { + const auto endpoint = fc::http::sanitized_endpoint(fc::url(url)); + std::string last_failure(retry_budget_exhausted_cause); try { fc::task::deadline_scope deadline(fc::time_point::now() + chain_id_resolution_timeout); return fc::task::retry_until( @@ -178,25 +222,34 @@ uint32_t resolve_rpc_chain_id( auto rpc = fc::network::json_rpc::json_rpc_client::create(url, rpc_options); response = rpc.call_idempotent( std::string(ethereum_rpc_method::chain_id), fc::variants{}); - } catch (const fc::exception&) { + } catch (const fc::exception& failure) { + const auto cause = sanitized_chain_id_failure_cause(failure); + const auto total_timeout_cause = + fc::http::failure_kind_name(fc::http::failure_kind::timeout_total); + if (cause != total_timeout_cause || last_failure == retry_budget_exhausted_cause) { + last_failure = cause; + } return std::nullopt; } catch (const std::exception&) { + last_failure = standard_exception_cause; return std::nullopt; } const auto chain_id = fc::network::ethereum::parse_rpc_quantity( response, transaction_policy_field::chain_id); if (chain_id == 0 || chain_id > std::numeric_limits::max()) { - throw_chain_id_resolution_failure(client_id); + throw_chain_id_resolution_failure(client_id, endpoint, invalid_rpc_response_cause); } return chain_id.convert_to(); }); } catch (const chain::plugin_config_exception&) { throw; + } catch (const fc::network::ethereum::ethereum_transaction_policy_exception&) { + throw_chain_id_resolution_failure(client_id, endpoint, invalid_rpc_response_cause); } catch (const fc::exception&) { - throw_chain_id_resolution_failure(client_id); + throw_chain_id_resolution_failure(client_id, endpoint, last_failure); } catch (const std::exception&) { - throw_chain_id_resolution_failure(client_id); + throw_chain_id_resolution_failure(client_id, endpoint, last_failure); } } @@ -503,13 +556,16 @@ outpost_ethereum_client_plugin::create_outpost_client(const std::string& eth_cli const std::string& operator_registry_addr) { const auto entry = my->get_client(eth_client_id); FC_ASSERT(entry, "Unknown ethereum client id: {}", eth_client_id); - if (entry->chain_id != chain_id) { - fc::network::ethereum::throw_transaction_policy_exception( - fc::network::ethereum::ethereum_transaction_policy_reason::configuration_chain_id_mismatch, - transaction_policy_field::chain_id, - std::to_string(chain_id), - std::to_string(entry->chain_id)); - } + const auto chain_name = fc::slug_name{chain_code}.to_string(); + SYS_ASSERT(entry->chain_id == chain_id, + chain::plugin_config_exception, + "Outpost Ethereum client configuration rejected " + "(reason_code={},chain={},client_id={},registry_chain_id={},client_chain_id={})", + configuration_chain_id_mismatch_reason, + chain_name, + eth_client_id, + chain_id, + entry->chain_id); std::vector all_abis; for (const auto& [path, contracts] : my->get_abi_files()) { diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp index 139575bbce..6de0d9221e 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -654,7 +655,7 @@ BOOST_AUTO_TEST_CASE(plugin_startup_rejects_mixed_unified_and_legacy_modes) { sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { +BOOST_AUTO_TEST_CASE(outpost_factory_reports_client_configuration_chain_mismatch) { fc::temp_directory directory; chain_id_rpc_server rpc_server; std::string configuration = R"json({ @@ -674,21 +675,24 @@ BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { with_initialized_outpost_plugin( {"--outpost-ethereum-client-config-file", path.string()}, [&](auto& plugin) { + const auto chain_code = fc::slug_name{"ETH"}.value; try { plugin.create_outpost_client( "client-a", - 1, + chain_code, 1, std::string(contract_address), std::string(contract_address), std::string(contract_address)); BOOST_FAIL("expected client/outpost chain mismatch rejection"); - } catch (const ethereum_transaction_policy_exception& rejection) { - BOOST_CHECK(rejection.reason() == - ethereum_transaction_policy_reason::configuration_chain_id_mismatch); - BOOST_CHECK_EQUAL(rejection.observed(), "1"); - BOOST_REQUIRE(rejection.allowed().has_value()); - BOOST_CHECK_EQUAL(*rejection.allowed(), "31337"); + } catch (const sysio::chain::plugin_config_exception& rejection) { + const auto detail = rejection.to_detail_string(); + BOOST_CHECK(detail.find("reason_code=configuration_chain_id_mismatch") != std::string::npos); + BOOST_CHECK(detail.find("chain=ETH") != std::string::npos); + BOOST_CHECK(detail.find("client_id=client-a") != std::string::npos); + BOOST_CHECK(detail.find("registry_chain_id=1") != std::string::npos); + BOOST_CHECK(detail.find("client_chain_id=31337") != std::string::npos); + BOOST_CHECK(detail.find("transaction policy") == std::string::npos); } }); } 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 951ff30a5d..a0d22a745c 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 @@ -165,6 +165,7 @@ constexpr std::string_view emit_outbound_envelope_abi_name = "emitOutboundEnvelo constexpr std::string_view emit_outbound_envelope_selector = "a3ad9cc3"; constexpr std::string_view test_opp_address = "5FbDB2315678afecb367f032d93F642f64180aa3"; constexpr std::string_view latest_slot_test_rpc_url = "http://127.0.0.1:1"; +constexpr std::string_view http_scheme_prefix = "http://"; constexpr std::string_view latest_slot_test_entry_id = "latest-slot-test"; constexpr std::string_view latest_slot_test_private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; @@ -439,12 +440,26 @@ BOOST_AUTO_TEST_CASE(startup_rejects_explicit_chain_id_mismatch) { BOOST_AUTO_TEST_CASE(startup_rejects_unavailable_rpc_after_bounded_grace) { 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); + const auto safe_endpoint = rpc_server.url(); + const auto sensitive_url = + "http://operator:super-secret@" + safe_endpoint.substr(http_scheme_prefix.size()) + + "/rpc?token=secret"; + try { + initialize_outpost_plugin({ + "--signature-provider", + named_ethereum_signature_provider(), + "--outpost-ethereum-client", + "client-a,signer-a," + sensitive_url + ",31337", + }); + BOOST_FAIL("expected unavailable RPC rejection"); + } catch (const sysio::chain::plugin_config_exception& rejection) { + const auto detail = rejection.to_detail_string(); + BOOST_CHECK(detail.find("client-a") != std::string::npos); + BOOST_CHECK(detail.find("endpoint=" + safe_endpoint) != std::string::npos); + BOOST_CHECK(detail.find("last_failure=io") != std::string::npos); + BOOST_CHECK(detail.find("super-secret") == std::string::npos); + BOOST_CHECK(detail.find("token=secret") == std::string::npos); + } } BOOST_AUTO_TEST_CASE(startup_retries_transient_chain_id_transport_failure) { From 5dc5ce9283d49012c1992b72289e7325ff306192 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 7 Aug 2026 02:37:29 +0000 Subject: [PATCH 11/13] Stabilize Ethereum startup diagnostics Change-Id: I27e1312d354993091e5857847e9ceccbb7292ac3 --- docs/outpost-client-plugins.md | 11 ++++++----- .../src/outpost_ethereum_client_plugin.cpp | 2 +- .../test/test_outpost_ethereum_client_plugin.cpp | 12 +++++++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index c5ca384516..e7a750dfd7 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -32,7 +32,8 @@ resource policy. The signer reference is validated during startup and must identify an explicit Ethereum `--signature-provider`; anonymous signature-provider specs cannot be referenced. File-configured -chain IDs are locally authoritative, so startup does not call `eth_chainId` for them. +chain IDs are locally authoritative for signing, and startup verifies them against `eth_chainId` +reported by the configured RPC endpoint. The legacy option remains available and cannot be combined with the file option: @@ -40,10 +41,10 @@ The legacy option remains available and cannot be combined with the file option: --outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 ``` -The four-field legacy form also treats its chain ID as locally authoritative. The historical -three-field form remains compatible by resolving `eth_chainId` during startup under a shared -five-second deadline. Legacy clients receive `UINT256_MAX` expenditure caps, with the same -compatibility-only warning above. +The four-field legacy form also treats its chain ID as locally authoritative for signing and +verifies it against the configured RPC endpoint. The historical three-field form remains +compatible by resolving `eth_chainId` during startup under a shared five-second deadline. Legacy +clients receive `UINT256_MAX` expenditure caps, with the same compatibility-only warning above. Every signing-capable client enforces its local policy after the transaction is fully assembled and immediately before signing. A rejected transaction is neither signed nor broadcast. 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 b44762d5c9..476bd49bd2 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 @@ -514,7 +514,7 @@ void outpost_ethereum_client_plugin::set_program_options(options_description& cl boost::program_options::value>()->multitoken(), "Legacy outpost Ethereum client spec: " ",,[,]. A three-field spec resolves " - "eth_chainId during startup; a four-field chain id is locally authoritative.") + "eth_chainId during startup; a four-field chain id controls signing and is verified against the endpoint.") (option_name_client_config_file, boost::program_options::value(), "Versioned protobuf-JSON outpost Ethereum client configuration file. Cannot be combined " 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 a0d22a745c..f2bcf52702 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 @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -166,6 +167,8 @@ constexpr std::string_view emit_outbound_envelope_selector = "a3ad9cc3"; constexpr std::string_view test_opp_address = "5FbDB2315678afecb367f032d93F642f64180aa3"; constexpr std::string_view latest_slot_test_rpc_url = "http://127.0.0.1:1"; constexpr std::string_view http_scheme_prefix = "http://"; +/** Prefix identifying the bounded transport category in chain-id startup diagnostics. */ +constexpr std::string_view last_failure_detail_prefix = "last_failure="; constexpr std::string_view latest_slot_test_entry_id = "latest-slot-test"; constexpr std::string_view latest_slot_test_private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; @@ -456,7 +459,14 @@ BOOST_AUTO_TEST_CASE(startup_rejects_unavailable_rpc_after_bounded_grace) { const auto detail = rejection.to_detail_string(); BOOST_CHECK(detail.find("client-a") != std::string::npos); BOOST_CHECK(detail.find("endpoint=" + safe_endpoint) != std::string::npos); - BOOST_CHECK(detail.find("last_failure=io") != std::string::npos); + const auto io_failure = + std::string(last_failure_detail_prefix) + + std::string(fc::http::failure_kind_name(fc::http::failure_kind::io)); + const auto connect_failure = + std::string(last_failure_detail_prefix) + + std::string(fc::http::failure_kind_name(fc::http::failure_kind::connect)); + BOOST_CHECK(detail.find(io_failure) != std::string::npos || + detail.find(connect_failure) != std::string::npos); BOOST_CHECK(detail.find("super-secret") == std::string::npos); BOOST_CHECK(detail.find("token=secret") == std::string::npos); } From a7e5153a6077359e890cff199af2b594d104d394 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 7 Aug 2026 02:57:32 +0000 Subject: [PATCH 12/13] Clarify Ethereum client configuration guidance Change-Id: Id17e276f84607ee9e5085f12eb70a6c24f18b418 --- plugins/batch_operator_plugin/src/batch_operator_plugin.cpp | 4 ++-- .../include/sysio/outpost_ethereum_client_plugin.hpp | 2 +- plugins/underwriter_plugin/src/underwriter_plugin.cpp | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index d52164caf8..4fbfb87cb4 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -600,7 +600,7 @@ struct batch_operator_plugin::impl { // chain's endpoint. auto entry = eth_plug->get_client_by_chain_id(op.chain_id); if (!entry) { - wlog("batch_operator: no unique --outpost-ethereum-client for chain_id {} " + wlog("batch_operator: no unique configured Ethereum client for chain_id {} " "(outpost {}); skipping until one is configured", op.chain_id, fc::slug_name{op.id}.to_string()); continue; @@ -942,7 +942,7 @@ void batch_operator_plugin::set_program_options(options_description& cli, "chain code. Spec: CHAIN_CODE,opp_addr[,opp_inbound_addr]. EVM rows require the OPP " "and OPPInbound contract addresses (0x-hex); SVM rows require only the outpost " "program id (base58). The Ethereum RPC client for a row is selected by matching the " - "row's external_chain_id against the chain ids of the outpost-ethereum-client specs; " + "row's external_chain_id against the chain ids of the configured Ethereum clients; " "an active row with no binding or no matching client is skipped (fail closed)."); } 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 201e30164b..2a29098b3e 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 @@ -127,7 +127,7 @@ class outpost_ethereum_client_plugin : public appbase::plugin,,,` — " "chain_code is the sysio.chains codename (e.g. ETHEREUM); client_id names the RPC " - "connection registered via --outpost-ethereum-client; operator_registry_addr is the OPP " + "connection of a configured Ethereum client; operator_registry_addr is the OPP " "OperatorRegistry (uw_commit target); source_deposit_contract_addr is the SwapDeposit-" "emitting contract scanned by the verify path. SEC-13/WSA-027: keyed by exact chain_code, " "so two EVM chains are wired independently."); From c694c7c89f52c7d9112894085d07d92444aed22b Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 7 Aug 2026 14:48:35 +0000 Subject: [PATCH 13/13] Clarify per-client chain ID startup budget Change-Id: Ic80a99c56d6de04a24c6bb38994bc849905b32d5 --- docs/outpost-client-plugins.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index e7a750dfd7..6aeb5f6447 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -43,8 +43,10 @@ The legacy option remains available and cannot be combined with the file option: The four-field legacy form also treats its chain ID as locally authoritative for signing and verifies it against the configured RPC endpoint. The historical three-field form remains -compatible by resolving `eth_chainId` during startup under a shared five-second deadline. Legacy -clients receive `UINT256_MAX` expenditure caps, with the same compatibility-only warning above. +compatible by resolving `eth_chainId` during startup. File-configured, four-field legacy, and +three-field legacy clients all require a reachable RPC endpoint at startup; each client receives +an independent five-second budget for chain ID verification or resolution. Legacy clients receive +`UINT256_MAX` expenditure caps, with the same compatibility-only warning above. Every signing-capable client enforces its local policy after the transaction is fully assembled and immediately before signing. A rejected transaction is neither signed nor broadcast.