diff --git a/docs/ethereum-client-config.example.json b/docs/ethereum-client-config.example.json new file mode 100644 index 0000000000..968093ef24 --- /dev/null +++ b/docs/ethereum-client-config.example.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "clients": [ + { + "connection": { + "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/outpost-client-plugins.md b/docs/outpost-client-plugins.md index 5dbab71a4d..6aeb5f6447 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -4,13 +4,22 @@ ## Configuration -The Ethereum client plugin is configured via program options as follows: +The preferred Ethereum client configuration is a versioned protobuf-JSON file: ```sh --signature-provider "eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" ---outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 +--outpost-ethereum-client-config-file /etc/wire/ethereum-client-config.json ``` +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`, 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. + HTTPS endpoints use system CA roots and mandatory DNS/IP identity verification. Private PKI can be added with `--outpost-ethereum-additional-ca-file` or `--outpost-ethereum-additional-ca-path`; an explicit proxy can be set with `--outpost-ethereum-proxy`. The equivalent Solana options are @@ -21,13 +30,26 @@ resource policy. > NOTE: If you look closely, the reference to `eth-01` in the Ethereum client config, matches the signature provider configured for `Ethereum`. This mapping is what enables `1..n` clients in a single process -The signer reference is validated during startup. Each -`--outpost-ethereum-client` must reference the explicit, non-empty name of a -configured `--signature-provider`; anonymous signature-provider specs cannot be -referenced by an Ethereum client. When the optional Ethereum chain ID is -present, startup also calls `eth_chainId` on the configured RPC endpoint and -fails if the endpoint is unavailable, returns an invalid value, or reports a -different chain ID. +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 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: + +```sh +--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 for signing and +verifies it against the configured RPC endpoint. The historical three-field form remains +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. 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/CMakeLists.txt b/libraries/libfc/CMakeLists.txt index 6d3c07407a..d9ea78ef5b 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/http/http_client_legacy.cpp src/network/json_rpc/json_rpc_client.cpp diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp index a7d5f2f96d..fd0b28928e 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -328,20 +329,23 @@ 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 * @param rpc_options authenticated transport and bounded request policy */ ethereum_client(const signature_provider_ptr& sig_provider, const std::variant& url_source, - std::optional chain_id = std::nullopt, + ethereum_transaction_policy transaction_policy, client_options rpc_options = {}); + /** Virtual destructor supporting deterministic RPC fakes in client tests. */ + virtual ~ethereum_client() = default; + /** * @brief General method to send RPC requests. * @param method The name of the RPC method to call (e.g., "eth_blockNumber"). * @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); /** * @brief Execute an explicitly read-only RPC with stale-connection recovery. @@ -349,7 +353,7 @@ class ethereum_client : public std::enable_shared_from_this { * The request may be replayed once only when an existing cached connection * proves stale. Transaction-submission methods must use `execute`. */ - fc::variant execute_idempotent(const std::string& method, const fc::variant& params); + virtual fc::variant execute_idempotent(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); @@ -525,10 +529,10 @@ 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(); + fc::uint256 get_chain_id() const; /** * @brief Retrieves the version of the connected Ethereum network. @@ -556,6 +560,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 * @@ -590,6 +597,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 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 */ @@ -605,10 +619,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 authoritative 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..1cdc853588 --- /dev/null +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -0,0 +1,141 @@ +#pragma once + +#include +#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_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 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); + +/** + * 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; +}; + +/** 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; + uint32_t 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; +}; + +/** 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. + * + * 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/include/fc/network/http/http_client.hpp b/libraries/libfc/include/fc/network/http/http_client.hpp index 64dcfc9188..c69ae4d806 100644 --- a/libraries/libfc/include/fc/network/http/http_client.hpp +++ b/libraries/libfc/include/fc/network/http/http_client.hpp @@ -106,6 +106,9 @@ inline constexpr size_t failure_kind_count = magic_enum::enum_count& url_source, - std::optional chain_id, + ethereum_transaction_policy transaction_policy, client_options rpc_options) : _signature_provider(sig_provider) , _address(to_address(_signature_provider->public_key)) , _client(json_rpc_client::create(url_source, std::move(rpc_options))) - , _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 @@ -103,6 +124,34 @@ fc::variant ethereum_client::execute_idempotent(const std::string& method, const return _client.call_idempotent(method, params); } +/** 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()), + _transaction_policy.client_id, + _transaction_policy.chain_id, + safe_operation_type, + rejection.field(), + rejection.observed(), + rejection.allowed().value_or(unavailable_policy_limit)); +} + /** * @brief Executes a contract view function (read-only call) * @@ -142,6 +191,14 @@ 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)); + + try { + validate_transaction_against_policy(_transaction_policy, tx); + } catch (const ethereum_transaction_policy_exception& rejection) { + log_transaction_rejection(rejection, abi.name); + throw; + } + auto tx_encoded = rlp::encode_eip1559_unsigned_typed(tx); if (sign) { fc::crypto::eth_client_signer signer(*_signature_provider); @@ -181,30 +238,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_idempotent("eth_getTransactionCount", params); - dlog("tx_count: {}", res.as_string()); - return to_uint256(res); + return parse_rpc_quantity(res, transaction_policy_field::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_idempotent("eth_chainId", params)); - - return _chain_id.value(); +fc::uint256 ethereum_client::get_chain_id() const { + return _transaction_policy.chain_id; } /** @@ -251,22 +297,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_transaction_rejection(rejection, contract.name); + throw; + } } /** @@ -369,9 +419,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(); + 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, + transaction_policy_field::base_fee_per_gas, + missing_rpc_value); + } + return parse_rpc_quantity(block[base_fee_field], transaction_policy_field::base_fee_per_gas); } /** @@ -386,7 +440,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 resp.as_uint256(); + return parse_rpc_quantity(resp, transaction_policy_field::max_priority_fee_per_gas); } /** @@ -406,7 +460,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 resp.as_uint256(); + return parse_rpc_quantity(resp, transaction_policy_field::estimated_gas); } /** @@ -420,9 +474,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_transaction_rejection(rejection, gas_configuration_operation); + 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, @@ -449,25 +513,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)) + (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 resp.as_uint256(); + return parse_rpc_quantity(resp, transaction_policy_field::estimated_gas); } /** @@ -482,7 +540,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 resp.as_uint256(); + 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 new file mode 100644 index 0000000000..f673146b2a --- /dev/null +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -0,0 +1,382 @@ +#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; +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)) + truncated_diagnostic_suffix; +} + +/** 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 + +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) { + const bool ascii_alphanumeric = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z'); + return ascii_alphanumeric || c == '-' || c == '_' || c == '.'; + }); +} + +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, + 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(unavailable_allowed_value)), + 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; +} + +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 = [&] { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + field, + numeric_value ? diagnostic_value(value) : diagnostic_observation::invalid); + }; + + 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()) { + throw_transaction_policy_exception( + 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, + diagnostic_observation::invalid); + } + + 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')) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, + field, + 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, + 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)) { + throw_transaction_policy_exception( + 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, + 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, + 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, + 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, + 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, + 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, + policy_field::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) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + policy_field::max_priority_fee_per_gas, + priority_fee.str(), + policy.max_priority_fee_per_gas.str()); + } + + 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, + 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; + 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, + 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, + 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; +} + +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, + policy_field::estimated_gas, + estimated_gas.str(), + policy.max_gas_limit.str()); + } + + 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, + 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, + policy_field::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 != fc::uint256{policy.chain_id}) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::chain_id_mismatch, + 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, + 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, + 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, + 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, + policy_field::gas_limit, + transaction.gas_limit.str(), + policy.max_gas_limit.str()); + } + + const fc::uint256 maximum_fee_without_overflow = + 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, + 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; + 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, + 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, + policy_field::max_total_native_cost, + maximum_total_cost.str(), + policy.max_total_native_cost.str()); + } +} + +} // namespace fc::network::ethereum diff --git a/libraries/libfc/src/network/http/http_client.cpp b/libraries/libfc/src/network/http/http_client.cpp index ec0225f864..f84520099d 100644 --- a/libraries/libfc/src/network/http/http_client.cpp +++ b/libraries/libfc/src/network/http/http_client.cpp @@ -235,7 +235,7 @@ constexpr uint32_t status_value(beast_http::status status) { } /** Return whether @p host is safe to serialize into an HTTP authority and TLS identity. */ -bool is_safe_network_host(std::string_view host) { +bool is_safe_network_host_impl(std::string_view host) { if (host.empty()) return false; @@ -1280,6 +1280,10 @@ struct async_resolution_state { } // namespace +bool is_safe_network_host(std::string_view host) { + return is_safe_network_host_impl(host); +} + void detail::post_platform_resolver_worker_task_for_testing( size_t worker, std::function task) { diff --git a/libraries/libfc/test/CMakeLists.txt b/libraries/libfc/test/CMakeLists.txt index a0205e553b..eb1ddcb995 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_http_client.cpp network/test_json_rpc_client.cpp network/solana/test_solana_client.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..8a9e24d3ae --- /dev/null +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -0,0 +1,322 @@ +#include + +#include + +#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"; + +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", + .chain_id = 31337, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .max_gas_limit = 1000, + .max_total_native_cost = 100000, + }; +} + +/** 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{ + .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, + }; +} + +/** Return a transaction that exactly matches the compact bounded policy. */ +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 = {}, + }; +} + +/** 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 { + 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(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 { + 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(insufficient_dynamic_base_fee_headroom_reports_formula) { + 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"); + 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"); + } +} + +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/libraries/opp/CMakeLists.txt b/libraries/opp/CMakeLists.txt index b7590ba3c4..7773bb2d36 100644 --- a/libraries/opp/CMakeLists.txt +++ b/libraries/opp/CMakeLists.txt @@ -1,4 +1,6 @@ # opp: library +add_subdirectory(client_config) + set(TARGET_NAME opp) set(TARGET_MODELS_NAME "${TARGET_NAME}_models") set(TARGET_CDT_MODELS_NAME "${TARGET_NAME}_cdt_models") diff --git a/libraries/opp/client_config/CMakeLists.txt b/libraries/opp/client_config/CMakeLists.txt new file mode 100644 index 0000000000..a397e648f3 --- /dev/null +++ b/libraries/opp/client_config/CMakeLists.txt @@ -0,0 +1,50 @@ +set(TARGET_MODELS_NAME opp_client_config_models) +set(TARGET_NAME opp_client_config) +set(PROTO_GEN_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated) + +file(GLOB_RECURSE PROTO_FILES CONFIGURE_DEPENDS proto/*.proto) + +add_library(${TARGET_MODELS_NAME} STATIC) +target_link_libraries(${TARGET_MODELS_NAME} PUBLIC protobuf::libprotobuf) + +file(MAKE_DIRECTORY ${PROTO_GEN_DIR}) +protobuf_generate( + TARGET ${TARGET_MODELS_NAME} + PROTOS ${PROTO_FILES} + IMPORT_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/proto + PROTOC_OUT_DIR ${PROTO_GEN_DIR} +) +target_include_directories( + ${TARGET_MODELS_NAME} + PUBLIC + $ + $ +) + +file(GLOB_RECURSE SRC_FILES CONFIGURE_DEPENDS src/*.cpp) +file(GLOB_RECURSE HEADER_FILES CONFIGURE_DEPENDS include/*.hpp) +add_library(${TARGET_NAME} STATIC ${SRC_FILES} ${HEADER_FILES}) +target_include_directories( + ${TARGET_NAME} + PUBLIC + $ + $ +) +target_link_libraries( + ${TARGET_NAME} + PUBLIC + ${TARGET_MODELS_NAME} + fc +) + +if(ENABLE_TESTS) + file(GLOB_RECURSE TEST_FILES CONFIGURE_DEPENDS test/*.cpp) + set(TEST_TARGET_NAME test_opp_client_config) + add_executable(${TEST_TARGET_NAME} ${TEST_FILES}) + target_link_libraries(${TEST_TARGET_NAME} PRIVATE ${TARGET_NAME}) + add_test( + NAME ${TEST_TARGET_NAME} + COMMAND ${CMAKE_CURRENT_BINARY_DIR}/${TEST_TARGET_NAME} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) +endif() 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 new file mode 100644 index 0000000000..c7166a95e1 --- /dev/null +++ b/libraries/opp/client_config/include/sysio/opp/config/client_config_loader.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace google::protobuf { +class Message; +} + +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; + +/** Stable reason codes for sanitized client-configuration failures. */ +enum class client_config_reason { + file_unreadable, + file_too_large, + proto_json_invalid, + schema_version_unsupported, + client_missing, + connection_missing, + client_identifier_invalid, + signature_provider_identifier_invalid, + rpc_url_invalid, + chain_id_invalid, + client_duplicate, + policy_incomplete, + policy_value_invalid, + policy_fee_relationship_invalid, + chain_id_duplicate, +}; + +/** Return the stable log spelling of a client-configuration reason. */ +std::string_view client_config_reason_name(client_config_reason reason); + +/** Sanitized structured failure raised while loading host client configuration. */ +class client_config_exception : public fc::exception { +public: + /** Construct a client-configuration failure without embedding raw document content. */ + client_config_exception(client_config_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 retaining its dynamic type. */ + void rethrow() const override; + + /** Stable machine-readable rejection reason. */ + client_config_reason reason() const { return _reason; } + + /** Schema field associated with the rejection. */ + const std::string& field() const { return _field; } + + /** Sanitized observed value. */ + const std::string& observed() const { return _observed; } + + /** Sanitized accepted value or range, when applicable. */ + const std::optional& allowed() const { return _allowed; } + +private: + client_config_reason _reason; + std::string _field; + std::string _observed; + std::optional _allowed; +}; + +/** + * Load bounded JSON into a generated protobuf message using strict 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); + +/** Validate semantic invariants of a generated Ethereum client configuration. */ +void validate_evm_client_configuration(const EvmClientConfigurationFile& configuration); + +/** Load and semantically validate a version-1 Ethereum client configuration file. */ +EvmClientConfigurationFile +load_evm_client_configuration_file(const std::filesystem::path& configuration_file); + +} // namespace sysio::opp::config diff --git a/libraries/opp/client_config/proto/sysio/opp/config/client_config.proto b/libraries/opp/client_config/proto/sysio/opp/config/client_config.proto new file mode 100644 index 0000000000..5783a07908 --- /dev/null +++ b/libraries/opp/client_config/proto/sysio/opp/config/client_config.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package sysio.opp.config; + +// One signing-capable client connection. Key material remains in the separately +// configured signature provider and is never stored in this document. +message ClientConnection { + optional string client_id = 1; + optional string signature_provider_id = 2; + optional string rpc_url = 3; +} + +// Absolute local expenditure limits for a complete EIP-1559 transaction. +message EvmTransactionPolicy { + optional string max_priority_fee_per_gas_wei = 1; + optional string max_fee_per_gas_wei = 2; + optional string max_gas_limit = 3; + optional string max_total_native_cost_wei = 4; +} + +// One EVM signing client. Future keyless witness endpoints must use a distinct +// endpoint message rather than overloading the signing ClientConnection. +message EvmClientConfiguration { + optional ClientConnection connection = 1; + optional uint32 chain_id = 2; + optional EvmTransactionPolicy transaction_policy = 3; +} + +// Versioned Ethereum client configuration owned by the Ethereum outpost plugin. +message EvmClientConfigurationFile { + optional uint32 schema_version = 1; + repeated EvmClientConfiguration clients = 2; +} diff --git a/libraries/opp/client_config/src/client_config_loader.cpp b/libraries/opp/client_config/src/client_config_loader.cpp new file mode 100644 index 0000000000..8040ae67d3 --- /dev/null +++ b/libraries/opp/client_config/src/client_config_loader.cpp @@ -0,0 +1,279 @@ +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace sysio::opp::config { + +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_view field, + std::string observed, + std::optional allowed = std::nullopt) { + throw client_config_exception(reason, + std::string(field), + std::move(observed), + std::move(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); + if (!input.is_open()) { + throw_client_config_failure(client_config_reason::file_unreadable, + 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_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_field::configuration_file, + std::to_string(size), + std::to_string(max_client_configuration_file_size)); + } + + std::string contents(static_cast(size), '\0'); + input.seekg(0); + if (!contents.empty() && !input.read(contents.data(), static_cast(contents.size()))) { + throw_client_config_failure(client_config_reason::file_unreadable, + 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); + } catch (const fc::network::ethereum::ethereum_transaction_policy_exception& rejection) { + throw_client_config_failure(client_config_reason::policy_value_invalid, + rejection.field(), + rejection.observed(), + rejection.allowed()); + } +} + +/** 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, + configuration_field::rpc_url, + diagnostic_observation::invalid); + } + try { + const fc::url parsed{std::string(raw_url)}; + 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, + configuration_field::rpc_url, + diagnostic_observation::invalid); +} + +} // namespace + +std::string_view client_config_reason_name(client_config_reason reason) { + return magic_enum::enum_name(reason); +} + +client_config_exception::client_config_exception(client_config_reason reason, + std::string field, + std::string observed, + std::optional allowed) + : fc::exception(FC_LOG_MESSAGE(error, + "reason_code={} field={} observed={} allowed={}", + client_config_reason_name(reason), + field, + observed, + allowed.value_or(unavailable_allowed_value)), + fc::invalid_arg_exception_code, + "client_config_exception", + "Client configuration rejected") + , _reason(reason) + , _field(std::move(field)) + , _observed(std::move(observed)) + , _allowed(std::move(allowed)) {} + +std::shared_ptr client_config_exception::dynamic_copy_exception() const { + return std::make_shared(*this); +} + +void client_config_exception::rethrow() const { + throw *this; +} + +void load_client_configuration_json(const std::filesystem::path& configuration_file, + google::protobuf::Message& destination) { + const auto contents = read_bounded_configuration_file(configuration_file); + + destination.Clear(); + google::protobuf::util::JsonParseOptions options; + options.ignore_unknown_fields = false; + const auto status = google::protobuf::util::JsonStringToMessage(contents, &destination, options); + if (!status.ok()) { + throw_client_config_failure(client_config_reason::proto_json_invalid, + configuration_field::document, + diagnostic_observation::invalid_proto_json); + } +} + +void validate_evm_client_configuration(const EvmClientConfigurationFile& configuration) { + if (!configuration.has_schema_version() || + configuration.schema_version() != supported_schema_version) { + throw_client_config_failure(client_config_reason::schema_version_unsupported, + 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, + configuration_field::clients, + diagnostic_observation::empty); + } + + 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, + 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, + configuration_field::client_id, + diagnostic_observation::invalid); + } + if (!client_ids.insert(connection.client_id()).second) { + throw_client_config_failure(client_config_reason::client_duplicate, + 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, + 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, + 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, + configuration_field::chain_id, + client.has_chain_id() ? diagnostic_observation::zero + : 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(); + const bool complete = + policy.has_max_priority_fee_per_gas_wei() && + !policy.max_priority_fee_per_gas_wei().empty() && + policy.has_max_fee_per_gas_wei() && + !policy.max_fee_per_gas_wei().empty() && + policy.has_max_gas_limit() && + !policy.max_gas_limit().empty() && + policy.has_max_total_native_cost_wei() && + !policy.max_total_native_cost_wei().empty(); + if (!complete) { + throw_client_config_failure(client_config_reason::policy_incomplete, + configuration_field::transaction_policy, + diagnostic_observation::incomplete); + } + + const auto maximum_priority_fee = parse_policy_value( + 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(), 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, + configuration_field::max_priority_fee_per_gas_wei, + maximum_priority_fee.str(), + maximum_fee.str()); + } + } +} + +EvmClientConfigurationFile +load_evm_client_configuration_file(const std::filesystem::path& configuration_file) { + EvmClientConfigurationFile configuration; + load_client_configuration_json(configuration_file, configuration); + validate_evm_client_configuration(configuration); + return configuration; +} + +} // namespace sysio::opp::config diff --git a/libraries/opp/client_config/test/main.cpp b/libraries/opp/client_config/test/main.cpp new file mode 100644 index 0000000000..6408e2a9d2 --- /dev/null +++ b/libraries/opp/client_config/test/main.cpp @@ -0,0 +1,2 @@ +#define BOOST_TEST_MODULE opp_client_config +#include diff --git a/libraries/opp/client_config/test/test_client_config_loader.cpp b/libraries/opp/client_config/test/test_client_config_loader.cpp new file mode 100644 index 0000000000..a7e5adb7b1 --- /dev/null +++ b/libraries/opp/client_config/test/test_client_config_loader.cpp @@ -0,0 +1,282 @@ +#include + +#include + +#include + +#include +#include +#include + +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 = 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())); + output.close(); + 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); + try { + (void) config::load_evm_client_configuration_file(path); + BOOST_FAIL("expected configuration rejection"); + } catch (const config::client_config_exception& rejection) { + return rejection.reason(); + } + 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": [{ + "connection": { + "client_id": "ethereum-mainnet", + "signature_provider_id": "signer-a", + "rpc_url": "https://rpc.example.invalid/v1" + }, + "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" + } + }, { + "connection": { + "client_id": "ethereum-sepolia", + "signature_provider_id": "signer-b", + "rpc_url": "http://127.0.0.1:8545" + }, + "chain_id": 11155111 + }] +})json"; + +} // namespace + +BOOST_AUTO_TEST_SUITE(opp_client_config_loader_tests) + +BOOST_AUTO_TEST_CASE(loads_generated_model_with_optional_policy) { + fc::temp_directory directory; + const auto configuration = config::load_evm_client_configuration_file( + write_configuration(directory, valid_configuration)); + + BOOST_REQUIRE(configuration.has_schema_version()); + BOOST_CHECK_EQUAL(configuration.schema_version(), 1u); + BOOST_REQUIRE_EQUAL(configuration.clients_size(), 2); + BOOST_CHECK_EQUAL(configuration.clients(0).connection().client_id(), "ethereum-mainnet"); + BOOST_CHECK_EQUAL(configuration.clients(0).chain_id(), 1u); + BOOST_REQUIRE(configuration.clients(0).has_transaction_policy()); + BOOST_CHECK_EQUAL(configuration.clients(0).transaction_policy().max_gas_limit(), "2000000"); + BOOST_CHECK(!configuration.clients(1).has_transaction_policy()); +} + +BOOST_AUTO_TEST_CASE(accepts_transport_valid_bracketed_ipv6_host) { + fc::temp_directory directory; + const auto path = write_configuration(directory, R"json({ + "schema_version":1, + "clients":[{ + "connection":{"client_id":"a","signature_provider_id":"prod/signing","rpc_url":"http://[::1]:8545"}, + "chain_id":1 + }] + })json"); + BOOST_CHECK_NO_THROW(config::load_evm_client_configuration_file(path)); +} + +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"); + BOOST_FAIL("expected unreadable file rejection"); + } catch (const config::client_config_exception& rejection) { + BOOST_CHECK(rejection.reason() == config::client_config_reason::file_unreadable); + } + + const std::string oversized(config::max_client_configuration_file_size + 1, ' '); + try { + (void) config::load_evm_client_configuration_file( + write_configuration(directory, oversized, "oversized.json")); + BOOST_FAIL("expected oversized file rejection"); + } catch (const config::client_config_exception& rejection) { + BOOST_CHECK(rejection.reason() == config::client_config_reason::file_too_large); + } + + 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::proto_json_invalid); +} + +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::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) { + BOOST_CHECK(rejection_reason(R"json({"schema_version":2,"clients":[]})json") == + config::client_config_reason::schema_version_unsupported); + BOOST_CHECK(rejection_reason(R"json({"schema_version":1,"clients":[]})json") == + config::client_config_reason::client_missing); + BOOST_CHECK(rejection_reason(R"json({"schema_version":1,"clients":[{"chain_id":1}]})json") == + config::client_config_reason::connection_missing); + BOOST_CHECK(rejection_reason(R"json({ + "schema_version":1, + "clients":[{ + "connection":{"client_id":"bad/id","signature_provider_id":"s","rpc_url":"http://localhost"}, + "chain_id":1 + }] + })json") == config::client_config_reason::client_identifier_invalid); + BOOST_CHECK(rejection_reason(R"json({ + "schema_version":1, + "clients":[{ + "connection":{"client_id":"a","signature_provider_id":"","rpc_url":"http://localhost"}, + "chain_id":1 + }] + })json") == config::client_config_reason::signature_provider_identifier_invalid); + BOOST_CHECK(rejection_reason(R"json({ + "schema_version":1, + "clients":[{ + "connection":{"client_id":"a","signature_provider_id":"s","rpc_url":"file:///tmp/rpc"}, + "chain_id":1 + }] + })json") == config::client_config_reason::rpc_url_invalid); + BOOST_CHECK(rejection_reason(R"json({ + "schema_version":1, + "clients":[{ + "connection":{"client_id":"a","signature_provider_id":"s","rpc_url":"http://#"}, + "chain_id":1 + }] + })json") == config::client_config_reason::rpc_url_invalid); + BOOST_CHECK(rejection_reason(R"json({ + "schema_version":1, + "clients":[{ + "connection":{"client_id":"a","signature_provider_id":"s","rpc_url":"http://host:80:90"}, + "chain_id":1 + }] + })json") == config::client_config_reason::rpc_url_invalid); + BOOST_CHECK(rejection_reason(R"json({ + "schema_version":1, + "clients":[{ + "connection":{"client_id":"a","signature_provider_id":"s","rpc_url":"http://example.invalid#secret"}, + "chain_id":1 + }] + })json") == config::client_config_reason::rpc_url_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":0 + }] + })json") == config::client_config_reason::chain_id_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}, + {"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) { + 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, + "transaction_policy":{"max_fee_per_gas_wei":"10"} + }] + })json") == config::client_config_reason::policy_incomplete); + 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, + "transaction_policy":{ + "max_priority_fee_per_gas_wei":"01", + "max_fee_per_gas_wei":"10", + "max_gas_limit":"100", + "max_total_native_cost_wei":"1000" + } + }] + })json") == config::client_config_reason::policy_value_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, + "transaction_policy":{ + "max_priority_fee_per_gas_wei":"11", + "max_fee_per_gas_wei":"10", + "max_gas_limit":"100", + "max_total_native_cost_wei":"1000" + } + }] + })json") == config::client_config_reason::policy_fee_relationship_invalid); +} + +BOOST_AUTO_TEST_CASE(redacts_unknown_fields_and_urls_from_failures) { + constexpr std::string_view secret = "https://user:password@example.invalid/rpc?token=secret"; + fc::temp_directory directory; + const auto path = write_configuration(directory, R"json({ + "schema_version":1, + "clients":[{ + "connection":{ + "client_id":"a", + "signature_provider_id":"s", + "rpc_url":"https://user:password@example.invalid/rpc?token=secret" + }, + "chain_id":1 + }], + "unknown":true + })json"); + try { + (void) config::load_evm_client_configuration_file(path); + BOOST_FAIL("expected unknown-field rejection"); + } catch (const config::client_config_exception& rejection) { + BOOST_CHECK(rejection.observed().find(secret) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(secret) == std::string::npos); + } +} + +BOOST_AUTO_TEST_SUITE_END() 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/CMakeLists.txt b/plugins/outpost_ethereum_client_plugin/CMakeLists.txt index 7b42dab968..9857ba67b5 100644 --- a/plugins/outpost_ethereum_client_plugin/CMakeLists.txt +++ b/plugins/outpost_ethereum_client_plugin/CMakeLists.txt @@ -5,6 +5,7 @@ plugin_target( ${TARGET_LIB_NAME} LIBRARIES chain_plugin + opp_client_config outpost_client_plugin signature_provider_manager_plugin ) 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 5b55f4607f..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 @@ -12,14 +12,13 @@ using namespace fc::network::ethereum; struct ethereum_client_entry_t { std::string id; - std::string url; fc::crypto::signature_provider_ptr signature_provider; ethereum_client_ptr client; - /// Numeric EVM chain id from the client spec's optional 4th field. Lets the + /// Authoritative numeric EVM chain id. 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; + /// remote endpoint. + uint32_t chain_id; }; using ethereum_client_entry_ptr = std::shared_ptr; @@ -102,7 +101,7 @@ 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 + /// Return the single configured client whose authoritative 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 @@ -127,8 +126,8 @@ class outpost_ethereum_client_plugin : public appbase::plugin -#include +#include + +#include +#include +#include +#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 chain_id_validation_timeout = fc::seconds(5); -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", - .proxy = "outpost-ethereum-proxy", - }; + +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 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"; +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"; +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", + .proxy = "outpost-ethereum-proxy", +}; [[maybe_unused]] inline fc::logger& logger() { static fc::logger log{"outpost_ethereum_client_plugin"}; return log; } +using client_map = std::map; +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_chain_id(std::string_view text) { - if (text.empty()) { - return std::nullopt; - } +std::optional parse_legacy_chain_id(std::string_view text) { + if (text.empty()) return std::nullopt; uint32_t base = 10; size_t offset = 0; @@ -42,14 +82,12 @@ std::optional parse_chain_id(std::string_view text) { base = 16; offset = 2; } - if (offset == text.size()) { - return std::nullopt; - } + if (offset == text.size()) return std::nullopt; uint32_t value = 0; for (; offset < text.size(); ++offset) { const char c = text[offset]; - uint32_t digit; + uint32_t digit = 0; if (c >= '0' && c <= '9') { digit = static_cast(c - '0'); } else if (c >= 'a' && c <= 'f') { @@ -64,245 +102,431 @@ std::optional parse_chain_id(std::string_view text) { } value = value * base + digit; } - return value == 0 ? std::nullopt : std::optional{value}; } -/** Report a neutral configuration error after client construction fails. */ +/** 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{ + .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, + }; +} + +/** Preserve legacy client names while keeping the new structured policy log label safe. */ +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 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()) { + return maximum_policy(connection.client_id(), client.chain_id()); + } + + const auto& policy = client.transaction_policy(); + return ethereum_transaction_policy{ + .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(), 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(), transaction_policy_field::max_fee_per_gas_wei), + .max_gas_limit = fc::network::ethereum::parse_canonical_uint256_decimal( + 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(), 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); + FC_THROW_EXCEPTION(chain::plugin_config_exception, + "Failed to initialize outpost Ethereum client '{}'", + client_id); } -/** Report a stable configuration error when the explicit chain ID cannot be verified. */ -[[noreturn]] void throw_chain_id_validation_failure(const std::string& client_id) { - FC_THROW_EXCEPTION( - chain::plugin_config_exception, - "Unable to validate chain id for outpost Ethereum client '{}': " - "the configured RPC endpoint did not return a valid eth_chainId", - client_id); +/** 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(); } -/** Construct the client and verify any explicit chain id within one startup deadline. */ -ethereum_client_ptr create_validated_client( - const std::string& client_id, - const fc::crypto::signature_provider_ptr& signature_provider, - const std::string& url, - const std::optional& configured_chain_id, +/** 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 (endpoint={},last_failure={})", + client_id, + endpoint, + last_failure); +} + +/** 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) { - fc::task::deadline_scope deadline(fc::time_point::now() + chain_id_validation_timeout); + 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( + 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& 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, 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, endpoint, last_failure); + } catch (const std::exception&) { + throw_chain_id_resolution_failure(client_id, endpoint, last_failure); + } +} - ethereum_client_ptr client; +/** 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, + const std::string& signature_provider_id) { + SYS_ASSERT(signature_provider_manager.is_explicitly_configured_provider(signature_provider_id), + chain::plugin_config_exception, + "Outpost Ethereum client '{}' references an unavailable explicitly named signature provider", + client_id); + + const auto provider = signature_provider_manager.get_provider(signature_provider_id); + SYS_ASSERT(provider->target_chain == fc::crypto::chain_kind_ethereum && + provider->key_type == fc::crypto::chain_key_type_ethereum, + chain::plugin_config_exception, + "Outpost Ethereum client '{}' signature provider must use chain=ethereum and key-type=ethereum", + client_id); + 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, + ethereum_transaction_policy policy, + const fc::network::json_rpc::client_options& rpc_options) { + const auto client_id = policy.client_id; try { - const auto client_chain_id = configured_chain_id - ? std::optional{fc::uint256{*configured_chain_id}} - : std::nullopt; - client = std::make_shared( - signature_provider, - url, - client_chain_id, - rpc_options); + return std::make_shared(signature_provider, url, std::move(policy), rpc_options); } catch (const fc::exception&) { throw_client_initialization_failure(client_id); } catch (const std::exception&) { throw_client_initialization_failure(client_id); } +} - if (configured_chain_id) { - std::string remote_chain_id_text; - try { - remote_chain_id_text = - client->execute_idempotent("eth_chainId", fc::variants{}).as_string(); - } catch (const fc::exception&) { - throw_chain_id_validation_failure(client_id); - } catch (const std::exception&) { - throw_chain_id_validation_failure(client_id); - } - - const auto remote_chain_id = parse_chain_id(remote_chain_id_text); - SYS_ASSERT(remote_chain_id, - chain::plugin_config_exception, - "Unable to validate chain id for outpost Ethereum client '{}': " - "the configured RPC endpoint did not return a valid 32-bit eth_chainId", - client_id); - - SYS_ASSERT(*remote_chain_id == *configured_chain_id, +/** 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, + 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, - *configured_chain_id, - *remote_chain_id); + chain_id, + remote_chain_id); } + const bool inserted = clients.emplace( + client_id, + std::make_shared( + client_id, signature_provider, std::move(client), chain_id)).second; + SYS_ASSERT(inserted, + chain::plugin_config_exception, + "Duplicate outpost Ethereum client id '{}'", + client_id); + ilog("Added Ethereum client (id={},endpoint={},chain_id={})", + client_id, + fc::http::sanitized_endpoint(fc::url(url)), + chain_id); +} - return client; +/** 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, + const fc::network::json_rpc::client_options& rpc_options) { + const auto configuration = client_config::load_evm_client_configuration_file(configuration_file); + client_map clients; + for (const auto& configured_client : configuration.clients()) { + const auto& connection = configured_client.connection(); + auto provider = resolve_signature_provider( + signature_provider_manager, connection.client_id(), connection.signature_provider_id()); + add_client(clients, + connection.client_id(), + connection.rpc_url(), + provider, + policy_from_configuration(configured_client), + rpc_options, + rpc_chain_id_validation::required); + } + 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, + const fc::network::json_rpc::client_options& rpc_options) { + client_map clients; + for (const auto& client_spec : client_specs) { + const auto parts = fc::split(client_spec, ','); + SYS_ASSERT(parts.size() == 3 || parts.size() == 4, + chain::plugin_config_exception, + "Invalid {} spec (expected: ,,[,])", + option_name_client); + + const auto& client_id = parts[0]; + const auto& signature_provider_id = parts[1]; + const auto& url = parts[2]; + SYS_ASSERT(!client_id.empty(), + chain::plugin_config_exception, + "Invalid {} spec: Ethereum client id must not be empty", + option_name_client); + SYS_ASSERT(!signature_provider_id.empty(), + chain::plugin_config_exception, + "Invalid {} spec for client '{}': signature provider id must not be empty", + option_name_client, + client_id); + SYS_ASSERT(!url.empty(), + chain::plugin_config_exception, + "Invalid {} spec for client '{}': RPC URL must not be empty", + option_name_client, + client_id); + + uint32_t chain_id = 0; + if (parts.size() == 4) { + const auto parsed = parse_legacy_chain_id(parts[3]); + SYS_ASSERT(parsed, + chain::plugin_config_exception, + "Invalid {} spec for client '{}': chain id must be a positive 32-bit decimal or hex integer", + option_name_client, + client_id); + chain_id = *parsed; + } else { + chain_id = resolve_rpc_chain_id(client_id, url, rpc_options); + } + + auto provider = resolve_signature_provider( + signature_provider_manager, client_id, signature_provider_id); + add_client(clients, + client_id, + url, + provider, + maximum_policy(transaction_policy_client_label(client_id), chain_id), + rpc_options, + parts.size() == 4 ? rpc_chain_id_validation::required + : rpc_chain_id_validation::not_required); + } + return 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 = std::pair>; + using file_abi_contracts_t = + std::pair>; std::vector _abi_files{}; public: - // Called only from plugin_initialize -- sequential, main-thread -- so the ABI list needs no synchronization. - std::vector load_abi_files(const std::vector& file_names) { - for (auto& filename : file_names) { + /** 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) { FC_ASSERT_FMT(exists(filename), "File does not exist: {}", filename.string()); auto file_path = std::filesystem::absolute(filename); ilog("Loading ABI file: {}", file_path.string()); - if (!std::ranges::none_of(_abi_files, [&](const auto& f) { return f.first == file_path; })) { + if (!std::ranges::none_of(_abi_files, [&](const auto& file) { return file.first == file_path; })) { wlog("Already registered ABI file: {}", file_path.string()); continue; } _abi_files.emplace_back(file_path, fc::network::ethereum::abi::parse_contracts(file_path)); } - 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(); } - ethereum_client_entry_ptr get_client(const std::string& id) { - return _clients.at(id); - } + /** 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 (auto& [id, entry] : _clients) { - if (entry->chain_id && *entry->chain_id == chain_id) { - if (match) return nullptr; // ambiguous: >1 client on this chain id - match = entry; - } + for (const auto& entry : std::views::values(_clients)) { + if (entry->chain_id != chain_id) continue; + if (match) return nullptr; + match = entry; } - return match; // nullptr when none matched - } - - void add_client(const std::string& id, ethereum_client_entry_ptr client) { - FC_ASSERT(client, "Client cannot be null"); - FC_ASSERT(!_clients.contains(id), "Client with id {} already exists", id); - _clients.emplace(id, client); + return match; } - const std::vector& get_abi_files() { - return _abi_files; - }; - + /** Return all loaded ABI files and their parsed contracts. */ + const std::vector& get_abi_files() const { return _abi_files; } }; 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); + my->load_abi_files(options.at(option_abi_file).as>()); } - 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>(); - const auto rpc_options = - outpost_rpc::rpc_options( - options, - transport_option_names); - for (auto& client_spec : client_specs) { - dlog("Adding configured Ethereum client"); - auto parts = fc::split(client_spec, ','); - SYS_ASSERT(parts.size() == 3 || parts.size() == 4, - chain::plugin_config_exception, - "Invalid {} spec '{}' (expected: ,,[,])", - option_name_client, - client_spec); - auto& id = parts[0]; - auto& sig_id = parts[1]; - auto& url = parts[2]; - SYS_ASSERT(!id.empty(), chain::plugin_config_exception, - "Invalid {} spec: Ethereum client id must not be empty", option_name_client); - SYS_ASSERT(!sig_id.empty(), chain::plugin_config_exception, - "Invalid {} spec for client '{}': signer name must not be empty", option_name_client, id); - SYS_ASSERT(!url.empty(), chain::plugin_config_exception, - "Invalid {} spec for client '{}': RPC URL must not be empty", option_name_client, id); - SYS_ASSERT(sig_mgr.is_explicitly_configured_provider(sig_id), - chain::plugin_config_exception, - "Outpost Ethereum client '{}' references signer '{}', but no explicitly named " - "--signature-provider with that name was specified", - id, - sig_id); - - fc::ostring chain_id_str = parts.size() == 4 ? fc::ostring{parts[3]} : fc::ostring{}; - std::optional chain_id; - if (chain_id_str.has_value()) { - SYS_ASSERT(!chain_id_str->empty(), chain::plugin_config_exception, - "Invalid {} spec for client '{}': chain id must not be empty", option_name_client, id); - chain_id = parse_chain_id(*chain_id_str); - SYS_ASSERT(chain_id, - chain::plugin_config_exception, - "Invalid {} spec for client '{}': chain id must be a positive 32-bit decimal or hex integer", - option_name_client, - id); - } - auto sig_provider = sig_mgr.get_provider(sig_id); - SYS_ASSERT(sig_provider->target_chain == fc::crypto::chain_kind_ethereum && - sig_provider->key_type == fc::crypto::chain_key_type_ethereum, - chain::plugin_config_exception, - "Outpost Ethereum client '{}' signer '{}' must use chain=ethereum and key-type=ethereum", - id, - sig_id); - - auto eth_client = create_validated_client( - id, - sig_provider, - url, - chain_id, - rpc_options); - my->add_client(id, - std::make_shared( - id, url, sig_provider, - std::move(eth_client), - chain_id)); - ilog("Added ethereum client (id={},sig_id={},endpoint={},chainId={})", - id, sig_id, fc::http::sanitized_endpoint(fc::url(url)), - chain_id ? std::to_string(*chain_id) : "none"); + const bool has_file = options.contains(option_name_client_config_file); + const bool has_legacy = options.contains(option_name_client); + SYS_ASSERT(has_file != has_legacy, + chain::plugin_config_exception, + "Configure exactly one of --{} or --{}", + option_name_client_config_file, + option_name_client); + + auto& signature_provider_manager = app().get_plugin(); + const auto rpc_options = outpost_rpc::rpc_options(options, transport_option_names); + + try { + if (has_file) { + my->set_clients(load_file_clients( + options.at(option_name_client_config_file).as(), + signature_provider_manager, + rpc_options)); + } else { + my->set_clients(load_legacy_clients( + options.at(option_name_client).as>(), + signature_provider_manager, + rpc_options)); + } + } catch (const client_config::client_config_exception& rejection) { + elog("Rejected Ethereum client configuration (reason_code={},field={},observed={},allowed={})", + client_config::client_config_reason_name(rejection.reason()), + rejection.field(), + rejection.observed(), + 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(unavailable_policy_limit)); + throw; } } void outpost_ethereum_client_plugin::plugin_startup() { - ilog("Starting outpost client plugin"); + ilog("Starting outpost Ethereum client plugin"); } - -outpost_ethereum_client_plugin::outpost_ethereum_client_plugin() : my( - std::make_unique()) {} +outpost_ethereum_client_plugin::outpost_ethereum_client_plugin() + : my(std::make_unique()) {} outpost_ethereum_client_plugin::~outpost_ethereum_client_plugin() = default; -void outpost_ethereum_client_plugin::set_program_options(options_description& cli, options_description& cfg) { - 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: " - "`,,[,]`. The signer id must " - "match an explicitly named --signature-provider; an explicit chain ID is checked against " - "the endpoint's eth_chainId response during startup")( - option_abi_file, - boost::program_options::value>()->multitoken(), - "Ethereum contract ABI file(s). Expects the file to have a JSON array of ABI complient contract definitions." - ); - outbound_http::add_transport_program_options( - cfg, - transport_option_names, - "Ethereum RPC"); +void outpost_ethereum_client_plugin::set_program_options(options_description& cli, + options_description& cfg) { + cfg.add_options() + (option_name_client, + boost::program_options::value>()->multitoken(), + "Legacy outpost Ethereum client spec: " + ",,[,]. A three-field spec resolves " + "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 " + "with --outpost-ethereum-client.") + (option_abi_file, + boost::program_options::value>()->multitoken(), + "Ethereum contract ABI file(s). Expects a JSON array of ABI-compliant contract definitions."); + outbound_http::add_transport_program_options(cfg, transport_option_names, "Ethereum RPC"); } - void outpost_ethereum_client_plugin::plugin_shutdown() { - ilog("Shutdown outpost client plugin"); + ilog("Shutdown outpost Ethereum client plugin"); } std::vector outpost_ethereum_client_plugin::get_clients() { @@ -317,22 +541,34 @@ ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client_by_chain_id return my->get_client_by_chain_id(chain_id); } -const std::vector>>& outpost_ethereum_client_plugin::get_abi_files() { +const std::vector>>& +outpost_ethereum_client_plugin::get_abi_files() { return my->get_abi_files(); } std::shared_ptr 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) { - auto entry = my->get_client(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) { + const auto entry = my->get_client(eth_client_id); FC_ASSERT(entry, "Unknown ethereum client id: {}", eth_client_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 (auto& [path, contracts] : my->get_abi_files()) { + for (const auto& [path, contracts] : my->get_abi_files()) { all_abis.insert(all_abis.end(), contracts.begin(), contracts.end()); } return std::make_shared(entry, 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..6de0d9221e --- /dev/null +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -0,0 +1,726 @@ +#include + +#include + +#include +#include +#include +#include +#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 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 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(); }) {} + + 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); + 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(); + } + + /** 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); + _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; + + std::ostringstream response; + response << "HTTP/1.1 200 OK\r\n" + << "Content-Type: application/json\r\n" + << "Content-Length: " << _response_body.size() << "\r\n" + << "Connection: close\r\n\r\n" + << _response_body; + const auto response_text = response.str(); + boost::asio::write(socket, boost::asio::buffer(response_text), error); + } + + std::string _response_body; + boost::asio::io_context _io; + tcp::acceptor _acceptor; + uint16_t _port; + 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), + .chain_id = 31337, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .max_gas_limit = 1000, + .max_total_native_cost = 100000, + }; +} + +/** Build a function ABI without input arguments. */ +ethabi::contract no_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {}, + .outputs = {}, + }; +} + +/** Build a function ABI with one dynamic-bytes argument. */ +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 = {}, + }; +} + +/** Build a function ABI with one uint32 argument. */ +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 = {}, + }; +} + +/** 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(); + 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; +} + +/** 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"); + 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); + } + + /** 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); + } + + std::vector methods; + fc::variant estimate_gas_params; + 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, + .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 = {}, + }; +} + +/** 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"; + std::ofstream output{path}; + output << contents; + output.close(); + 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, + const std::function& inspect_plugin, + std::string_view signature_provider_id = "signer-a", + std::string_view signature_provider_chain = "ethereum") { + auto reset_application = gsl_lite::finally([] { appbase::application::reset_app_singleton(); }); + appbase::scoped_app test_application{}; + + const std::string signature_spec = + std::string(signature_provider_id) + "," + std::string(signature_provider_chain) + + ",ethereum," + std::string(signer_public_key) + ",KEY:" + + std::string(signer_private_key); + std::vector arguments{ + "test_outpost_ethereum_transaction_policy", + "--signature-provider", + signature_spec, + }; + arguments.insert(arguments.end(), configuration_arguments.begin(), configuration_arguments.end()); + + std::vector argv; + argv.reserve(arguments.size()); + for (auto& argument : arguments) argv.emplace_back(argument.data()); + + 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()); +} + +/** 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", + std::string_view signature_provider_chain = "ethereum") { + std::vector clients; + with_initialized_outpost_plugin( + configuration_arguments, + [&](auto& plugin) { clients = plugin.get_clients(); }, + signature_provider_id, + signature_provider_chain); + 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(); }); + 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-client-config-file", + configuration_file.string(), + }; + std::vector argv; + argv.reserve(arguments.size()); + for (auto& argument : arguments) argv.emplace_back(argument.data()); + + try { + (void) test_application->initialize( + argv.size(), argv.data()); + BOOST_FAIL("expected file configuration rejection"); + } catch (const sysio::chain::plugin_config_exception&) { + return test_application->get_plugin() + .get_clients() + .size(); + } + return std::numeric_limits::max(); +} + +} // 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 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; + 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(plugin_startup_attaches_unified_client_policies) { + fc::temp_directory directory; + 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": "RPC_URL_A" + }, + "chain_id": 31337, + "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" + } + }, { + "connection": { + "client_id": "client-b", + "signature_provider_id": "signer-a", + "rpc_url": "RPC_URL_B" + }, + "chain_id": 1 + }] + })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(); + 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, maximum); +} + +BOOST_AUTO_TEST_CASE(file_configuration_accepts_nonempty_provider_identifier) { + 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":"prod/signing", + "rpc_url":"RPC_URL_A" + }, + "chain_id":31337 + }] + })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({ + "schema_version":1, + "clients":[{ + "connection":{ + "client_id":"client-a", + "signature_provider_id":"signer-a", + "rpc_url":"http://127.0.0.1:1" + }, + "chain_id":31337 + }] + })json"); + BOOST_CHECK_THROW( + initialize_outpost_plugin( + {"--outpost-ethereum-client-config-file", path.string()}, "signer-a", "wire"), + sysio::chain::plugin_config_exception); +} + +BOOST_AUTO_TEST_CASE(file_configuration_does_not_publish_a_partial_client_map) { + fc::temp_directory directory; + 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":"RPC_URL_A" + }, + "chain_id":31337 + },{ + "connection":{ + "client_id":"invalid-second", + "signature_provider_id":"missing-signer", + "rpc_url":"http://127.0.0.1:1" + }, + "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_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," + 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); + BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().max_fee_per_gas, maximum); +} + +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," + rpc_server.url() + ",31337"}, + "prod/signing"); + BOOST_REQUIRE_EQUAL(clients.size(), 1u); + BOOST_CHECK_EQUAL(clients.front()->id, "legacy/client"); + BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().client_id, "legacy-client"); +} + +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({"schema_version":1,"clients":[]})json"); + BOOST_CHECK_THROW(initialize_outpost_plugin( + {"--outpost-ethereum-client-config-file", + path.string(), + "--outpost-ethereum-client", + "client-a,signer-a,http://127.0.0.1:1,31337"}), + sysio::chain::plugin_config_exception); +} + +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({ + "schema_version": 1, + "clients": [{ + "connection": { + "client_id": "client-a", + "signature_provider_id": "signer-a", + "rpc_url": "RPC_URL_A" + }, + "chain_id": 31337 + }] + })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()}, + [&](auto& plugin) { + const auto chain_code = fc::slug_name{"ETH"}.value; + try { + plugin.create_outpost_client( + "client-a", + 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 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); + } + }); +} + +BOOST_AUTO_TEST_CASE(plugin_startup_redacts_an_invalid_authenticated_rpc_url) { + fc::temp_directory directory; + 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({ + "schema_version": 1, + "clients": [{ + "connection": { + "client_id": "client-a", + "signature_provider_id": "signer-a", + "rpc_url": "http://user:password@localhost:not-a-port/rpc?token=secret" + }, + "chain_id": 31337 + }] + })json"); + try { + initialize_outpost_plugin( + {"--outpost-ethereum-client-config-file", path.string()}); + BOOST_FAIL("expected invalid URL rejection"); + } catch (const sysio::opp::config::client_config_exception& rejection) { + BOOST_CHECK(rejection.reason() == sysio::opp::config::client_config_reason::rpc_url_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 8fee7b56d2..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 @@ -2,6 +2,8 @@ #include #include +#include +#include #include @@ -19,6 +21,7 @@ #include #include #include +#include #include #include @@ -163,6 +166,9 @@ 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://"; +/** 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"; @@ -180,6 +186,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 +202,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") { @@ -274,13 +351,14 @@ BOOST_AUTO_TEST_CASE(authenticated_transport_options_are_registered) { BOOST_CHECK(option_names.contains("outpost-ethereum-additional-ca-file")); BOOST_CHECK(option_names.contains("outpost-ethereum-additional-ca-path")); BOOST_CHECK(option_names.contains("outpost-ethereum-proxy")); + BOOST_CHECK(option_names.contains("outpost-ethereum-client-config-file")); } // --------------------------------------------------------------------------- // Startup configuration validation // --------------------------------------------------------------------------- -BOOST_AUTO_TEST_CASE(startup_accepts_matching_named_signer_and_remote_chain_id) { +BOOST_AUTO_TEST_CASE(startup_accepts_explicit_locally_authoritative_chain_id) { auto rpc_server = chain_id_rpc_server(); BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ "--signature-provider", @@ -290,8 +368,8 @@ BOOST_AUTO_TEST_CASE(startup_accepts_matching_named_signer_and_remote_chain_id) })); } -BOOST_AUTO_TEST_CASE(startup_accepts_three_field_client_without_remote_chain_id_check) { - fc::test::connection_closing_http_server rpc_server; +BOOST_AUTO_TEST_CASE(startup_resolves_three_field_client_chain_id_from_rpc) { + auto rpc_server = chain_id_rpc_server(); BOOST_CHECK_NO_THROW(initialize_outpost_plugin({ "--signature-provider", named_ethereum_signature_provider(), @@ -353,7 +431,7 @@ BOOST_AUTO_TEST_CASE(startup_rejects_named_signer_with_wrong_key_type) { }), sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(startup_rejects_chain_id_mismatch_with_rpc_endpoint) { +BOOST_AUTO_TEST_CASE(startup_rejects_explicit_chain_id_mismatch) { auto rpc_server = chain_id_rpc_server(); BOOST_CHECK_THROW(initialize_outpost_plugin({ "--signature-provider", @@ -363,14 +441,45 @@ BOOST_AUTO_TEST_CASE(startup_rejects_chain_id_mismatch_with_rpc_endpoint) { }), sysio::chain::plugin_config_exception); } -BOOST_AUTO_TEST_CASE(startup_rejects_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({ + 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); + 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); + } +} + +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(), "--outpost-ethereum-client", "client-a,signer-a," + rpc_server.url() + ",31337", - }), sysio::chain::plugin_config_exception); + })); } BOOST_AUTO_TEST_CASE(startup_rejects_invalid_remote_chain_id) { @@ -379,7 +488,7 @@ BOOST_AUTO_TEST_CASE(startup_rejects_invalid_remote_chain_id) { "--signature-provider", named_ethereum_signature_provider(), "--outpost-ethereum-client", - "client-a,signer-a," + rpc_server.url() + ",31337", + "client-a,signer-a," + rpc_server.url(), }), sysio::chain::plugin_config_exception); } @@ -421,7 +530,7 @@ BOOST_AUTO_TEST_CASE(startup_rejects_remote_chain_id_wider_than_uint256_without_ "--signature-provider", named_ethereum_signature_provider(), "--outpost-ethereum-client", - "client-a,signer-a," + rpc_server.url() + ",31337", + "client-a,signer-a," + rpc_server.url(), }), sysio::chain::plugin_config_exception); } @@ -566,17 +675,24 @@ BOOST_AUTO_TEST_CASE(read_inbound_envelope_validates_latest_slot) try { private_key_spec); const std::string rpc_url{latest_slot_test_rpc_url}; + ethereum_transaction_policy transaction_policy{ + .client_id = std::string(latest_slot_test_entry_id), + .chain_id = test_evm_chain_id, + .max_priority_fee_per_gas = maximum_ethereum_transaction_policy_value(), + .max_fee_per_gas = maximum_ethereum_transaction_policy_value(), + .max_gas_limit = maximum_ethereum_transaction_policy_value(), + .max_total_native_cost = maximum_ethereum_transaction_policy_value(), + }; auto eth_client = std::make_shared( sig_provider, std::variant{rpc_url}, - fc::uint256{test_evm_chain_id}); + std::move(transaction_policy)); 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/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..827a174dc3 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()); } } @@ -2646,7 +2649,7 @@ void underwriter_plugin::set_program_options(options_description& cli, "Per-EVM-chain outpost wiring (repeatable, one per EVM chain served). Format: " "`,,,` — " "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."); 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() diff --git a/programs/examples/cranker-example/README.md b/programs/examples/cranker-example/README.md index f24d4889d0..3d627fa411 100644 --- a/programs/examples/cranker-example/README.md +++ b/programs/examples/cranker-example/README.md @@ -11,7 +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-client-config-file docs/ethereum-client-config.example.json \ --ethereum-abi-file tests/fixtures/ethereum-abi-counter-01.json ``` @@ -27,14 +27,19 @@ 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) The Ethereum chain ID. +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). + +The legacy +`--outpost-ethereum-client ,,[,]` option remains +available. It assigns maximum-value policy defaults and cannot be combined with +`--outpost-ethereum-client-config-file`. Those defaults preserve compatibility only, provide no +finite economic boundary, and are not recommended for production; production operators must use +reviewed finite `transaction_policy` values in the file configuration. #### 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. @@ -42,5 +47,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. +2. At least **one** Ethereum outpost client, from the unified file or legacy option. 3. At least **one** Ethereum ABI file reference.