From 9491e0ab50646385bd8eb0759295774d6c36eb85 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 18:04:19 +0000 Subject: [PATCH 1/5] Enforce Ethereum transaction expenditure policy --- docs/ethereum-transaction-policy.example.json | 13 + docs/outpost-client-plugins.md | 36 +- libraries/libfc/CMakeLists.txt | 1 + .../fc/network/ethereum/ethereum_client.hpp | 30 +- .../network/ethereum/ethereum_rlp_encoder.hpp | 4 +- .../ethereum/ethereum_transaction_policy.hpp | 134 ++++ .../src/network/ethereum/ethereum_client.cpp | 141 ++-- .../ethereum/ethereum_transaction_policy.cpp | 303 +++++++++ libraries/libfc/test/CMakeLists.txt | 1 + .../ethereum/test_ethereum_client_and_rlp.cpp | 28 + .../test_ethereum_transaction_policy.cpp | 284 ++++++++ .../sysio/outpost_ethereum_client_plugin.hpp | 32 +- .../src/outpost_ethereum_client_plugin.cpp | 389 +++++++++-- .../test/test_ethereum_transaction_policy.cpp | 610 ++++++++++++++++++ .../test_outpost_ethereum_client_plugin.cpp | 15 +- programs/examples/cranker-example/README.md | 10 +- 16 files changed, 1907 insertions(+), 124 deletions(-) create mode 100644 docs/ethereum-transaction-policy.example.json create mode 100644 libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp create mode 100644 libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp create mode 100644 libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp create mode 100644 plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp diff --git a/docs/ethereum-transaction-policy.example.json b/docs/ethereum-transaction-policy.example.json new file mode 100644 index 0000000000..0045b72da3 --- /dev/null +++ b/docs/ethereum-transaction-policy.example.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "policies": [ + { + "client_id": "eth-anvil-local", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } + ] +} diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index c7f0692df6..cb35f4b5af 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -9,8 +9,40 @@ The Ethereum client plugin is configured via program options as follows: ```sh --signature-provider "eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" --outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 +--outpost-ethereum-transaction-policy-file /etc/wire/ethereum-transaction-policy.json ``` -> NOTE: If you look closely, the reference to `eth-01` in the Ethereum client config, matches the signature provider configured for `Ethereum`. This mapping is what enables `1..n` clients in a single process + +The policy file is required whenever the plugin is configured. A minimal file is: + +```json +{ + "version": 1, + "policies": [ + { + "client_id": "eth-anvil-local", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } + ] +} +``` + +All policy integers are positive canonical decimal strings. Strings avoid JSON number precision loss for values up to `uint256`. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. + +Immediately before signing, the client requires: + +- `maxPriorityFeePerGas <= max_priority_fee_per_gas_wei` +- `maxFeePerGas <= max_fee_per_gas_wei` +- `gasLimit <= max_gas_limit` +- `maxFeePerGas >= maxPriorityFeePerGas` +- `gasLimit * maxFeePerGas + value <= max_total_native_cost_wei` + +All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. + +The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. With the above configuration and the appropriate `app` & `plugin` config, you can access the `outpost-ethereum-client` configured with name/id == `eth-anvil-local` as follows @@ -24,7 +56,7 @@ auto client_entry = eth_plug.get_clients()[0]; // CLIENT IS A `std::shared_ptr` auto& client = client_entry->client; -// GET CHAIN ID, JUST AN EXAMPLE +// GET THE AUTHORITATIVE POLICY CHAIN ID, JUST AN EXAMPLE // `chain_id` will have the type `fc::uint256` auto chain_id = client->get_chain_id(); ``` diff --git a/libraries/libfc/CMakeLists.txt b/libraries/libfc/CMakeLists.txt index 21bf152082..66e1255c6a 100644 --- a/libraries/libfc/CMakeLists.txt +++ b/libraries/libfc/CMakeLists.txt @@ -63,6 +63,7 @@ set(fc_sources src/network/ethereum/ethereum_abi.cpp src/network/ethereum/ethereum_client.cpp src/network/ethereum/ethereum_rlp_encoder.cpp + src/network/ethereum/ethereum_transaction_policy.cpp src/network/http/http_client.cpp src/network/json_rpc/json_rpc_client.cpp src/network/solana/solana_types.cpp diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp index 3720f1b15d..7355343f01 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp @@ -10,9 +10,12 @@ #include #include #include +#include #include #include +#include + namespace fc::network::ethereum { using namespace fc::crypto; using namespace fc::crypto::ethereum; @@ -328,10 +331,13 @@ class ethereum_client : public std::enable_shared_from_this { * @brief Constructs an EthereumClient instance. * @param sig_provider `signature_provider` shared pointer * @param url_source The URL of the Ethereum node (e.g., Infura, local node). - * @param chain_id optional uint256 encapsulating the chain id + * @param transaction_policy Required local expenditure policy and authoritative chain id */ ethereum_client(const signature_provider_ptr& sig_provider, const std::variant& url_source, - std::optional chain_id = std::nullopt); + ethereum_transaction_policy transaction_policy); + + /** Virtual destructor supporting deterministic RPC fakes in client tests. */ + virtual ~ethereum_client() = default; /** * @brief General method to send RPC requests. @@ -339,7 +345,7 @@ class ethereum_client : public std::enable_shared_from_this { * @param params The parameters for the RPC method (as a JSON object). * @return The raw JSON response as a string, wrapped in std::optional. */ - fc::variant execute(const std::string& method, const fc::variant& params); + virtual fc::variant execute(const std::string& method, const fc::variant& params); fc::variant execute_contract_view_fn(const address& contract_address, const abi::contract& abi, const block_number_or_tag_t& block, const contract_invoke_data_items& params); @@ -518,7 +524,7 @@ class ethereum_client : public std::enable_shared_from_this { * @brief Retrieves the chain ID of the connected Ethereum network. * @return The chain ID. */ - fc::uint256 get_chain_id(); + fc::uint256 get_chain_id() const; /** * @brief Retrieves the version of the connected Ethereum network. @@ -546,6 +552,9 @@ class ethereum_client : public std::enable_shared_from_this { */ ethereum::address get_signer_address() const { return _address; }; + /** Return the immutable local transaction policy attached at construction. */ + const ethereum_transaction_policy& transaction_policy() const { return _transaction_policy; } + /** * @brief Creates a default EIP-1559 transaction with estimated gas and current fees * @@ -580,6 +589,13 @@ class ethereum_client : public std::enable_shared_from_this { } private: + /** Fetch and validate fee suggestions without logging a duplicate rejection. */ + gas_config_t get_gas_config_unlogged(); + + /** Emit one sanitized high-severity record for a policy rejection. */ + void log_policy_rejection(const ethereum_transaction_policy_exception& rejection, + std::string_view operation_type) const; + /** * @brief Signature provider for signing transactions */ @@ -595,10 +611,8 @@ class ethereum_client : public std::enable_shared_from_this { */ json_rpc_client _client; - /** - * @brief Cached chain ID (fetched once and reused) - */ - std::optional _chain_id; + /** Required immutable policy, including the locally configured chain ID. */ + const ethereum_transaction_policy _transaction_policy; /** * @brief Mutex for thread-safe access to _contracts_map diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp index a8738a874e..cb8a4306d4 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_rlp_encoder.hpp @@ -79,7 +79,7 @@ bytes encode_uint(T value) { bytes buf; bool started = false; - for (int shift = 56; shift >= 0; shift -= 8) { + for (int shift = 248; shift >= 0; shift -= 8) { std::uint8_t byte = static_cast((value >> shift) & 0xff); if (byte == 0 && !started) continue; @@ -154,4 +154,4 @@ bytes encode_eip1559_signed(const eip1559_tx& tx); bytes encode_eip1559_signed_typed(const eip1559_tx& tx); -} // namespace fc::network::ethereum::rlp \ No newline at end of file +} // namespace fc::network::ethereum::rlp diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp new file mode 100644 index 0000000000..84db9c72f2 --- /dev/null +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace fc::network::ethereum { + +/** Stable reason codes emitted when Ethereum transaction policy validation fails. */ +enum class ethereum_transaction_policy_reason { + configuration_file_unreadable, + configuration_schema_invalid, + configuration_version_unsupported, + configuration_client_missing, + configuration_client_duplicate, + configuration_client_unknown, + configuration_chain_id_mismatch, + configuration_value_invalid, + rpc_quantity_invalid, + rpc_quantity_out_of_range, + max_fee_derivation_overflow, + gas_limit_derivation_overflow, + fee_relationship_invalid, + chain_id_mismatch, + priority_fee_cap_exceeded, + max_fee_cap_exceeded, + gas_limit_cap_exceeded, + total_cost_multiplication_overflow, + total_cost_addition_overflow, + total_cost_cap_exceeded +}; + +/** Return the stable wire/log spelling for a transaction-policy reason. */ +inline std::string_view reason_code_name(ethereum_transaction_policy_reason reason) { + return magic_enum::enum_name(reason); +} + +/** Return whether an identifier is bounded and ASCII-safe for policy logs and lookups. */ +bool is_safe_transaction_policy_identifier(std::string_view identifier); + +/** + * Exception carrying structured, non-sensitive Ethereum transaction-policy rejection details. + * + * The reason enum is the stable machine-readable code. Field, observed, and allowed values are + * intentionally strings so arithmetic-overflow diagnostics can describe both operands without + * performing another potentially unsafe calculation. + */ +class ethereum_transaction_policy_exception : public fc::exception { +public: + /** Construct a structured transaction-policy rejection. */ + ethereum_transaction_policy_exception(ethereum_transaction_policy_reason reason, + std::string field, + std::string observed, + std::optional allowed = std::nullopt); + + /** Copy this exception without slicing its structured fields. */ + std::shared_ptr dynamic_copy_exception() const override; + + /** Rethrow this exception while preserving its dynamic type. */ + void rethrow() const override; + + /** Stable rejection reason. */ + ethereum_transaction_policy_reason reason() const { return _reason; } + + /** Transaction field or configuration field associated with the rejection. */ + const std::string& field() const { return _field; } + + /** Sanitized observed value or arithmetic operands. */ + const std::string& observed() const { return _observed; } + + /** Sanitized configured limit, when the rejection has one. */ + const std::optional& allowed() const { return _allowed; } + +private: + ethereum_transaction_policy_reason _reason; + std::string _field; + std::string _observed; + std::optional _allowed; +}; + +/** Immutable local expenditure policy for one configured Ethereum client and chain. */ +struct ethereum_transaction_policy { + std::string client_id; + fc::uint256 chain_id; + fc::uint256 max_priority_fee_per_gas; + fc::uint256 max_fee_per_gas; + fc::uint256 max_gas_limit; + fc::uint256 max_total_native_cost; +}; + +/** + * Parse a canonical unsigned decimal uint256 configuration value. + * + * Canonical values contain only ASCII decimal digits and have no leading zeroes. Zero is accepted + * only when `allow_zero` is true. Values wider than uint256 are rejected before conversion. + */ +fc::uint256 parse_canonical_uint256_decimal(std::string_view value, + std::string_view field, + bool allow_zero = false); + +/** + * Parse a canonical Ethereum JSON-RPC QUANTITY without truncation. + * + * The accepted form is `0x0` or `0x` followed by at most 64 hexadecimal digits with no leading zero. + */ +fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field); + +/** Format a uint256 as a canonical Ethereum JSON-RPC QUANTITY. */ +std::string format_rpc_quantity(const fc::uint256& value); + +/** Validate the policy itself before it is attached to a signing-capable client. */ +void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy); + +/** Derive and cap `2 * base_fee + priority_fee` using checked uint256 arithmetic. */ +fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, + const fc::uint256& priority_fee, + const fc::uint256& base_fee); + +/** Apply checked `(estimated_gas * 6) / 5` headroom and enforce the gas cap. */ +fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, + const fc::uint256& estimated_gas); + +/** Validate every settled EIP-1559 transaction field before a private-key operation. */ +void validate_transaction_against_policy(const ethereum_transaction_policy& policy, + const fc::crypto::ethereum::eip1559_tx& transaction); + +} // namespace fc::network::ethereum diff --git a/libraries/libfc/src/network/ethereum/ethereum_client.cpp b/libraries/libfc/src/network/ethereum/ethereum_client.cpp index 6e69cecc0a..58f46e5428 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_client.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_client.cpp @@ -8,7 +8,9 @@ #include #include #include + #include +#include #include @@ -18,6 +20,8 @@ namespace { using namespace fc::crypto; using namespace fc::crypto::ethereum; using namespace fc::network::json_rpc; + +constexpr std::string_view invalid_log_identifier = ""; } // namespace /** @@ -70,20 +74,22 @@ const abi::contract& ethereum_contract_client::get_abi(const std::string& contra * @brief Constructs an ethereum_client instance * * Initializes the client with a signature provider for transaction signing, - * a JSON-RPC endpoint URL, and an optional chain ID. The Ethereum address - * is derived from the signature provider's public key. + * a JSON-RPC endpoint URL, and a required local transaction policy. The + * Ethereum address is derived from the signature provider's public key. * * @param sig_provider Signature provider for signing transactions * @param url_source URL of the Ethereum node (string or fc::url) - * @param chain_id Optional chain ID (if not provided, will be fetched from the node) + * @param transaction_policy Required expenditure limits and authoritative chain ID */ ethereum_client::ethereum_client(const signature_provider_ptr& sig_provider, const std::variant& url_source, - std::optional chain_id) + ethereum_transaction_policy transaction_policy) : _signature_provider(sig_provider) , _address(to_address(_signature_provider->public_key)) , _client(json_rpc_client::create(url_source)) - , _chain_id(chain_id) {} + , _transaction_policy(std::move(transaction_policy)) { + validate_transaction_policy_configuration(_transaction_policy); +} /** * @brief Executes a JSON-RPC method call on the Ethereum node @@ -97,6 +103,22 @@ fc::variant ethereum_client::execute(const std::string& method, const fc::varian return _client.call(method, params); } +/** Emit a sanitized policy-rejection record without endpoint, key, calldata, or signature data. */ +void ethereum_client::log_policy_rejection(const ethereum_transaction_policy_exception& rejection, + std::string_view operation_type) const { + const std::string_view safe_operation_type = + is_safe_transaction_policy_identifier(operation_type) ? operation_type : invalid_log_identifier; + elog("Ethereum transaction policy rejected reason_code={} client_id={} chain_id={} operation={} " + "field={} observed={} allowed={}", + reason_code_name(rejection.reason()), + _transaction_policy.client_id, + _transaction_policy.chain_id.str(), + safe_operation_type, + rejection.field(), + rejection.observed(), + rejection.allowed().value_or("n/a")); +} + /** * @brief Executes a contract view function (read-only call) * @@ -136,6 +158,16 @@ fc::variant ethereum_client::execute_contract_tx_fn(const eip1559_tx& source_tx, const contract_invoke_data_items& params, bool sign) { eip1559_tx tx = source_tx; tx.data = from_hex(contract_encode_data(abi, params)); + + if (sign) { + try { + validate_transaction_against_policy(_transaction_policy, tx); + } catch (const ethereum_transaction_policy_exception& rejection) { + log_policy_rejection(rejection, abi.name); + throw; + } + } + auto tx_encoded = rlp::encode_eip1559_unsigned_typed(tx); if (sign) { fc::crypto::eth_client_signer signer(*_signature_provider); @@ -175,30 +207,19 @@ fc::uint256 ethereum_client::get_transaction_count(const address_compat_type& ad auto from_addr_hex = to_hex(from_addr, true); fc::variants params{from_addr_hex, to_block_tag(block)}; auto res = execute("eth_getTransactionCount", params); - dlog("tx_count: {}", res.as_string()); - return to_uint256(res); + return parse_rpc_quantity(res, "nonce"); } /** * @brief Retrieves the chain ID of the connected Ethereum network * - * Fetches the chain ID from the node on first call and caches it for subsequent calls. - * The chain ID is used in EIP-155 replay protection for transactions. + * Returns the locally configured policy chain ID. An RPC provider is never trusted to + * select the replay-protection domain of a transaction this client will sign. * * @return The chain ID as a uint256 - * @throws fc::network::json_rpc::json_rpc_exception if the RPC call fails */ -fc::uint256 ethereum_client::get_chain_id() { - static std::mutex mutex; - std::scoped_lock lock(mutex); - if (_chain_id.has_value()) { - return *_chain_id; - } - - fc::variants params; // empty array - _chain_id = to_uint256(execute("eth_chainId", params)); - - return _chain_id.value(); +fc::uint256 ethereum_client::get_chain_id() const { + return _transaction_policy.chain_id; } /** @@ -245,22 +266,26 @@ fc::variant ethereum_client::get_syncing_status() { */ eip1559_tx ethereum_client::create_default_tx(const address_compat_type& to, const abi::contract& contract, const fc::variants& params) { - auto gc = get_gas_config(); - auto data = contract_encode_data(contract, params); - - auto estimated_gas = estimate_gas(to, contract, data, gc); - // add 20% buffer - same as ceil(x * 1.2), but integer division only - auto gas_limit = (estimated_gas * 6) /5; - - return eip1559_tx{.chain_id = get_chain_id(), - .nonce = get_transaction_count(get_signer_address(), "pending"), - .max_priority_fee_per_gas = gc.tip, - .max_fee_per_gas = gc.max_fee_per_gas, - .gas_limit = gas_limit, - .to = to_address(to), - .value = 0, - .data = from_hex(data), - .access_list = {}}; + try { + auto gc = get_gas_config_unlogged(); + auto data = contract_encode_data(contract, params); + + auto estimated_gas = estimate_gas(to, contract, data, gc); + auto gas_limit = derive_buffered_gas_limit(_transaction_policy, estimated_gas); + + return eip1559_tx{.chain_id = get_chain_id(), + .nonce = get_transaction_count(get_signer_address(), block_tag_t::pending), + .max_priority_fee_per_gas = gc.tip, + .max_fee_per_gas = gc.max_fee_per_gas, + .gas_limit = gas_limit, + .to = to_address(to), + .value = 0, + .data = from_hex(data), + .access_list = {}}; + } catch (const ethereum_transaction_policy_exception& rejection) { + log_policy_rejection(rejection, contract.name); + throw; + } } /** @@ -363,9 +388,13 @@ fc::variant ethereum_client::get_transaction_by_hash(const std::string& tx_hash) */ fc::uint256 ethereum_client::get_base_fee_per_gas() { auto block = get_block_by_number(block_tag_t::latest); - FC_ASSERT_FMT(block.contains("baseFeePerGas"), "Block {} does not contain baseFeePerGas", - to_string(block_tag_t::latest)); - return block["baseFeePerGas"].as_uint256(); + if (!block.contains("baseFeePerGas")) { + throw ethereum_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, + "base_fee_per_gas", + ""); + } + return parse_rpc_quantity(block["baseFeePerGas"], "base_fee_per_gas"); } /** @@ -380,7 +409,7 @@ fc::uint256 ethereum_client::get_base_fee_per_gas() { fc::uint256 ethereum_client::get_max_priority_fee_per_gas() { fc::variants params; // empty auto resp = execute("eth_maxPriorityFeePerGas", params); - return resp.as_uint256(); + return parse_rpc_quantity(resp, "max_priority_fee_per_gas"); } /** @@ -400,7 +429,7 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const s tx("from", get_address())("to", to_address(to))("value", value); fc::variants params{fc::variant(tx)}; auto resp = execute("eth_estimateGas", params); - return resp.as_uint256(); + return parse_rpc_quantity(resp, "estimated_gas"); } /** @@ -414,9 +443,19 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const s * @throws fc::network::json_rpc::json_rpc_exception if any RPC call fails */ ethereum_client::gas_config_t ethereum_client::get_gas_config() { + try { + return get_gas_config_unlogged(); + } catch (const ethereum_transaction_policy_exception& rejection) { + log_policy_rejection(rejection, "get_gas_config"); + throw; + } +} + +/** Fetch fee suggestions and apply the local caps without emitting a duplicate log record. */ +ethereum_client::gas_config_t ethereum_client::get_gas_config_unlogged() { auto tip = get_max_priority_fee_per_gas(); auto base_fee = get_base_fee_per_gas(); - auto max_fee_per_gas = (base_fee * 2) + tip; + auto max_fee_per_gas = derive_max_fee_per_gas(_transaction_policy, tip, base_fee); return gas_config_t{ .tip = tip, @@ -443,25 +482,19 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const a const data_or_params_t& data_or_params, const std::optional& gas_config_opt) { fc::mutable_variant_object tx; - gas_config_t gc = gas_config_opt.value_or(get_gas_config()); + gas_config_t gc = gas_config_opt ? *gas_config_opt : get_gas_config(); - std::string data = to_data_from_params(contract, data_or_params);; - if (std::holds_alternative(data_or_params)) { - auto& params = std::get(data_or_params); - data = "0x" + contract_encode_data(contract, params); - } else { - data = "0x" + std::get(data_or_params); - } + std::string data = to_data_from_params(contract, data_or_params, true); tx("from", to_hex(get_address(), true)) ("to", to_hex(to_address(to), true)) - ("maxPriorityFeePerGas", to_hex(rlp::encode_uint(gc.max_fee_per_gas), true)) - ("maxFeePerGas", to_hex(rlp::encode_uint(gc.max_fee_per_gas), true)) + ("maxPriorityFeePerGas", format_rpc_quantity(gc.tip)) + ("maxFeePerGas", format_rpc_quantity(gc.max_fee_per_gas)) ("data", data) ("input", data); auto resp = execute("eth_estimateGas", fc::variants{tx}); - return resp.as_uint256(); + return parse_rpc_quantity(resp, "estimated_gas"); } /** diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp new file mode 100644 index 0000000000..5beee48366 --- /dev/null +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -0,0 +1,303 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fc::network::ethereum { + +namespace { + +constexpr std::string_view max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; +constexpr size_t max_uint256_hex_digits = 64; +constexpr size_t max_diagnostic_value_chars = 96; +constexpr size_t max_client_id_chars = 64; +constexpr std::string_view hex_quantity_prefix = "0x"; +constexpr uint64_t gas_headroom_multiplier = 6; +constexpr uint64_t gas_headroom_divisor = 5; +constexpr uint64_t max_fee_base_multiplier = 2; + +/** Return the largest uint256 value without relying on a narrowing intermediate. */ +const fc::uint256& max_uint256() { + static const fc::uint256 value{std::string(max_uint256_decimal)}; + return value; +} + +/** Bound untrusted diagnostic strings so malformed configuration cannot flood logs. */ +std::string diagnostic_value(std::string_view value) { + if (value.size() <= max_diagnostic_value_chars) return std::string(value); + return std::string(value.substr(0, max_diagnostic_value_chars)) + "..."; +} + +/** Throw a structured policy exception. */ +[[noreturn]] void reject(ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed = std::nullopt) { + throw ethereum_transaction_policy_exception( + reason, std::string(field), std::move(observed), std::move(allowed)); +} + +/** Check whether every character is an ASCII decimal digit. */ +bool is_decimal(std::string_view value) { + return std::ranges::all_of(value, [](unsigned char c) { return c >= '0' && c <= '9'; }); +} + +/** Check whether every character is an ASCII hexadecimal digit. */ +bool is_hexadecimal(std::string_view value) { + return std::ranges::all_of(value, [](unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + }); +} + +} // namespace + +bool is_safe_transaction_policy_identifier(std::string_view identifier) { + return !identifier.empty() && identifier.size() <= max_client_id_chars && + std::ranges::all_of(identifier, [](unsigned char c) { + const bool ascii_alphanumeric = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z'); + return ascii_alphanumeric || c == '-' || c == '_' || c == '.'; + }); +} + +ethereum_transaction_policy_exception::ethereum_transaction_policy_exception( + ethereum_transaction_policy_reason reason, + std::string field, + std::string observed, + std::optional allowed) + : fc::exception(FC_LOG_MESSAGE(error, + "reason_code={} field={} observed={} allowed={}", + reason_code_name(reason), + field, + observed, + allowed.value_or("n/a")), + fc::invalid_arg_exception_code, + "ethereum_transaction_policy_exception", + "Ethereum transaction policy rejected") + , _reason(reason) + , _field(std::move(field)) + , _observed(std::move(observed)) + , _allowed(std::move(allowed)) {} + +std::shared_ptr ethereum_transaction_policy_exception::dynamic_copy_exception() const { + return std::make_shared(*this); +} + +void ethereum_transaction_policy_exception::rethrow() const { + throw *this; +} + +fc::uint256 parse_canonical_uint256_decimal(std::string_view value, + std::string_view field, + bool allow_zero) { + const bool numeric_value = is_decimal(value); + const auto invalid = [&] { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, + field, + numeric_value ? diagnostic_value(value) : ""); + }; + + if (value.empty() || !numeric_value || (value.size() > 1 && value.front() == '0')) invalid(); + if (value.size() > max_uint256_decimal.size() || + (value.size() == max_uint256_decimal.size() && value > max_uint256_decimal)) { + invalid(); + } + + fc::uint256 parsed{std::string(value)}; + if (!allow_zero && parsed == 0) invalid(); + return parsed; +} + +fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field) { + if (!value.is_string()) { + reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + } + + const std::string encoded = value.as_string(); + if (!encoded.starts_with(hex_quantity_prefix)) { + reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + } + + const std::string_view digits{encoded.data() + hex_quantity_prefix.size(), + encoded.size() - hex_quantity_prefix.size()}; + const bool hexadecimal_value = is_hexadecimal(digits); + if (digits.empty() || !hexadecimal_value || (digits.size() > 1 && digits.front() == '0')) { + reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, + field, + hexadecimal_value ? diagnostic_value(encoded) : ""); + } + if (digits.size() > max_uint256_hex_digits) { + reject(ethereum_transaction_policy_reason::rpc_quantity_out_of_range, field, diagnostic_value(encoded)); + } + + return fc::uint256(fc::from_hex(encoded)); +} + +std::string format_rpc_quantity(const fc::uint256& value) { + return std::string(hex_quantity_prefix) + value.str(0, std::ios_base::hex); +} + +void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy) { + if (!is_safe_transaction_policy_identifier(policy.client_id)) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "client_id", ""); + } + if (policy.chain_id == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "chain_id", "0"); + } + if (policy.max_priority_fee_per_gas == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, + "max_priority_fee_per_gas_wei", + "0"); + } + if (policy.max_fee_per_gas == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_fee_per_gas_wei", "0"); + } + if (policy.max_gas_limit == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_gas_limit", "0"); + } + if (policy.max_total_native_cost == 0) { + reject(ethereum_transaction_policy_reason::configuration_value_invalid, + "max_total_native_cost_wei", + "0"); + } + if (policy.max_priority_fee_per_gas > policy.max_fee_per_gas) { + reject(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_priority_fee_per_gas_wei", + policy.max_priority_fee_per_gas.str(), + policy.max_fee_per_gas.str()); + } +} + +fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, + const fc::uint256& priority_fee, + const fc::uint256& base_fee) { + if (priority_fee > policy.max_priority_fee_per_gas) { + reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + priority_fee.str(), + policy.max_priority_fee_per_gas.str()); + } + + const fc::uint256 maximum_base_fee = max_uint256() / max_fee_base_multiplier; + if (base_fee > maximum_base_fee) { + reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), + max_uint256().str()); + } + const fc::uint256 doubled_base_fee = base_fee * max_fee_base_multiplier; + const fc::uint256 remaining_fee_capacity = max_uint256() - doubled_base_fee; + if (priority_fee > remaining_fee_capacity) { + reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), + max_uint256().str()); + } + + const fc::uint256 max_fee = doubled_base_fee + priority_fee; + if (max_fee > policy.max_fee_per_gas) { + reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas", + max_fee.str(), + policy.max_fee_per_gas.str()); + } + return max_fee; +} + +fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, + const fc::uint256& estimated_gas) { + if (estimated_gas > policy.max_gas_limit) { + reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "estimated_gas", + estimated_gas.str(), + policy.max_gas_limit.str()); + } + + const fc::uint256 maximum_estimate = max_uint256() / gas_headroom_multiplier; + if (estimated_gas > maximum_estimate) { + reject(ethereum_transaction_policy_reason::gas_limit_derivation_overflow, + "gas_limit", + "estimated_gas=" + estimated_gas.str() + + ",multiplier=" + std::to_string(gas_headroom_multiplier), + max_uint256().str()); + } + + const fc::uint256 gas_limit = (estimated_gas * gas_headroom_multiplier) / gas_headroom_divisor; + if (gas_limit > policy.max_gas_limit) { + reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + gas_limit.str(), + policy.max_gas_limit.str()); + } + return gas_limit; +} + +void validate_transaction_against_policy(const ethereum_transaction_policy& policy, + const fc::crypto::ethereum::eip1559_tx& transaction) { + if (transaction.chain_id != policy.chain_id) { + reject(ethereum_transaction_policy_reason::chain_id_mismatch, + "chain_id", + transaction.chain_id.str(), + policy.chain_id.str()); + } + if (transaction.max_fee_per_gas < transaction.max_priority_fee_per_gas) { + reject(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + transaction.max_priority_fee_per_gas.str()); + } + if (transaction.max_priority_fee_per_gas > policy.max_priority_fee_per_gas) { + reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + transaction.max_priority_fee_per_gas.str(), + policy.max_priority_fee_per_gas.str()); + } + if (transaction.max_fee_per_gas > policy.max_fee_per_gas) { + reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + policy.max_fee_per_gas.str()); + } + if (transaction.gas_limit > policy.max_gas_limit) { + reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + transaction.gas_limit.str(), + policy.max_gas_limit.str()); + } + + const fc::uint256 maximum_fee_without_overflow = + transaction.gas_limit == 0 ? max_uint256() : fc::uint256{max_uint256() / transaction.gas_limit}; + if (transaction.max_fee_per_gas > maximum_fee_without_overflow) { + reject(ethereum_transaction_policy_reason::total_cost_multiplication_overflow, + "max_total_native_cost", + "gas_limit=" + transaction.gas_limit.str() + + ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), + max_uint256().str()); + } + const fc::uint256 maximum_gas_cost = transaction.gas_limit * transaction.max_fee_per_gas; + const fc::uint256 remaining_total_capacity = max_uint256() - maximum_gas_cost; + if (transaction.value > remaining_total_capacity) { + reject(ethereum_transaction_policy_reason::total_cost_addition_overflow, + "max_total_native_cost", + "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), + max_uint256().str()); + } + + const fc::uint256 maximum_total_cost = maximum_gas_cost + transaction.value; + if (maximum_total_cost > policy.max_total_native_cost) { + reject(ethereum_transaction_policy_reason::total_cost_cap_exceeded, + "max_total_native_cost", + maximum_total_cost.str(), + policy.max_total_native_cost.str()); + } +} + +} // namespace fc::network::ethereum diff --git a/libraries/libfc/test/CMakeLists.txt b/libraries/libfc/test/CMakeLists.txt index ec1cc0e211..7f8393ebd2 100644 --- a/libraries/libfc/test/CMakeLists.txt +++ b/libraries/libfc/test/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable( test_fc io/test_raw.cpp io/test_tracked_storage.cpp network/ethereum/test_ethereum_client_and_rlp.cpp + network/ethereum/test_ethereum_transaction_policy.cpp network/test_json_rpc_client.cpp network/solana/test_solana_client.cpp network/test_message_buffer.cpp diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp index e925b4b4a2..9c50071030 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_client_and_rlp.cpp @@ -381,6 +381,34 @@ BOOST_AUTO_TEST_CASE(can_encode_tx_01) try { } FC_LOG_AND_RETHROW(); +BOOST_AUTO_TEST_CASE(eip1559_rlp_preserves_transaction_fields_above_uint64) try { + const fc::uint256 two_to_64{"18446744073709551616"}; + auto wide_tx = test_tx_01; + wide_tx.chain_id = two_to_64 + 1; + wide_tx.nonce = two_to_64 + 2; + wide_tx.max_priority_fee_per_gas = two_to_64 + 3; + wide_tx.max_fee_per_gas = two_to_64 + 4; + wide_tx.gas_limit = two_to_64 + 5; + wide_tx.value = two_to_64 + 6; + wide_tx.data.clear(); + wide_tx.v = 0; + wide_tx.r = {}; + wide_tx.s = {}; + + const auto encoded_hex = rlp::to_hex(rlp::encode_eip1559_signed_typed(wide_tx), false); + const std::string expected_fields = + "89010000000000000001" + "89010000000000000002" + "89010000000000000003" + "89010000000000000004" + "89010000000000000005" + "945fbdb2315678afecb367f032d93f642f64180aa3" + "89010000000000000006" + "80c0"; + BOOST_CHECK(encoded_hex.contains(expected_fields)); +} +FC_LOG_AND_RETHROW(); + // Signed EIP-1559 r/s must be encoded as minimal big-endian integers. // Strict RLP decoders (alloy-rs used by reth/anvil) reject non-minimal // fixed-width 32-byte string encodings of r/s with "leading zero" when the diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp new file mode 100644 index 0000000000..aa38f87ff2 --- /dev/null +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -0,0 +1,284 @@ +#include + +#include + +#include +#include + +using namespace fc::crypto::ethereum; +using namespace fc::network::ethereum; + +namespace { + +constexpr std::string_view max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; +constexpr std::string_view above_max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639936"; + +ethereum_transaction_policy bounded_policy() { + return ethereum_transaction_policy{ + .client_id = "client-a", + .chain_id = 31337, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .max_gas_limit = 1000, + .max_total_native_cost = 100000, + }; +} + +ethereum_transaction_policy uint256_wide_policy() { + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + return ethereum_transaction_policy{ + .client_id = "client-a", + .chain_id = 31337, + .max_priority_fee_per_gas = maximum, + .max_fee_per_gas = maximum, + .max_gas_limit = maximum, + .max_total_native_cost = maximum, + }; +} + +eip1559_tx bounded_transaction() { + return eip1559_tx{ + .chain_id = 31337, + .nonce = 0, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .gas_limit = 1000, + .to = {}, + .value = 0, + .data = {}, + .access_list = {}, + }; +} + +void check_rejection_reason(const std::function& operation, + ethereum_transaction_policy_reason expected_reason) { + try { + operation(); + BOOST_FAIL("expected ethereum transaction policy rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == expected_reason); + BOOST_CHECK_EQUAL(reason_code_name(rejection.reason()), reason_code_name(expected_reason)); + } +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(ethereum_transaction_policy_tests) + +BOOST_AUTO_TEST_CASE(canonical_configuration_values_preserve_uint256_range) { + const auto maximum = parse_canonical_uint256_decimal(max_uint256_decimal, "limit"); + BOOST_CHECK_EQUAL(maximum.str(), max_uint256_decimal); + + check_rejection_reason( + [] { parse_canonical_uint256_decimal(above_max_uint256_decimal, "limit"); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + check_rejection_reason( + [] { parse_canonical_uint256_decimal("01", "limit"); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + check_rejection_reason( + [] { parse_canonical_uint256_decimal("0", "limit"); }, + ethereum_transaction_policy_reason::configuration_value_invalid); +} + +BOOST_AUTO_TEST_CASE(rpc_quantities_are_canonical_and_non_truncating) { + const std::string maximum_quantity = "0x" + std::string(64, 'f'); + BOOST_CHECK_EQUAL(parse_rpc_quantity(fc::variant(maximum_quantity), "nonce").str(), max_uint256_decimal); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{0}), "0x0"); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{127}), "0x7f"); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{128}), "0x80"); + BOOST_CHECK_EQUAL(format_rpc_quantity(fc::uint256{"18446744073709551617"}), + "0x10000000000000001"); + BOOST_CHECK_EQUAL(format_rpc_quantity(parse_rpc_quantity(fc::variant(maximum_quantity), "nonce")), + maximum_quantity); + + check_rejection_reason( + [] { parse_rpc_quantity(fc::variant("0x" + std::string(65, '1')), "nonce"); }, + ethereum_transaction_policy_reason::rpc_quantity_out_of_range); + check_rejection_reason( + [] { parse_rpc_quantity(fc::variant("0x01"), "nonce"); }, + ethereum_transaction_policy_reason::rpc_quantity_invalid); + check_rejection_reason( + [] { parse_rpc_quantity(fc::variant(uint64_t{1}), "nonce"); }, + ethereum_transaction_policy_reason::rpc_quantity_invalid); +} + +BOOST_AUTO_TEST_CASE(malformed_values_do_not_reappear_in_policy_diagnostics) { + constexpr std::string_view sensitive_url = "https://user:password@example.invalid/rpc?token=secret"; + try { + parse_rpc_quantity(fc::variant(std::string(sensitive_url)), "nonce"); + BOOST_FAIL("expected malformed RPC quantity rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } + + auto policy = bounded_policy(); + policy.client_id = sensitive_url; + try { + validate_transaction_policy_configuration(policy); + BOOST_FAIL("expected malformed client id rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } +} + +BOOST_AUTO_TEST_CASE(policy_configuration_requires_positive_consistent_limits) { + auto policy = bounded_policy(); + policy.chain_id = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_priority_fee_per_gas = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_fee_per_gas = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_gas_limit = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_total_native_cost = 0; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::configuration_value_invalid); + + policy = bounded_policy(); + policy.max_priority_fee_per_gas = policy.max_fee_per_gas + 1; + check_rejection_reason( + [&] { validate_transaction_policy_configuration(policy); }, + ethereum_transaction_policy_reason::fee_relationship_invalid); +} + +BOOST_AUTO_TEST_CASE(exact_caps_pass_and_each_cap_plus_one_rejects) { + const auto policy = bounded_policy(); + const auto exact = bounded_transaction(); + BOOST_CHECK_NO_THROW(validate_transaction_against_policy(policy, exact)); + + auto transaction = exact; + ++transaction.max_priority_fee_per_gas; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::priority_fee_cap_exceeded); + + transaction = exact; + ++transaction.max_fee_per_gas; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::max_fee_cap_exceeded); + + transaction = exact; + transaction.max_fee_per_gas = 99; + ++transaction.gas_limit; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::gas_limit_cap_exceeded); + + transaction = exact; + transaction.gas_limit = 999; + transaction.value = 101; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_cap_exceeded); +} + +BOOST_AUTO_TEST_CASE(nonzero_value_is_included_in_the_total_cost) { + const auto policy = bounded_policy(); + auto transaction = bounded_transaction(); + transaction.gas_limit = 999; + + transaction.value = 99; + BOOST_CHECK_NO_THROW(validate_transaction_against_policy(policy, transaction)); + + transaction.value = 100; + BOOST_CHECK_NO_THROW(validate_transaction_against_policy(policy, transaction)); + + ++transaction.value; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_cap_exceeded); +} + +BOOST_AUTO_TEST_CASE(fee_relation_and_chain_domain_are_enforced) { + const auto policy = bounded_policy(); + auto transaction = bounded_transaction(); + transaction.max_fee_per_gas = transaction.max_priority_fee_per_gas - 1; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::fee_relationship_invalid); + + transaction = bounded_transaction(); + ++transaction.chain_id; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::chain_id_mismatch); +} + +BOOST_AUTO_TEST_CASE(total_cost_multiplication_and_addition_overflow_reject) { + const auto policy = uint256_wide_policy(); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + auto transaction = bounded_transaction(); + transaction.max_priority_fee_per_gas = 1; + transaction.max_fee_per_gas = maximum; + transaction.gas_limit = 2; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_multiplication_overflow); + + transaction.max_fee_per_gas = maximum - 1; + transaction.gas_limit = 1; + transaction.value = 2; + check_rejection_reason( + [&] { validate_transaction_against_policy(policy, transaction); }, + ethereum_transaction_policy_reason::total_cost_addition_overflow); +} + +BOOST_AUTO_TEST_CASE(fee_derivation_checks_each_intermediate_and_caps_the_result) { + const auto wide_policy = uint256_wide_policy(); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + check_rejection_reason( + [&] { derive_max_fee_per_gas(wide_policy, 1, maximum / 2 + 1); }, + ethereum_transaction_policy_reason::max_fee_derivation_overflow); + check_rejection_reason( + [&] { derive_max_fee_per_gas(wide_policy, 2, maximum / 2); }, + ethereum_transaction_policy_reason::max_fee_derivation_overflow); + + const auto policy = bounded_policy(); + BOOST_CHECK_EQUAL(derive_max_fee_per_gas(policy, 10, 45), 100); + check_rejection_reason( + [&] { derive_max_fee_per_gas(policy, 10, 46); }, + ethereum_transaction_policy_reason::max_fee_cap_exceeded); + check_rejection_reason( + [&] { derive_max_fee_per_gas(policy, 11, 1); }, + ethereum_transaction_policy_reason::priority_fee_cap_exceeded); +} + +BOOST_AUTO_TEST_CASE(gas_headroom_checks_multiplication_and_final_cap) { + const auto wide_policy = uint256_wide_policy(); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + check_rejection_reason( + [&] { derive_buffered_gas_limit(wide_policy, maximum / 6 + 1); }, + ethereum_transaction_policy_reason::gas_limit_derivation_overflow); + + const auto policy = bounded_policy(); + BOOST_CHECK_EQUAL(derive_buffered_gas_limit(policy, 833), 999); + BOOST_CHECK_EQUAL(derive_buffered_gas_limit(policy, 834), 1000); + check_rejection_reason( + [&] { derive_buffered_gas_limit(policy, 835); }, + ethereum_transaction_policy_reason::gas_limit_cap_exceeded); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp index 1be9389344..d69b4cc6ac 100644 --- a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp +++ b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp @@ -6,23 +6,34 @@ #include #include +#include +#include +#include +#include +#include +#include + namespace sysio { using namespace fc::crypto::ethereum; using namespace fc::network::ethereum; struct ethereum_client_entry_t { + /** Configuration identifier used to select this connection. */ std::string id; - std::string url; + /** Provider used by the policy-enforcing client at the final signing boundary. */ fc::crypto::signature_provider_ptr signature_provider; + /** Ethereum JSON-RPC client with its immutable local transaction policy. */ ethereum_client_ptr client; - /// Numeric EVM chain id from the client spec's optional 4th field. Lets the - /// batch operator auto-select the client for an outpost row by matching the - /// row's `external_chain_id`, so multiple EVM outposts never share one - /// remote endpoint. `nullopt` when the spec omitted the chain id. - std::optional chain_id; + /** Authoritative EVM chain id from the client's policy. */ + uint32_t chain_id; }; using ethereum_client_entry_ptr = std::shared_ptr; +using ethereum_transaction_policy_map = std::map; + +/** Load and strictly validate a versioned per-client Ethereum transaction-policy file. */ +ethereum_transaction_policy_map +load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file); /// Typed contract client for OPP.sol. State-changing calls go through /// `create_tx_and_confirm` — OPP writes are consensus-critical and must @@ -102,12 +113,9 @@ class outpost_ethereum_client_plugin : public appbase::plugin get_clients(); ethereum_client_entry_ptr get_client(const std::string& id); - /// Return the single configured client whose spec chain id equals - /// `chain_id`, or nullptr when none — or more than one — match. The batch - /// operator uses this to bind each EVM outpost row to its own RPC client by - /// `external_chain_id`; an ambiguous (duplicate chain id) or missing match - /// yields nullptr so the caller can fail closed rather than relay an - /// outpost through the wrong endpoint. + /// Return the single configured client whose policy chain id equals `chain_id`, or nullptr when + /// none — or more than one — match. Ambiguous same-chain clients fail closed because this lookup + /// has no separate endpoint-selection signal. ethereum_client_entry_ptr get_client_by_chain_id(uint64_t chain_id); const std::vector>>& get_abi_files(); diff --git a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp index 7f3cb356e3..27962ab965 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp @@ -1,22 +1,245 @@ -#include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include + namespace sysio { // using namespace outpost_client::ethereum; namespace { -constexpr auto option_name_client = "outpost-ethereum-client"; -constexpr auto option_abi_file = "ethereum-abi-file"; +constexpr auto option_name_client = "outpost-ethereum-client"; +constexpr auto option_name_transaction_policy_file = "outpost-ethereum-transaction-policy-file"; +constexpr auto option_abi_file = "ethereum-abi-file"; + +constexpr auto root_field_version = "version"; +constexpr auto root_field_policies = "policies"; +constexpr auto policy_field_client_id = "client_id"; +constexpr auto policy_field_chain_id = "chain_id"; +constexpr auto policy_field_max_priority_fee_per_gas_wei = "max_priority_fee_per_gas_wei"; +constexpr auto policy_field_max_fee_per_gas_wei = "max_fee_per_gas_wei"; +constexpr auto policy_field_max_gas_limit = "max_gas_limit"; +constexpr auto policy_field_max_total_native_cost_wei = "max_total_native_cost_wei"; +constexpr uint64_t transaction_policy_schema_version = 1; + +constexpr std::array root_fields{ + root_field_version, + root_field_policies, +}; +constexpr std::array policy_fields{ + policy_field_client_id, + policy_field_chain_id, + policy_field_max_priority_fee_per_gas_wei, + policy_field_max_fee_per_gas_wei, + policy_field_max_gas_limit, + policy_field_max_total_native_cost_wei, +}; + +/** Parsed non-secret fields of one `--outpost-ethereum-client` specification. */ +struct ethereum_client_spec { + std::string id; + std::string signature_provider_id; + std::string url; + std::optional chain_id; +}; +/** Throw a structured, sanitized plugin-configuration rejection. */ +[[noreturn]] void reject_configuration(ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed = std::nullopt) { + throw ethereum_transaction_policy_exception( + reason, std::string(field), std::move(observed), std::move(allowed)); +} + +/** Require an object to contain each expected field exactly once and no unknown fields. */ +template +void validate_exact_fields(const fc::variant_object& object, + const std::array& expected, + std::string_view context) { + std::set observed_fields; + for (const auto& entry : object) { + const bool known = std::ranges::find(expected, entry.key()) != expected.end(); + if (!known) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); + } + if (!observed_fields.insert(entry.key()).second) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); + } + } + + for (const auto field : expected) { + if (!observed_fields.contains(std::string(field))) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + "missing=" + std::string(field)); + } + } +} + +/** Read a required JSON string without coercing another JSON type. */ +std::string require_string_field(const fc::variant_object& object, std::string_view field) { + const auto& value = object[std::string(field)]; + if (!value.is_string()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + field, + ""); + } + return value.as_string(); +} + +/** Parse an externally registered chain id without truncation, then enforce its uint32 domain. */ +uint32_t require_external_chain_id(std::string_view value, std::string_view field) { + const fc::uint256 chain_id = parse_canonical_uint256_decimal(value, field); + constexpr auto max_external_chain_id = std::numeric_limits::max(); + if (chain_id > max_external_chain_id) { + reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, + field, + chain_id.str(), + std::to_string(max_external_chain_id)); + } + return chain_id.convert_to(); +} + +/** Parse a client specification while keeping its credential-bearing URL out of diagnostics. */ +ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { + auto parts = fc::split(encoded_spec, ','); + if (parts.size() != 3 && parts.size() != 4) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_client, + "field_count=" + std::to_string(parts.size()), + "3 or 4"); + } + if (parts[0].empty() || parts[1].empty() || parts[2].empty()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, + option_name_client, + ""); + } + if (!is_safe_transaction_policy_identifier(parts[0])) { + reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field_client_id, + ""); + } + + ethereum_client_spec result{ + .id = parts[0], + .signature_provider_id = parts[1], + .url = parts[2], + }; + if (parts.size() == 4) { + result.chain_id = require_external_chain_id(parts[3], "client_spec.chain_id"); + } + return result; +} + +/** Return this plugin's logger for appbase log macros. */ [[maybe_unused]] inline fc::logger& logger() { static fc::logger log{"outpost_ethereum_client_plugin"}; return log; } } +ethereum_transaction_policy_map +load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) { + std::ifstream readable_policy_file{policy_file}; + if (!readable_policy_file.is_open()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); + } + + fc::variant document; + try { + document = fc::json::from_file(policy_file, fc::json::parse_type::strict_parser); + } catch (const fc::exception&) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); + } catch (const std::exception&) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); + } + + if (!document.is_object()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "root", + ""); + } + const auto root = document.get_object(); + validate_exact_fields(root, root_fields, "root"); + + const auto& version = root[root_field_version]; + const bool supported_version = + (version.is_uint64() && version.as_uint64() == transaction_policy_schema_version) || + (version.is_int64() && version.as_int64() == transaction_policy_schema_version); + if (!supported_version) { + reject_configuration(ethereum_transaction_policy_reason::configuration_version_unsupported, + root_field_version, + "", + std::to_string(transaction_policy_schema_version)); + } + + const auto& policies_value = root[root_field_policies]; + if (!policies_value.is_array()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + root_field_policies, + ""); + } + + ethereum_transaction_policy_map result; + for (const auto& policy_value : policies_value.get_array()) { + if (!policy_value.is_object()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "policy", + ""); + } + const auto policy_object = policy_value.get_object(); + validate_exact_fields(policy_object, policy_fields, "policy"); + + const auto client_id = require_string_field(policy_object, policy_field_client_id); + ethereum_transaction_policy policy{ + .client_id = client_id, + .chain_id = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id), + .max_priority_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), + policy_field_max_priority_fee_per_gas_wei), + .max_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_fee_per_gas_wei), + policy_field_max_fee_per_gas_wei), + .max_gas_limit = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_gas_limit), policy_field_max_gas_limit), + .max_total_native_cost = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_total_native_cost_wei), + policy_field_max_total_native_cost_wei), + }; + validate_transaction_policy_configuration(policy); + require_external_chain_id(policy.chain_id.str(), policy_field_chain_id); + + if (!result.emplace(client_id, std::move(policy)).second) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + client_id); + } + } + return result; +} + class outpost_ethereum_client_plugin_impl { std::map _clients{}; using file_abi_contracts_t = std::pair>; @@ -49,7 +272,7 @@ class outpost_ethereum_client_plugin_impl { ethereum_client_entry_ptr get_client_by_chain_id(uint64_t chain_id) { ethereum_client_entry_ptr match; for (auto& [id, entry] : _clients) { - if (entry->chain_id && *entry->chain_id == chain_id) { + if (entry->chain_id == chain_id) { if (match) return nullptr; // ambiguous: >1 client on this chain id match = entry; } @@ -70,41 +293,103 @@ class outpost_ethereum_client_plugin_impl { }; void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& options) { - if (options.contains(option_abi_file)) { - auto& abi_files = options.at(option_abi_file).as>(); - my->load_abi_files(abi_files); - } - FC_ASSERT(options.count(option_name_client), "At least one ethereum client argument is required {}", option_name_client); - - // This plugin APPBASE_PLUGIN_REQUIRES the signature_provider_manager_plugin, which creates every configured provider - // at its own plugin_initialize (failing the boot there on a misconfigured or not-enabled scheme). So by the time - // this runs, every provider already exists regardless of `--plugin` ordering, and clients can be resolved and - // constructed here rather than deferred to startup. - auto& sig_mgr = app().get_plugin(); - auto client_specs = options.at(option_name_client).as>(); - for (auto& client_spec : client_specs) { - dlog("Adding ethereum client with spec: {}", client_spec); - auto parts = fc::split(client_spec, ','); - FC_ASSERT(parts.size() == 3 || parts.size() == 4, "Invalid spec {}", client_spec); - auto& id = parts[0]; - auto& sig_id = parts[1]; - auto& url = parts[2]; - fc::ostring chain_id_str = parts.size() == 4 ? fc::ostring{parts[3]} : fc::ostring{}; - std::optional chain_id; - std::optional chain_id_num; - if (chain_id_str.has_value()) { - chain_id = std::make_optional(fc::to_uint256(chain_id_str.value())); - chain_id_num = chain_id->convert_to(); + try { + if (options.contains(option_abi_file)) { + const auto abi_files = options.at(option_abi_file).as>(); + my->load_abi_files(abi_files); + } + if (!options.contains(option_name_client)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); + } + if (!options.contains(option_name_transaction_policy_file)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); } - auto sig_provider = sig_mgr.get_provider(sig_id); - my->add_client(id, - std::make_shared( - id, url, sig_provider, - std::make_shared(sig_provider, url, chain_id), - chain_id_num)); - ilog("Added ethereum client (id={},sig_id={},url={},chainId={})", id, sig_id, url, - chain_id_num ? std::to_string(*chain_id_num) : "none"); + const auto policy_file = options.at(option_name_transaction_policy_file).as(); + auto policies = load_ethereum_transaction_policy_file(policy_file); + + // This required plugin has already initialized every configured provider, independent of `--plugin` ordering. + auto& signature_manager = app().get_plugin(); + const auto encoded_client_specs = options.at(option_name_client).as>(); + if (encoded_client_specs.empty()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); + } + + std::vector client_specs; + std::set configured_client_ids; + client_specs.reserve(encoded_client_specs.size()); + for (const auto& encoded_spec : encoded_client_specs) { + auto spec = parse_client_spec(encoded_spec); + if (!configured_client_ids.insert(spec.id).second) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + spec.id); + } + client_specs.emplace_back(std::move(spec)); + } + + for (const auto& [client_id, policy] : policies) { + if (!configured_client_ids.contains(client_id)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_unknown, + policy_field_client_id, + client_id); + } + } + + for (const auto& spec : client_specs) { + const auto policy_iterator = policies.find(spec.id); + if (policy_iterator == policies.end()) { + reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, + policy_field_client_id, + spec.id); + } + const auto& policy = policy_iterator->second; + if (spec.chain_id && fc::uint256{*spec.chain_id} != policy.chain_id) { + reject_configuration(ethereum_transaction_policy_reason::configuration_chain_id_mismatch, + "client_spec.chain_id", + std::to_string(*spec.chain_id), + policy.chain_id.str()); + } + + if (!signature_manager.has_provider(spec.signature_provider_id)) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "signature_provider", + ""); + } + const auto signature_provider = signature_manager.get_provider(spec.signature_provider_id); + const auto chain_id = policy.chain_id.convert_to(); + ethereum_client_ptr client; + try { + client = std::make_shared(signature_provider, spec.url, policy); + } catch (const ethereum_transaction_policy_exception&) { + throw; + } catch (const std::exception&) { + reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, + "client_spec.url", + ""); + } + my->add_client(spec.id, + std::make_shared( + spec.id, + signature_provider, + std::move(client), + chain_id)); + + ilog("Added policy-constrained ethereum client client_id={} chain_id={}", spec.id, chain_id); + } + } catch (const ethereum_transaction_policy_exception& rejection) { + elog("Ethereum transaction policy configuration rejected reason_code={} field={} observed={} allowed={}", + reason_code_name(rejection.reason()), + rejection.field(), + rejection.observed(), + rejection.allowed().value_or("n/a")); + throw; } } @@ -122,6 +407,10 @@ void outpost_ethereum_client_plugin::set_program_options(options_description& cl boost::program_options::value>()->multitoken(), "Outpost Ethereum Client spec, the plugin supports 1 to many clients in a given process" "`,,[,]`")( + option_name_transaction_policy_file, + boost::program_options::value(), + "Versioned JSON file containing exactly one finite transaction expenditure policy per configured " + "Ethereum client id and chain id")( option_abi_file, boost::program_options::value>()->multitoken(), "Ethereum contract ABI file(s). Expects the file to have a JSON array of ABI complient contract definitions." @@ -151,13 +440,29 @@ const std::vector outpost_ethereum_client_plugin::create_outpost_client(const std::string& eth_client_id, - uint64_t chain_code, - uint32_t chain_id, - const std::string& opp_addr, - const std::string& opp_inbound_addr, - const std::string& operator_registry_addr) { + uint64_t chain_code, + uint32_t chain_id, + const std::string& opp_addr, + const std::string& opp_inbound_addr, + const std::string& operator_registry_addr) { auto entry = my->get_client(eth_client_id); FC_ASSERT(entry, "Unknown ethereum client id: {}", eth_client_id); + if (entry->chain_id != chain_id) { + ethereum_transaction_policy_exception rejection{ + ethereum_transaction_policy_reason::configuration_chain_id_mismatch, + "outpost.chain_id", + std::to_string(chain_id), + std::to_string(entry->chain_id), + }; + elog("Ethereum transaction policy client binding rejected reason_code={} client_id={} field={} " + "observed={} allowed={}", + reason_code_name(rejection.reason()), + entry->id, + rejection.field(), + rejection.observed(), + rejection.allowed().value_or("n/a")); + throw rejection; + } std::vector all_abis; for (auto& [path, contracts] : my->get_abi_files()) { diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp new file mode 100644 index 0000000000..70c26a7984 --- /dev/null +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -0,0 +1,610 @@ +#include + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace fc::crypto; +using namespace fc::crypto::ethereum; +using namespace fc::network::ethereum; +namespace ethabi = fc::network::ethereum::abi; + +namespace { + +constexpr std::string_view fake_rpc_url = "http://127.0.0.1:1"; +constexpr std::string_view contract_address = "5FbDB2315678afecb367f032d93F642f64180aa3"; +constexpr std::string_view transaction_hash = + "0x1111111111111111111111111111111111111111111111111111111111111111"; +constexpr std::string_view signer_public_key = + "0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed7535" + "47f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5"; +constexpr std::string_view signer_private_key = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +constexpr std::string_view max_uint256_decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + +ethereum_transaction_policy bounded_policy(std::string client_id = "client-a") { + return ethereum_transaction_policy{ + .client_id = std::move(client_id), + .chain_id = 31337, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .max_gas_limit = 1000, + .max_total_native_cost = 100000, + }; +} + +ethabi::contract no_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {}, + .outputs = {}, + }; +} + +ethabi::contract bytes_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {ethabi::component_type{"data", ethabi::data_type::bytes}}, + .outputs = {}, + }; +} + +ethabi::contract uint32_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {ethabi::component_type{"wireEpochIndex", ethabi::data_type::uint32}}, + .outputs = {}, + }; +} + +signature_provider_ptr make_recording_signer(std::atomic& sign_count) { + auto private_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); + auto ethereum_key = private_key.get(); + auto provider = std::make_shared(); + provider->target_chain = chain_kind_ethereum; + provider->key_type = chain_key_type_ethereum; + provider->key_name = "recording-signer"; + provider->public_key = private_key.get_public_key(); + provider->private_key.reset(); + provider->sign = [ethereum_key, &sign_count](const fc::sha256& digest) { + ++sign_count; + const fc::crypto::keccak256 ethereum_digest{digest.str()}; + return signature(signature::storage_type(ethereum_key.sign_keccak256(ethereum_digest))); + }; + return provider; +} + +class recording_ethereum_client final : public ethereum_client { +public: + recording_ethereum_client(const signature_provider_ptr& provider, + ethereum_transaction_policy policy) + : ethereum_client(provider, std::string(fake_rpc_url), std::move(policy)) {} + + fc::variant execute(const std::string& method, const fc::variant& params) override { + methods.emplace_back(method); + if (method == "eth_maxPriorityFeePerGas") return fc::variant("0xa"); + if (method == "eth_getBlockByNumber") { + return fc::variant(fc::mutable_variant_object("baseFeePerGas", "0x2d")); + } + if (method == "eth_estimateGas") { + estimate_gas_params = params; + return fc::variant("0x342"); + } + if (method == "eth_getTransactionCount") return fc::variant("0x0"); + if (method == "eth_sendRawTransaction") { + ++broadcast_count; + return fc::variant(std::string(transaction_hash)); + } + FC_THROW_EXCEPTION(fc::invalid_arg_exception, "unexpected fake RPC method {}", method); + } + + std::vector methods; + fc::variant estimate_gas_params; + size_t broadcast_count = 0; +}; + +eip1559_tx exact_transaction() { + return eip1559_tx{ + .chain_id = 31337, + .nonce = 0, + .max_priority_fee_per_gas = 10, + .max_fee_per_gas = 100, + .gas_limit = 1000, + .to = fc::crypto::ethereum::to_address(std::string(contract_address)), + .value = 0, + .data = {}, + .access_list = {}, + }; +} + +void expect_policy_rejection(const std::function& operation) { + BOOST_CHECK_THROW(operation(), ethereum_transaction_policy_exception); +} + +std::filesystem::path write_policy_file(fc::temp_directory& directory, std::string_view contents) { + const auto path = directory.path() / "ethereum-transaction-policy.json"; + std::ofstream output{path}; + output << contents; + output.close(); + return path; +} + +void with_initialized_outpost_plugin( + const std::filesystem::path& policy_file, + const std::vector& client_specs, + const std::function& inspect_plugin) { + auto reset_application = gsl_lite::finally([] { appbase::application::reset_app_singleton(); }); + appbase::scoped_app test_application{}; + + const std::string signature_spec = + "signer-a,ethereum,ethereum," + std::string(signer_public_key) + ",KEY:" + + std::string(signer_private_key); + std::vector arguments{ + "test_outpost_ethereum_transaction_policy", + "--signature-provider", + signature_spec, + "--outpost-ethereum-transaction-policy-file", + policy_file.string(), + }; + for (const auto& client_spec : client_specs) { + arguments.emplace_back("--outpost-ethereum-client"); + arguments.emplace_back(client_spec); + } + + std::vector argv; + argv.reserve(arguments.size()); + for (auto& argument : arguments) argv.emplace_back(argument.data()); + + if (!test_application->initialize(argv.size(), argv.data())) { + FC_THROW_EXCEPTION(fc::invalid_arg_exception, "test application initialization returned false"); + } + inspect_plugin(test_application->get_plugin()); +} + +std::vector +initialize_outpost_plugin(const std::filesystem::path& policy_file, + const std::vector& client_specs) { + std::vector clients; + with_initialized_outpost_plugin( + policy_file, + client_specs, + [&](auto& plugin) { clients = plugin.get_clients(); }); + return clients; +} + +ethereum_transaction_policy_reason startup_rejection_reason( + const std::filesystem::path& policy_file, + const std::vector& client_specs) { + try { + initialize_outpost_plugin(policy_file, client_specs); + BOOST_FAIL("expected startup policy rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + return rejection.reason(); + } + return ethereum_transaction_policy_reason::configuration_schema_invalid; +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(outpost_ethereum_transaction_policy_tests) + +BOOST_AUTO_TEST_CASE(final_signing_boundary_rejects_without_signing_or_broadcasting) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto client = std::make_shared(provider, bounded_policy()); + const auto contract = no_argument_function("submit"); + + std::vector rejected_transactions; + auto transaction = exact_transaction(); + ++transaction.max_priority_fee_per_gas; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + ++transaction.max_fee_per_gas; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + ++transaction.gas_limit; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + transaction.gas_limit = 999; + transaction.value = 101; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + transaction.max_fee_per_gas = 9; + rejected_transactions.emplace_back(transaction); + transaction = exact_transaction(); + ++transaction.chain_id; + rejected_transactions.emplace_back(transaction); + + for (const auto& rejected : rejected_transactions) { + expect_policy_rejection([&] { client->execute_contract_tx_fn(rejected, contract); }); + } + + auto wide_policy = bounded_policy("wide-client"); + const fc::uint256 maximum{std::string(max_uint256_decimal)}; + wide_policy.max_priority_fee_per_gas = maximum; + wide_policy.max_fee_per_gas = maximum; + wide_policy.max_gas_limit = maximum; + wide_policy.max_total_native_cost = maximum; + auto wide_client = std::make_shared(provider, wide_policy); + + transaction = exact_transaction(); + transaction.max_priority_fee_per_gas = 1; + transaction.max_fee_per_gas = maximum; + transaction.gas_limit = 2; + expect_policy_rejection([&] { wide_client->execute_contract_tx_fn(transaction, contract); }); + + transaction.max_fee_per_gas = maximum - 1; + transaction.gas_limit = 1; + transaction.value = 2; + expect_policy_rejection([&] { wide_client->execute_contract_tx_fn(transaction, contract); }); + + BOOST_CHECK_EQUAL(sign_count.load(), 0u); + BOOST_CHECK_EQUAL(client->broadcast_count, 0u); + BOOST_CHECK_EQUAL(wide_client->broadcast_count, 0u); +} + +BOOST_AUTO_TEST_CASE(exact_caps_reach_the_signer_and_broadcast_once) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto client = std::make_shared(provider, bounded_policy()); + + const auto result = client->execute_contract_tx_fn(exact_transaction(), no_argument_function("submit")); + BOOST_CHECK_EQUAL(result.as_string(), transaction_hash); + BOOST_CHECK_EQUAL(sign_count.load(), 1u); + BOOST_CHECK_EQUAL(client->broadcast_count, 1u); +} + +BOOST_AUTO_TEST_CASE(two_clients_enforce_their_own_runtime_policies) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + + auto client_a = std::make_shared(provider, bounded_policy("client-a")); + auto policy_b = bounded_policy("client-b"); + policy_b.chain_id = 1; + policy_b.max_fee_per_gas = 99; + auto client_b = std::make_shared(provider, policy_b); + + BOOST_CHECK_NO_THROW( + client_a->execute_contract_tx_fn(exact_transaction(), no_argument_function("submit"))); + + auto transaction_b = exact_transaction(); + transaction_b.chain_id = 1; + expect_policy_rejection( + [&] { client_b->execute_contract_tx_fn(transaction_b, no_argument_function("submit")); }); + + BOOST_CHECK_EQUAL(sign_count.load(), 1u); + BOOST_CHECK_EQUAL(client_a->broadcast_count, 1u); + BOOST_CHECK_EQUAL(client_b->broadcast_count, 0u); +} + +BOOST_AUTO_TEST_CASE(default_transaction_uses_priority_fee_in_estimate_payload_and_local_chain_id) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto client = std::make_shared(provider, bounded_policy()); + + const auto transaction = client->create_default_tx( + std::string(contract_address), no_argument_function("submit")); + BOOST_CHECK_EQUAL(transaction.chain_id, 31337); + BOOST_CHECK_EQUAL(transaction.max_priority_fee_per_gas, 10); + BOOST_CHECK_EQUAL(transaction.max_fee_per_gas, 100); + BOOST_CHECK_EQUAL(transaction.gas_limit, 1000); + + const fc::uint256 wide_priority{"18446744073709551617"}; + const fc::uint256 wide_max_fee{"18446744073709551618"}; + client->estimate_gas(std::string(contract_address), + no_argument_function("submit"), + std::string{}, + ethereum_client::gas_config_t{ + .tip = wide_priority, + .max_fee_per_gas = wide_max_fee, + }); + + const auto estimate_params = client->estimate_gas_params.get_array(); + BOOST_REQUIRE_EQUAL(estimate_params.size(), 1u); + const auto estimate_transaction = estimate_params.front().get_object(); + BOOST_CHECK_EQUAL(estimate_transaction["maxPriorityFeePerGas"].as_string(), + "0x10000000000000001"); + BOOST_CHECK_EQUAL(estimate_transaction["maxFeePerGas"].as_string(), + "0x10000000000000002"); + BOOST_CHECK_EQUAL(std::ranges::count(client->methods, "eth_maxPriorityFeePerGas"), 1u); + BOOST_CHECK_EQUAL(std::ranges::count(client->methods, "eth_getBlockByNumber"), 1u); + BOOST_CHECK(std::ranges::find(client->methods, "eth_chainId") == client->methods.end()); + BOOST_CHECK_EQUAL(sign_count.load(), 0u); +} + +BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { + std::atomic sign_count = 0; + const auto provider = make_recording_signer(sign_count); + auto policy = bounded_policy(); + policy.max_gas_limit = 999; + auto client = std::make_shared(provider, policy); + + sysio::opp_inbound_contract_client inbound{ + client, + std::string(contract_address), + {bytes_argument_function("epochIn"), no_argument_function("nextEpochIndex")}, + }; + std::string envelope = "01"; + expect_policy_rejection([&] { inbound.epoch_in(envelope); }); + + sysio::operator_registry_contract_client registry{ + client, + std::string(contract_address), + {bytes_argument_function("commit")}, + }; + std::string commitment = "01"; + expect_policy_rejection([&] { registry.commit(commitment); }); + + sysio::opp_contract_client opp{ + client, + std::string(contract_address), + {uint32_argument_function("emitOutboundEnvelope"), no_argument_function("getLatestOutboundEnvelope")}, + }; + uint32_t epoch = 1; + expect_policy_rejection([&] { opp.emit_outbound_envelope(epoch); }); + + BOOST_CHECK_EQUAL(sign_count.load(), 0u); + BOOST_CHECK_EQUAL(client->broadcast_count, 0u); +} + +BOOST_AUTO_TEST_CASE(policy_file_loads_two_independent_client_chain_entries) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [ + { + "client_id": "ethereum-mainnet", + "chain_id": "1", + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + }, + { + "client_id": "ethereum-sepolia", + "chain_id": "11155111", + "max_priority_fee_per_gas_wei": "3000000000", + "max_fee_per_gas_wei": "120000000000", + "max_gas_limit": "3000000", + "max_total_native_cost_wei": "500000000000000000" + } + ] + })json"); + + const auto policies = sysio::load_ethereum_transaction_policy_file(path); + BOOST_REQUIRE_EQUAL(policies.size(), 2u); + BOOST_CHECK_EQUAL(policies.at("ethereum-mainnet").chain_id, 1); + BOOST_CHECK_EQUAL(policies.at("ethereum-sepolia").chain_id, 11155111); + BOOST_CHECK(policies.at("ethereum-mainnet").max_fee_per_gas != + policies.at("ethereum-sepolia").max_fee_per_gas); +} + +BOOST_AUTO_TEST_CASE(policy_file_rejects_schema_duplicates_ranges_and_sensitive_unknown_fields) { + fc::temp_directory directory; + expect_policy_rejection([&] { + sysio::load_ethereum_transaction_policy_file(directory.path() / "missing.json"); + }); + + auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "client_id": "client-b", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }, { + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + try { + sysio::load_ethereum_transaction_policy_file(path); + BOOST_FAIL("expected duplicate policy rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == + ethereum_transaction_policy_reason::configuration_client_duplicate); + } + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "4294967296", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + + constexpr std::string_view sensitive_url = "https://user:password@example.invalid/rpc?token=secret"; + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [], + "https://user:password@example.invalid/rpc?token=secret": true + })json"); + try { + sysio::load_ethereum_transaction_policy_file(path); + BOOST_FAIL("expected unknown-field rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } +} + +BOOST_AUTO_TEST_CASE(plugin_startup_requires_exact_client_coverage_and_matching_chain) { + fc::temp_directory directory; + const std::vector client_specs{ + "client-a,signer-a,http://127.0.0.1:1,31337", + }; + + auto path = write_policy_file(directory, R"json({"version":1,"policies":[]})json"); + BOOST_CHECK(startup_rejection_reason(path, client_specs) == + ethereum_transaction_policy_reason::configuration_client_missing); + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "orphan-client", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + BOOST_CHECK(startup_rejection_reason(path, client_specs) == + ethereum_transaction_policy_reason::configuration_client_unknown); + + path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "1", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + BOOST_CHECK(startup_rejection_reason(path, client_specs) == + ethereum_transaction_policy_reason::configuration_chain_id_mismatch); +} + +BOOST_AUTO_TEST_CASE(plugin_startup_attaches_two_matching_client_chain_policies) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }, { + "client_id": "client-b", + "chain_id": "1", + "max_priority_fee_per_gas_wei": "20", + "max_fee_per_gas_wei": "200", + "max_gas_limit": "2000", + "max_total_native_cost_wei": "200000" + }] + })json"); + const auto clients = initialize_outpost_plugin( + path, + {"client-a,signer-a,http://127.0.0.1:1,31337", + "client-b,signer-a,http://127.0.0.1:1,1"}); + BOOST_REQUIRE_EQUAL(clients.size(), 2u); + BOOST_CHECK_EQUAL(clients.front()->id, "client-a"); + BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); + BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().max_total_native_cost, 100000); + BOOST_CHECK_EQUAL(clients.back()->id, "client-b"); + BOOST_CHECK_EQUAL(clients.back()->chain_id, 1); + BOOST_CHECK_EQUAL(clients.back()->client->transaction_policy().max_total_native_cost, 200000); +} + +BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + + with_initialized_outpost_plugin( + path, + {"client-a,signer-a,http://127.0.0.1:1,31337"}, + [&](auto& plugin) { + try { + plugin.create_outpost_client( + "client-a", + 1, + 1, + std::string(contract_address), + std::string(contract_address), + std::string(contract_address)); + BOOST_FAIL("expected client/outpost chain mismatch rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == + ethereum_transaction_policy_reason::configuration_chain_id_mismatch); + BOOST_CHECK_EQUAL(rejection.observed(), "1"); + BOOST_REQUIRE(rejection.allowed().has_value()); + BOOST_CHECK_EQUAL(*rejection.allowed(), "31337"); + } + }); +} + +BOOST_AUTO_TEST_CASE(plugin_startup_redacts_an_invalid_authenticated_rpc_url) { + fc::temp_directory directory; + const auto path = write_policy_file(directory, R"json({ + "version": 1, + "policies": [{ + "client_id": "client-a", + "chain_id": "31337", + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + }] + })json"); + constexpr std::string_view sensitive_url = + "http://user:password@localhost:not-a-port/rpc?token=secret"; + try { + initialize_outpost_plugin( + path, + {"client-a,signer-a," + std::string(sensitive_url) + ",31337"}); + BOOST_FAIL("expected invalid URL rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == + ethereum_transaction_policy_reason::configuration_schema_invalid); + BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); + BOOST_CHECK(rejection.to_detail_string().find(sensitive_url) == std::string::npos); + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp index 5d75f399ce..a904e775df 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp @@ -187,6 +187,18 @@ constexpr size_t rpc_length_oversized_envelope_bytes = sysio::OPP_MAX_ENVELOPE_B constexpr char malformed_envelope_byte = static_cast(0xff); constexpr char oversized_envelope_fill_byte = static_cast(0x01); +ethereum_transaction_policy test_transaction_policy(std::string client_id, + uint32_t chain_id = test_evm_chain_id) { + return ethereum_transaction_policy{ + .client_id = std::move(client_id), + .chain_id = chain_id, + .max_priority_fee_per_gas = 100000000000, + .max_fee_per_gas = 1000000000000, + .max_gas_limit = 10000000, + .max_total_native_cost = fc::uint256{"1000000000000000000"}, + }; +} + auto load_abi_fixture(std::string_view filename) { auto path = fc::test::get_test_fixtures_path() / bfs::path(filename); return fc::network::ethereum::abi::parse_contracts(std::filesystem::path(path.generic_string())); @@ -359,14 +371,13 @@ BOOST_AUTO_TEST_CASE(read_inbound_envelope_validates_latest_slot) try { auto eth_client = std::make_shared( sig_provider, std::variant{rpc_url}, - fc::uint256{test_evm_chain_id}); + test_transaction_policy(std::string(latest_slot_test_entry_id))); auto abis = load_abi_fixture(opp_abi_fixture); const std::string opp_address{test_opp_address}; auto typed_opp = eth_client->get_contract(opp_address, abis); auto entry = std::make_shared(); entry->id = latest_slot_test_entry_id; - entry->url = rpc_url; entry->signature_provider = sig_provider; entry->client = eth_client; entry->chain_id = test_evm_chain_id; diff --git a/programs/examples/cranker-example/README.md b/programs/examples/cranker-example/README.md index f24d4889d0..8ed101e8a0 100644 --- a/programs/examples/cranker-example/README.md +++ b/programs/examples/cranker-example/README.md @@ -12,6 +12,7 @@ To run `cranker-example`, you need to provide at least one Ethereum signature pr cranker-example \ --signature-provider eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ --outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 \ + --outpost-ethereum-transaction-policy-file docs/ethereum-transaction-policy.example.json \ --ethereum-abi-file tests/fixtures/ethereum-abi-counter-01.json ``` @@ -34,7 +35,11 @@ Defines an Ethereum client connection. The format is: - **eth-client-id**: Unique identifier for this client. - **sig-provider-id**: The name of the signature provider to use (must match a name defined in `--signature-provider`). - **eth-node-url**: The URL of the Ethereum JSON-RPC endpoint. -- **eth-chain-id**: (Optional) The Ethereum chain ID. +- **eth-chain-id**: (Optional) A startup cross-check against the policy chain ID. The policy chain ID is authoritative. + +#### Transaction Policy (`--outpost-ethereum-transaction-policy-file`) + +Path to a versioned JSON file containing exactly one finite policy for every configured Ethereum client. See [`docs/ethereum-transaction-policy.example.json`](../../../docs/ethereum-transaction-policy.example.json) and the [outpost client configuration guide](../../../docs/outpost-client-plugins.md) for the schema and enforced invariants. #### Ethereum ABI File (`--ethereum-abi-file`) Path to an Ethereum contract ABI file (relative from current working directory or absolute path). The file should contain a JSON array of ABI-compliant contract definitions. @@ -43,4 +48,5 @@ Path to an Ethereum contract ABI file (relative from current working directory o To successfully start the application, the following are required: 1. At least **one** Ethereum signature provider. 2. At least **one** Ethereum outpost client. -3. At least **one** Ethereum ABI file reference. +3. One transaction-policy file covering every Ethereum client. +4. At least **one** Ethereum ABI file reference. From 05fe1fd0fcb62a8e5daea2410887190dd94b9d63 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 18:50:58 +0000 Subject: [PATCH 2/5] Address Ethereum policy review findings --- docs/outpost-client-plugins.md | 4 +- .../ethereum/ethereum_transaction_policy.hpp | 12 +- .../src/network/ethereum/ethereum_client.cpp | 4 +- .../ethereum/ethereum_transaction_policy.cpp | 207 ++++++++++-------- .../test_ethereum_transaction_policy.cpp | 22 ++ .../src/outpost_ethereum_client_plugin.cpp | 202 +++++++++-------- 6 files changed, 255 insertions(+), 196 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index cb35f4b5af..fda759c1bd 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -30,7 +30,7 @@ The policy file is required whenever the plugin is configured. A minimal file is } ``` -All policy integers are positive canonical decimal strings. Strings avoid JSON number precision loss for values up to `uint256`. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. +The chain id is a positive canonical decimal string in the external outpost domain `1..UINT32_MAX`. The four policy limits are positive canonical decimal strings up to `uint256`; strings avoid JSON number precision loss. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. Immediately before signing, the client requires: @@ -42,6 +42,8 @@ Immediately before signing, the client requires: All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. +Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded`, field `max_fee_per_gas_wei`, and numeric derived/configured values. + The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. With the above configuration and the appropriate `app` & `plugin` config, you can access the `outpost-ethereum-client` configured with name/id == `eth-anvil-local` as follows diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp index 84db9c72f2..7386d9d3a9 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -85,10 +86,17 @@ class ethereum_transaction_policy_exception : public fc::exception { std::optional _allowed; }; -/** Immutable local expenditure policy for one configured Ethereum client and chain. */ +/** Throw a structured transaction-policy exception from shared library or plugin validation. */ +[[noreturn]] void throw_transaction_policy_exception( + ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed = std::nullopt); + +/** Immutable local expenditure policy for one configured Ethereum client and uint32 outpost chain. */ struct ethereum_transaction_policy { std::string client_id; - fc::uint256 chain_id; + uint32_t chain_id; fc::uint256 max_priority_fee_per_gas; fc::uint256 max_fee_per_gas; fc::uint256 max_gas_limit; diff --git a/libraries/libfc/src/network/ethereum/ethereum_client.cpp b/libraries/libfc/src/network/ethereum/ethereum_client.cpp index 58f46e5428..2351d2903a 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_client.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_client.cpp @@ -112,7 +112,7 @@ void ethereum_client::log_policy_rejection(const ethereum_transaction_policy_exc "field={} observed={} allowed={}", reason_code_name(rejection.reason()), _transaction_policy.client_id, - _transaction_policy.chain_id.str(), + _transaction_policy.chain_id, safe_operation_type, rejection.field(), rejection.observed(), @@ -389,7 +389,7 @@ fc::variant ethereum_client::get_transaction_by_hash(const std::string& tx_hash) fc::uint256 ethereum_client::get_base_fee_per_gas() { auto block = get_block_by_number(block_tag_t::latest); if (!block.contains("baseFeePerGas")) { - throw ethereum_transaction_policy_exception( + throw_transaction_policy_exception( ethereum_transaction_policy_reason::rpc_quantity_invalid, "base_fee_per_gas", ""); diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 5beee48366..7b59494ead 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -36,15 +36,6 @@ std::string diagnostic_value(std::string_view value) { return std::string(value.substr(0, max_diagnostic_value_chars)) + "..."; } -/** Throw a structured policy exception. */ -[[noreturn]] void reject(ethereum_transaction_policy_reason reason, - std::string_view field, - std::string observed, - std::optional allowed = std::nullopt) { - throw ethereum_transaction_policy_exception( - reason, std::string(field), std::move(observed), std::move(allowed)); -} - /** Check whether every character is an ASCII decimal digit. */ bool is_decimal(std::string_view value) { return std::ranges::all_of(value, [](unsigned char c) { return c >= '0' && c <= '9'; }); @@ -95,14 +86,23 @@ void ethereum_transaction_policy_exception::rethrow() const { throw *this; } +void throw_transaction_policy_exception(ethereum_transaction_policy_reason reason, + std::string_view field, + std::string observed, + std::optional allowed) { + throw ethereum_transaction_policy_exception( + reason, std::string(field), std::move(observed), std::move(allowed)); +} + fc::uint256 parse_canonical_uint256_decimal(std::string_view value, std::string_view field, bool allow_zero) { const bool numeric_value = is_decimal(value); const auto invalid = [&] { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, - field, - numeric_value ? diagnostic_value(value) : ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + field, + numeric_value ? diagnostic_value(value) : ""); }; if (value.empty() || !numeric_value || (value.size() > 1 && value.front() == '0')) invalid(); @@ -118,24 +118,29 @@ fc::uint256 parse_canonical_uint256_decimal(std::string_view value, fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field) { if (!value.is_string()) { - reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); } const std::string encoded = value.as_string(); if (!encoded.starts_with(hex_quantity_prefix)) { - reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, field, ""); } const std::string_view digits{encoded.data() + hex_quantity_prefix.size(), encoded.size() - hex_quantity_prefix.size()}; const bool hexadecimal_value = is_hexadecimal(digits); if (digits.empty() || !hexadecimal_value || (digits.size() > 1 && digits.front() == '0')) { - reject(ethereum_transaction_policy_reason::rpc_quantity_invalid, - field, - hexadecimal_value ? diagnostic_value(encoded) : ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::rpc_quantity_invalid, + field, + hexadecimal_value ? diagnostic_value(encoded) : ""); } if (digits.size() > max_uint256_hex_digits) { - reject(ethereum_transaction_policy_reason::rpc_quantity_out_of_range, field, diagnostic_value(encoded)); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::rpc_quantity_out_of_range, + field, + diagnostic_value(encoded)); } return fc::uint256(fc::from_hex(encoded)); @@ -147,32 +152,39 @@ std::string format_rpc_quantity(const fc::uint256& value) { void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy) { if (!is_safe_transaction_policy_identifier(policy.client_id)) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "client_id", ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, "client_id", ""); } if (policy.chain_id == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "chain_id", "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, "chain_id", "0"); } if (policy.max_priority_fee_per_gas == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, - "max_priority_fee_per_gas_wei", - "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + "max_priority_fee_per_gas_wei", + "0"); } if (policy.max_fee_per_gas == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_fee_per_gas_wei", "0"); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + "max_fee_per_gas_wei", + "0"); } if (policy.max_gas_limit == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, "max_gas_limit", "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, "max_gas_limit", "0"); } if (policy.max_total_native_cost == 0) { - reject(ethereum_transaction_policy_reason::configuration_value_invalid, - "max_total_native_cost_wei", - "0"); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + "max_total_native_cost_wei", + "0"); } if (policy.max_priority_fee_per_gas > policy.max_fee_per_gas) { - reject(ethereum_transaction_policy_reason::fee_relationship_invalid, - "max_priority_fee_per_gas_wei", - policy.max_priority_fee_per_gas.str(), - policy.max_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_priority_fee_per_gas_wei", + policy.max_priority_fee_per_gas.str(), + policy.max_fee_per_gas.str()); } } @@ -180,34 +192,36 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, const fc::uint256& priority_fee, const fc::uint256& base_fee) { if (priority_fee > policy.max_priority_fee_per_gas) { - reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, - "max_priority_fee_per_gas", - priority_fee.str(), - policy.max_priority_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + priority_fee.str(), + policy.max_priority_fee_per_gas.str()); } const fc::uint256 maximum_base_fee = max_uint256() / max_fee_base_multiplier; if (base_fee > maximum_base_fee) { - reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, - "max_fee_per_gas", - "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), + max_uint256().str()); } const fc::uint256 doubled_base_fee = base_fee * max_fee_base_multiplier; const fc::uint256 remaining_fee_capacity = max_uint256() - doubled_base_fee; if (priority_fee > remaining_fee_capacity) { - reject(ethereum_transaction_policy_reason::max_fee_derivation_overflow, - "max_fee_per_gas", - "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::max_fee_derivation_overflow, + "max_fee_per_gas", + "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), + max_uint256().str()); } const fc::uint256 max_fee = doubled_base_fee + priority_fee; if (max_fee > policy.max_fee_per_gas) { - reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas", - max_fee.str(), - policy.max_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas_wei", + max_fee.str(), + policy.max_fee_per_gas.str()); } return max_fee; } @@ -215,88 +229,91 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, const fc::uint256& estimated_gas) { if (estimated_gas > policy.max_gas_limit) { - reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "estimated_gas", - estimated_gas.str(), - policy.max_gas_limit.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "estimated_gas", + estimated_gas.str(), + policy.max_gas_limit.str()); } const fc::uint256 maximum_estimate = max_uint256() / gas_headroom_multiplier; if (estimated_gas > maximum_estimate) { - reject(ethereum_transaction_policy_reason::gas_limit_derivation_overflow, - "gas_limit", - "estimated_gas=" + estimated_gas.str() + - ",multiplier=" + std::to_string(gas_headroom_multiplier), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::gas_limit_derivation_overflow, + "gas_limit", + "estimated_gas=" + estimated_gas.str() + + ",multiplier=" + std::to_string(gas_headroom_multiplier), + max_uint256().str()); } const fc::uint256 gas_limit = (estimated_gas * gas_headroom_multiplier) / gas_headroom_divisor; if (gas_limit > policy.max_gas_limit) { - reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "gas_limit", - gas_limit.str(), - policy.max_gas_limit.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + gas_limit.str(), + policy.max_gas_limit.str()); } return gas_limit; } void validate_transaction_against_policy(const ethereum_transaction_policy& policy, const fc::crypto::ethereum::eip1559_tx& transaction) { - if (transaction.chain_id != policy.chain_id) { - reject(ethereum_transaction_policy_reason::chain_id_mismatch, - "chain_id", - transaction.chain_id.str(), - policy.chain_id.str()); + if (transaction.chain_id != fc::uint256{policy.chain_id}) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::chain_id_mismatch, + "chain_id", + transaction.chain_id.str(), + std::to_string(policy.chain_id)); } if (transaction.max_fee_per_gas < transaction.max_priority_fee_per_gas) { - reject(ethereum_transaction_policy_reason::fee_relationship_invalid, - "max_fee_per_gas", - transaction.max_fee_per_gas.str(), - transaction.max_priority_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::fee_relationship_invalid, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + transaction.max_priority_fee_per_gas.str()); } if (transaction.max_priority_fee_per_gas > policy.max_priority_fee_per_gas) { - reject(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, - "max_priority_fee_per_gas", - transaction.max_priority_fee_per_gas.str(), - policy.max_priority_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::priority_fee_cap_exceeded, + "max_priority_fee_per_gas", + transaction.max_priority_fee_per_gas.str(), + policy.max_priority_fee_per_gas.str()); } if (transaction.max_fee_per_gas > policy.max_fee_per_gas) { - reject(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas", - transaction.max_fee_per_gas.str(), - policy.max_fee_per_gas.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, + "max_fee_per_gas", + transaction.max_fee_per_gas.str(), + policy.max_fee_per_gas.str()); } if (transaction.gas_limit > policy.max_gas_limit) { - reject(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, - "gas_limit", - transaction.gas_limit.str(), - policy.max_gas_limit.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded, + "gas_limit", + transaction.gas_limit.str(), + policy.max_gas_limit.str()); } const fc::uint256 maximum_fee_without_overflow = transaction.gas_limit == 0 ? max_uint256() : fc::uint256{max_uint256() / transaction.gas_limit}; if (transaction.max_fee_per_gas > maximum_fee_without_overflow) { - reject(ethereum_transaction_policy_reason::total_cost_multiplication_overflow, - "max_total_native_cost", - "gas_limit=" + transaction.gas_limit.str() + - ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::total_cost_multiplication_overflow, + "max_total_native_cost", + "gas_limit=" + transaction.gas_limit.str() + + ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), + max_uint256().str()); } const fc::uint256 maximum_gas_cost = transaction.gas_limit * transaction.max_fee_per_gas; const fc::uint256 remaining_total_capacity = max_uint256() - maximum_gas_cost; if (transaction.value > remaining_total_capacity) { - reject(ethereum_transaction_policy_reason::total_cost_addition_overflow, - "max_total_native_cost", - "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), - max_uint256().str()); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::total_cost_addition_overflow, + "max_total_native_cost", + "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), + max_uint256().str()); } const fc::uint256 maximum_total_cost = maximum_gas_cost + transaction.value; if (maximum_total_cost > policy.max_total_native_cost) { - reject(ethereum_transaction_policy_reason::total_cost_cap_exceeded, - "max_total_native_cost", - maximum_total_cost.str(), - policy.max_total_native_cost.str()); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::total_cost_cap_exceeded, + "max_total_native_cost", + maximum_total_cost.str(), + policy.max_total_native_cost.str()); } } diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index aa38f87ff2..810897812c 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -2,8 +2,10 @@ #include +#include #include #include +#include using namespace fc::crypto::ethereum; using namespace fc::network::ethereum; @@ -15,6 +17,8 @@ constexpr std::string_view max_uint256_decimal = constexpr std::string_view above_max_uint256_decimal = "115792089237316195423570985008687907853269984665640564039457584007913129639936"; +static_assert(std::is_same_v); + ethereum_transaction_policy bounded_policy() { return ethereum_transaction_policy{ .client_id = "client-a", @@ -163,6 +167,24 @@ BOOST_AUTO_TEST_CASE(policy_configuration_requires_positive_consistent_limits) { ethereum_transaction_policy_reason::fee_relationship_invalid); } +BOOST_AUTO_TEST_CASE(fee_caps_document_dynamic_base_fee_headroom) { + auto policy = bounded_policy(); + policy.max_priority_fee_per_gas = 3; + policy.max_fee_per_gas = 4; + BOOST_CHECK_NO_THROW(validate_transaction_policy_configuration(policy)); + + try { + derive_max_fee_per_gas(policy, 3, 1); + BOOST_FAIL("expected insufficient fee-cap headroom rejection"); + } catch (const ethereum_transaction_policy_exception& rejection) { + BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::max_fee_cap_exceeded); + BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas_wei"); + BOOST_CHECK_EQUAL(rejection.observed(), "5"); + BOOST_REQUIRE(rejection.allowed().has_value()); + BOOST_CHECK_EQUAL(*rejection.allowed(), "4"); + } +} + BOOST_AUTO_TEST_CASE(exact_caps_pass_and_each_cap_plus_one_rejects) { const auto policy = bounded_policy(); const auto exact = bounded_transaction(); diff --git a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp index 27962ab965..a247de3027 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp @@ -53,15 +53,6 @@ struct ethereum_client_spec { std::optional chain_id; }; -/** Throw a structured, sanitized plugin-configuration rejection. */ -[[noreturn]] void reject_configuration(ethereum_transaction_policy_reason reason, - std::string_view field, - std::string observed, - std::optional allowed = std::nullopt) { - throw ethereum_transaction_policy_exception( - reason, std::string(field), std::move(observed), std::move(allowed)); -} - /** Require an object to contain each expected field exactly once and no unknown fields. */ template void validate_exact_fields(const fc::variant_object& object, @@ -71,22 +62,25 @@ void validate_exact_fields(const fc::variant_object& object, for (const auto& entry : object) { const bool known = std::ranges::find(expected, entry.key()) != expected.end(); if (!known) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - context, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); } if (!observed_fields.insert(entry.key()).second) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - context, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + ""); } } for (const auto field : expected) { if (!observed_fields.contains(std::string(field))) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - context, - "missing=" + std::string(field)); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + context, + "missing=" + std::string(field)); } } } @@ -95,22 +89,21 @@ void validate_exact_fields(const fc::variant_object& object, std::string require_string_field(const fc::variant_object& object, std::string_view field) { const auto& value = object[std::string(field)]; if (!value.is_string()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - field, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, field, ""); } return value.as_string(); } /** Parse an externally registered chain id without truncation, then enforce its uint32 domain. */ -uint32_t require_external_chain_id(std::string_view value, std::string_view field) { +[[nodiscard]] uint32_t require_external_chain_id(std::string_view value, std::string_view field) { const fc::uint256 chain_id = parse_canonical_uint256_decimal(value, field); constexpr auto max_external_chain_id = std::numeric_limits::max(); if (chain_id > max_external_chain_id) { - reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, - field, - chain_id.str(), - std::to_string(max_external_chain_id)); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + field, + chain_id.str(), + std::to_string(max_external_chain_id)); } return chain_id.convert_to(); } @@ -119,20 +112,21 @@ uint32_t require_external_chain_id(std::string_view value, std::string_view fiel ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { auto parts = fc::split(encoded_spec, ','); if (parts.size() != 3 && parts.size() != 4) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_client, - "field_count=" + std::to_string(parts.size()), - "3 or 4"); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_client, + "field_count=" + std::to_string(parts.size()), + "3 or 4"); } if (parts[0].empty() || parts[1].empty() || parts[2].empty()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, - option_name_client, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + option_name_client, + ""); } if (!is_safe_transaction_policy_identifier(parts[0])) { - reject_configuration(ethereum_transaction_policy_reason::configuration_value_invalid, - policy_field_client_id, - ""); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field_client_id, + ""); } ethereum_client_spec result{ @@ -157,65 +151,71 @@ ethereum_transaction_policy_map load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) { std::ifstream readable_policy_file{policy_file}; if (!readable_policy_file.is_open()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); } fc::variant document; try { document = fc::json::from_file(policy_file, fc::json::parse_type::strict_parser); } catch (const fc::exception&) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); } catch (const std::exception&) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_transaction_policy_file, + ""); } if (!document.is_object()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "root", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, "root", ""); } const auto root = document.get_object(); validate_exact_fields(root, root_fields, "root"); const auto& version = root[root_field_version]; + // Accept either integer representation: parser inputs and test-built variants do not share one signedness tag. const bool supported_version = (version.is_uint64() && version.as_uint64() == transaction_policy_schema_version) || (version.is_int64() && version.as_int64() == transaction_policy_schema_version); if (!supported_version) { - reject_configuration(ethereum_transaction_policy_reason::configuration_version_unsupported, - root_field_version, - "", - std::to_string(transaction_policy_schema_version)); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_version_unsupported, + root_field_version, + "", + std::to_string(transaction_policy_schema_version)); } const auto& policies_value = root[root_field_policies]; if (!policies_value.is_array()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - root_field_policies, - ""); + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, + root_field_policies, + ""); } ethereum_transaction_policy_map result; for (const auto& policy_value : policies_value.get_array()) { if (!policy_value.is_object()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "policy", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "policy", + ""); } const auto policy_object = policy_value.get_object(); validate_exact_fields(policy_object, policy_fields, "policy"); const auto client_id = require_string_field(policy_object, policy_field_client_id); + const auto chain_id = require_external_chain_id( + require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id); ethereum_transaction_policy policy{ .client_id = client_id, - .chain_id = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id), + .chain_id = chain_id, .max_priority_fee_per_gas = parse_canonical_uint256_decimal( require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), policy_field_max_priority_fee_per_gas_wei), @@ -229,12 +229,12 @@ load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) policy_field_max_total_native_cost_wei), }; validate_transaction_policy_configuration(policy); - require_external_chain_id(policy.chain_id.str(), policy_field_chain_id); if (!result.emplace(client_id, std::move(policy)).second) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, - policy_field_client_id, - client_id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + client_id); } } return result; @@ -299,14 +299,16 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti my->load_abi_files(abi_files); } if (!options.contains(option_name_client)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); } if (!options.contains(option_name_transaction_policy_file)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name_transaction_policy_file, + ""); } const auto policy_file = options.at(option_name_transaction_policy_file).as(); @@ -316,9 +318,10 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti auto& signature_manager = app().get_plugin(); const auto encoded_client_specs = options.at(option_name_client).as>(); if (encoded_client_specs.empty()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); } std::vector client_specs; @@ -327,61 +330,68 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti for (const auto& encoded_spec : encoded_client_specs) { auto spec = parse_client_spec(encoded_spec); if (!configured_client_ids.insert(spec.id).second) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_duplicate, - policy_field_client_id, - spec.id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + spec.id); } client_specs.emplace_back(std::move(spec)); } for (const auto& [client_id, policy] : policies) { if (!configured_client_ids.contains(client_id)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_unknown, - policy_field_client_id, - client_id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_unknown, + policy_field_client_id, + client_id); } } for (const auto& spec : client_specs) { const auto policy_iterator = policies.find(spec.id); if (policy_iterator == policies.end()) { - reject_configuration(ethereum_transaction_policy_reason::configuration_client_missing, - policy_field_client_id, - spec.id); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_missing, + policy_field_client_id, + spec.id); } const auto& policy = policy_iterator->second; - if (spec.chain_id && fc::uint256{*spec.chain_id} != policy.chain_id) { - reject_configuration(ethereum_transaction_policy_reason::configuration_chain_id_mismatch, - "client_spec.chain_id", - std::to_string(*spec.chain_id), - policy.chain_id.str()); + if (spec.chain_id && *spec.chain_id != policy.chain_id) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_chain_id_mismatch, + "client_spec.chain_id", + std::to_string(*spec.chain_id), + std::to_string(policy.chain_id)); } if (!signature_manager.has_provider(spec.signature_provider_id)) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "signature_provider", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "signature_provider", + ""); } const auto signature_provider = signature_manager.get_provider(spec.signature_provider_id); - const auto chain_id = policy.chain_id.convert_to(); ethereum_client_ptr client; try { client = std::make_shared(signature_provider, spec.url, policy); } catch (const ethereum_transaction_policy_exception&) { throw; } catch (const std::exception&) { - reject_configuration(ethereum_transaction_policy_reason::configuration_schema_invalid, - "client_spec.url", - ""); + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "client_spec.url", + ""); } my->add_client(spec.id, std::make_shared( spec.id, signature_provider, std::move(client), - chain_id)); + policy.chain_id)); - ilog("Added policy-constrained ethereum client client_id={} chain_id={}", spec.id, chain_id); + ilog("Added policy-constrained ethereum client client_id={} chain_id={}", + spec.id, + policy.chain_id); } } catch (const ethereum_transaction_policy_exception& rejection) { elog("Ethereum transaction policy configuration rejected reason_code={} field={} observed={} allowed={}", From 7e59d9f7dbae7b6ad995e2e161c27ca62e062ec8 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 19:07:08 +0000 Subject: [PATCH 3/5] Clarify Ethereum fee cap diagnostics --- docs/outpost-client-plugins.md | 2 +- .../src/network/ethereum/ethereum_transaction_policy.cpp | 7 +++++-- .../network/ethereum/test_ethereum_transaction_policy.cpp | 8 +++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index fda759c1bd..9dcb4dfa6b 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -42,7 +42,7 @@ Immediately before signing, the client requires: All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. -Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded`, field `max_fee_per_gas_wei`, and numeric derived/configured values. +Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands and points to policy field `max_fee_per_gas_wei`; its allowed value is the configured cap. The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 7b59494ead..5484a8d899 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -219,8 +219,11 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, const fc::uint256 max_fee = doubled_base_fee + priority_fee; if (max_fee > policy.max_fee_per_gas) { throw_transaction_policy_exception(ethereum_transaction_policy_reason::max_fee_cap_exceeded, - "max_fee_per_gas_wei", - max_fee.str(), + "max_fee_per_gas", + "derived_max_fee_per_gas=" + max_fee.str() + + ",formula=2*base_fee_per_gas(" + base_fee.str() + + ")+max_priority_fee_per_gas(" + priority_fee.str() + + "),policy_field=max_fee_per_gas_wei", policy.max_fee_per_gas.str()); } return max_fee; diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index 810897812c..027f84bff6 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -167,7 +167,7 @@ BOOST_AUTO_TEST_CASE(policy_configuration_requires_positive_consistent_limits) { ethereum_transaction_policy_reason::fee_relationship_invalid); } -BOOST_AUTO_TEST_CASE(fee_caps_document_dynamic_base_fee_headroom) { +BOOST_AUTO_TEST_CASE(insufficient_dynamic_base_fee_headroom_reports_formula) { auto policy = bounded_policy(); policy.max_priority_fee_per_gas = 3; policy.max_fee_per_gas = 4; @@ -178,8 +178,10 @@ BOOST_AUTO_TEST_CASE(fee_caps_document_dynamic_base_fee_headroom) { BOOST_FAIL("expected insufficient fee-cap headroom rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::max_fee_cap_exceeded); - BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas_wei"); - BOOST_CHECK_EQUAL(rejection.observed(), "5"); + BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas"); + BOOST_CHECK_EQUAL(rejection.observed(), + "derived_max_fee_per_gas=5,formula=2*base_fee_per_gas(1)+" + "max_priority_fee_per_gas(3),policy_field=max_fee_per_gas_wei"); BOOST_REQUIRE(rejection.allowed().has_value()); BOOST_CHECK_EQUAL(*rejection.allowed(), "4"); } From c7602ae76a9df22548e02d787da7499f74dc93ce Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 19:14:14 +0000 Subject: [PATCH 4/5] Simplify Ethereum fee diagnostics --- docs/outpost-client-plugins.md | 2 +- .../src/network/ethereum/ethereum_transaction_policy.cpp | 3 +-- .../network/ethereum/test_ethereum_transaction_policy.cpp | 7 ++++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index 9dcb4dfa6b..30037cabb0 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -42,7 +42,7 @@ Immediately before signing, the client requires: All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. -Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands and points to policy field `max_fee_per_gas_wei`; its allowed value is the configured cap. +Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands; its allowed value is the configured cap. The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index 5484a8d899..fdf24140cb 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -222,8 +222,7 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, "max_fee_per_gas", "derived_max_fee_per_gas=" + max_fee.str() + ",formula=2*base_fee_per_gas(" + base_fee.str() + - ")+max_priority_fee_per_gas(" + priority_fee.str() + - "),policy_field=max_fee_per_gas_wei", + ")+max_priority_fee_per_gas(" + priority_fee.str() + ")", policy.max_fee_per_gas.str()); } return max_fee; diff --git a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp index 027f84bff6..c9138a2134 100644 --- a/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp +++ b/libraries/libfc/test/network/ethereum/test_ethereum_transaction_policy.cpp @@ -179,9 +179,10 @@ BOOST_AUTO_TEST_CASE(insufficient_dynamic_base_fee_headroom_reports_formula) { } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::max_fee_cap_exceeded); BOOST_CHECK_EQUAL(rejection.field(), "max_fee_per_gas"); - BOOST_CHECK_EQUAL(rejection.observed(), - "derived_max_fee_per_gas=5,formula=2*base_fee_per_gas(1)+" - "max_priority_fee_per_gas(3),policy_field=max_fee_per_gas_wei"); + BOOST_CHECK(rejection.observed().find("derived_max_fee_per_gas=5") != std::string::npos); + BOOST_CHECK(rejection.observed().find("base_fee_per_gas(1)") != std::string::npos); + BOOST_CHECK(rejection.observed().find("max_priority_fee_per_gas(3)") != std::string::npos); + BOOST_CHECK(rejection.observed().find("policy_field=") == std::string::npos); BOOST_REQUIRE(rejection.allowed().has_value()); BOOST_CHECK_EQUAL(*rejection.allowed(), "4"); } From 25e32410cf859ade49387de69d6f6e2c4aa9e9c9 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 21 Jul 2026 21:39:16 +0000 Subject: [PATCH 5/5] Unify Ethereum client configuration --- docs/ethereum-client-config.example.json | 17 + docs/ethereum-transaction-policy.example.json | 13 - docs/outpost-client-plugins.md | 55 ++- .../ethereum/ethereum_transaction_policy.hpp | 3 + .../ethereum/ethereum_transaction_policy.cpp | 37 +- .../sysio/outpost_ethereum_client_plugin.hpp | 29 +- .../src/outpost_ethereum_client_plugin.cpp | 426 ++++++++++++------ .../test/test_ethereum_transaction_policy.cpp | 338 +++++++------- programs/examples/cranker-example/README.md | 27 +- 9 files changed, 588 insertions(+), 357 deletions(-) create mode 100644 docs/ethereum-client-config.example.json delete mode 100644 docs/ethereum-transaction-policy.example.json diff --git a/docs/ethereum-client-config.example.json b/docs/ethereum-client-config.example.json new file mode 100644 index 0000000000..cbc041d7fd --- /dev/null +++ b/docs/ethereum-client-config.example.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "clients": [ + { + "client_id": "eth-anvil-local", + "signature_provider_id": "eth-01", + "rpc_url": "http://localhost:8545", + "chain_id": "31337", + "transaction_policy": { + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } + } + ] +} diff --git a/docs/ethereum-transaction-policy.example.json b/docs/ethereum-transaction-policy.example.json deleted file mode 100644 index 0045b72da3..0000000000 --- a/docs/ethereum-transaction-policy.example.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "policies": [ - { - "client_id": "eth-anvil-local", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "2000000000", - "max_fee_per_gas_wei": "100000000000", - "max_gas_limit": "2000000", - "max_total_native_cost_wei": "250000000000000000" - } - ] -} diff --git a/docs/outpost-client-plugins.md b/docs/outpost-client-plugins.md index 30037cabb0..88fb9b3350 100644 --- a/docs/outpost-client-plugins.md +++ b/docs/outpost-client-plugins.md @@ -4,33 +4,55 @@ ## Configuration -The Ethereum client plugin is configured via program options as follows: +The preferred configuration keeps each RPC connection, replay-protection domain, and transaction policy in one +versioned JSON file. Signature providers remain separate because they may use `KEY`, wallet, or KMS backends: ```sh --signature-provider "eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" ---outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 ---outpost-ethereum-transaction-policy-file /etc/wire/ethereum-transaction-policy.json +--outpost-ethereum-client-config-file /etc/wire/ethereum-clients.json ``` -The policy file is required whenever the plugin is configured. A minimal file is: +The unified file can configure one or more EVM outposts: ```json { "version": 1, - "policies": [ + "clients": [ { "client_id": "eth-anvil-local", + "signature_provider_id": "eth-01", + "rpc_url": "http://localhost:8545", "chain_id": "31337", - "max_priority_fee_per_gas_wei": "2000000000", - "max_fee_per_gas_wei": "100000000000", - "max_gas_limit": "2000000", - "max_total_native_cost_wei": "250000000000000000" + "transaction_policy": { + "max_priority_fee_per_gas_wei": "2000000000", + "max_fee_per_gas_wei": "100000000000", + "max_gas_limit": "2000000", + "max_total_native_cost_wei": "250000000000000000" + } } ] } ``` -The chain id is a positive canonical decimal string in the external outpost domain `1..UINT32_MAX`. The four policy limits are positive canonical decimal strings up to `uint256`; strings avoid JSON number precision loss. The loader rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, duplicate client ids, policies without a configured client, clients without a policy, and chain-id mismatches. There is no unlimited default. +`transaction_policy` is optional. If omitted, all four caps are set to the maximum `uint256` value. This permissive +default preserves the behavior from before transaction policies were introduced; production operators should set +finite limits explicitly. + +The chain id is a positive canonical decimal string in the external outpost domain `1..UINT32_MAX`. The four policy +limits are positive canonical decimal strings up to `uint256`; strings avoid JSON number precision loss. The loader +rejects unknown or duplicate fields, unsupported versions, invalid or zero limits, and duplicate client ids. + +For backward compatibility, `--outpost-ethereum-client` remains available and cannot be combined with the unified +file: + +```sh +--outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 +``` + +Legacy clients always receive the permissive maximum-value policy. A fourth chain-id field is preferred and remains +locally authoritative. If the original three-field form is used, startup obtains `eth_chainId` from the RPC endpoint +under one aggregate five-second deadline, so an unavailable endpoint fails startup instead of blocking it indefinitely. +The removed `--outpost-ethereum-transaction-policy-file` option is not supported. Immediately before signing, the client requires: @@ -40,13 +62,20 @@ Immediately before signing, the client requires: - `maxFeePerGas >= maxPriorityFeePerGas` - `gasLimit * maxFeePerGas + value <= max_total_native_cost_wei` -All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus one is rejected. Rejected transactions are not clamped, signed, or broadcast. The policy's `chain_id` is authoritative for signing; an optional chain id in `--outpost-ethereum-client` is only a startup cross-check, and the RPC endpoint cannot select the replay-protection domain. +All arithmetic is checked for `uint256` overflow. Limits are inclusive: a value equal to a cap is allowed and cap plus +one is rejected. Rejected transactions are not clamped, signed, or broadcast. In the unified and four-field legacy +modes, the configured `chain_id` is authoritative for signing and the RPC endpoint cannot select the +replay-protection domain. Fee-cap sizing must account for the EIP-1559 derivation `maxFeePerGas = 2 * baseFeePerGas + maxPriorityFeePerGas`. Startup can validate the two configured caps, but it cannot choose a minimum base fee because base fees are dynamic and chain-specific. For an actual priority fee `p`, the largest base fee admitted by the policy is `floor((max_fee_per_gas_wei - p) / 2)`. Operators should size the two caps for the intended chain; insufficient headroom fails closed with reason code `max_fee_cap_exceeded` and field `max_fee_per_gas`. The diagnostic's observed value names the `2 * base_fee_per_gas + max_priority_fee_per_gas` operands; its allowed value is the configured cap. -The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than one `client_id` only when it serves multiple distinct EVM outposts or chains, with one policy per client. Multiple same-chain RPC endpoints are not automatic failover: chain-id lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. +The `eth-01` reference in the client configuration matches the Ethereum signature provider. A process needs more than +one `client_id` only when it serves multiple distinct EVM outposts or chains, or when callers explicitly select +different same-chain endpoints or signers. Multiple same-chain RPC endpoints are not automatic failover: chain-id +lookup becomes ambiguous and fails closed unless a caller selects a client id explicitly. -With the above configuration and the appropriate `app` & `plugin` config, you can access the `outpost-ethereum-client` configured with name/id == `eth-anvil-local` as follows +With the above configuration and the appropriate `app` and plugin config, you can access the Ethereum client configured +with name/id `eth-anvil-local` as follows: ```cpp // GET `outpost_ethereum_client_plugin` diff --git a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp index 7386d9d3a9..fee3e25be6 100644 --- a/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp +++ b/libraries/libfc/include/fc/network/ethereum/ethereum_transaction_policy.hpp @@ -103,6 +103,9 @@ struct ethereum_transaction_policy { fc::uint256 max_total_native_cost; }; +/** Return the maximum value accepted by any uint256 transaction-policy cap. */ +const fc::uint256& maximum_ethereum_transaction_policy_value(); + /** * Parse a canonical unsigned decimal uint256 configuration value. * diff --git a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp index fdf24140cb..87611464de 100644 --- a/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp +++ b/libraries/libfc/src/network/ethereum/ethereum_transaction_policy.cpp @@ -24,12 +24,6 @@ constexpr uint64_t gas_headroom_multiplier = 6; constexpr uint64_t gas_headroom_divisor = 5; constexpr uint64_t max_fee_base_multiplier = 2; -/** Return the largest uint256 value without relying on a narrowing intermediate. */ -const fc::uint256& max_uint256() { - static const fc::uint256 value{std::string(max_uint256_decimal)}; - return value; -} - /** Bound untrusted diagnostic strings so malformed configuration cannot flood logs. */ std::string diagnostic_value(std::string_view value) { if (value.size() <= max_diagnostic_value_chars) return std::string(value); @@ -50,6 +44,11 @@ bool is_hexadecimal(std::string_view value) { } // namespace +const fc::uint256& maximum_ethereum_transaction_policy_value() { + static const fc::uint256 value{std::string(max_uint256_decimal)}; + return value; +} + bool is_safe_transaction_policy_identifier(std::string_view identifier) { return !identifier.empty() && identifier.size() <= max_client_id_chars && std::ranges::all_of(identifier, [](unsigned char c) { @@ -198,22 +197,24 @@ fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy, policy.max_priority_fee_per_gas.str()); } - const fc::uint256 maximum_base_fee = max_uint256() / max_fee_base_multiplier; + const fc::uint256 maximum_base_fee = + maximum_ethereum_transaction_policy_value() / max_fee_base_multiplier; if (base_fee > maximum_base_fee) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::max_fee_derivation_overflow, "max_fee_per_gas", "base_fee=" + base_fee.str() + ",multiplier=" + std::to_string(max_fee_base_multiplier), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 doubled_base_fee = base_fee * max_fee_base_multiplier; - const fc::uint256 remaining_fee_capacity = max_uint256() - doubled_base_fee; + const fc::uint256 remaining_fee_capacity = + maximum_ethereum_transaction_policy_value() - doubled_base_fee; if (priority_fee > remaining_fee_capacity) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::max_fee_derivation_overflow, "max_fee_per_gas", "doubled_base_fee=" + doubled_base_fee.str() + ",priority_fee=" + priority_fee.str(), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 max_fee = doubled_base_fee + priority_fee; @@ -237,14 +238,15 @@ fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy, policy.max_gas_limit.str()); } - const fc::uint256 maximum_estimate = max_uint256() / gas_headroom_multiplier; + const fc::uint256 maximum_estimate = + maximum_ethereum_transaction_policy_value() / gas_headroom_multiplier; if (estimated_gas > maximum_estimate) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::gas_limit_derivation_overflow, "gas_limit", "estimated_gas=" + estimated_gas.str() + ",multiplier=" + std::to_string(gas_headroom_multiplier), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 gas_limit = (estimated_gas * gas_headroom_multiplier) / gas_headroom_divisor; @@ -291,23 +293,26 @@ void validate_transaction_against_policy(const ethereum_transaction_policy& } const fc::uint256 maximum_fee_without_overflow = - transaction.gas_limit == 0 ? max_uint256() : fc::uint256{max_uint256() / transaction.gas_limit}; + transaction.gas_limit == 0 + ? maximum_ethereum_transaction_policy_value() + : fc::uint256{maximum_ethereum_transaction_policy_value() / transaction.gas_limit}; if (transaction.max_fee_per_gas > maximum_fee_without_overflow) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::total_cost_multiplication_overflow, "max_total_native_cost", "gas_limit=" + transaction.gas_limit.str() + ",max_fee_per_gas=" + transaction.max_fee_per_gas.str(), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 maximum_gas_cost = transaction.gas_limit * transaction.max_fee_per_gas; - const fc::uint256 remaining_total_capacity = max_uint256() - maximum_gas_cost; + const fc::uint256 remaining_total_capacity = + maximum_ethereum_transaction_policy_value() - maximum_gas_cost; if (transaction.value > remaining_total_capacity) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::total_cost_addition_overflow, "max_total_native_cost", "maximum_gas_cost=" + maximum_gas_cost.str() + ",value=" + transaction.value.str(), - max_uint256().str()); + maximum_ethereum_transaction_policy_value().str()); } const fc::uint256 maximum_total_cost = maximum_gas_cost + transaction.value; diff --git a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp index d69b4cc6ac..4bff6a461a 100644 --- a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp +++ b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp @@ -29,11 +29,30 @@ struct ethereum_client_entry_t { }; using ethereum_client_entry_ptr = std::shared_ptr; -using ethereum_transaction_policy_map = std::map; -/** Load and strictly validate a versioned per-client Ethereum transaction-policy file. */ -ethereum_transaction_policy_map -load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file); +/** One validated client connection and its optional explicitly configured transaction policy. */ +struct ethereum_client_configuration { + /** Stable identifier used by outpost plugins to select this client. */ + std::string id; + /** Name of an Ethereum signature provider configured separately. */ + std::string signature_provider_id; + /** Ethereum JSON-RPC endpoint. May contain credentials and must not be logged. */ + std::string url; + /** Authoritative EVM replay-protection domain. */ + uint32_t chain_id; + /** Immutable local expenditure policy for this client. */ + ethereum_transaction_policy policy; +}; + +using ethereum_client_configuration_map = std::map; + +/** Construct the permissive compatibility policy for a client and authoritative chain id. */ +ethereum_transaction_policy make_default_ethereum_transaction_policy(std::string client_id, + uint32_t chain_id); + +/** Load a versioned unified Ethereum client file with optional nested transaction policies. */ +ethereum_client_configuration_map +load_ethereum_client_configuration_file(const std::filesystem::path& configuration_file); /// Typed contract client for OPP.sol. State-changing calls go through /// `create_tx_and_confirm` — OPP writes are consensus-critical and must @@ -135,7 +154,7 @@ class outpost_ethereum_client_plugin : public appbase::plugin #include +#include +#include #include #include @@ -18,27 +20,42 @@ namespace sysio { // using namespace outpost_client::ethereum; namespace { -constexpr auto option_name_client = "outpost-ethereum-client"; -constexpr auto option_name_transaction_policy_file = "outpost-ethereum-transaction-policy-file"; -constexpr auto option_abi_file = "ethereum-abi-file"; +constexpr auto option_name_client = "outpost-ethereum-client"; +constexpr auto option_name_client_configuration_file = "outpost-ethereum-client-config-file"; +constexpr auto option_abi_file = "ethereum-abi-file"; +constexpr int64_t legacy_chain_id_resolution_timeout_seconds = 5; -constexpr auto root_field_version = "version"; -constexpr auto root_field_policies = "policies"; +constexpr auto root_field_version = "version"; +constexpr auto root_field_clients = "clients"; constexpr auto policy_field_client_id = "client_id"; +constexpr auto client_field_signature_provider_id = "signature_provider_id"; +constexpr auto client_field_rpc_url = "rpc_url"; constexpr auto policy_field_chain_id = "chain_id"; +constexpr auto client_field_transaction_policy = "transaction_policy"; constexpr auto policy_field_max_priority_fee_per_gas_wei = "max_priority_fee_per_gas_wei"; constexpr auto policy_field_max_fee_per_gas_wei = "max_fee_per_gas_wei"; constexpr auto policy_field_max_gas_limit = "max_gas_limit"; constexpr auto policy_field_max_total_native_cost_wei = "max_total_native_cost_wei"; constexpr uint64_t transaction_policy_schema_version = 1; -constexpr std::array root_fields{ +constexpr std::array client_configuration_root_fields{ root_field_version, - root_field_policies, + root_field_clients, }; -constexpr std::array policy_fields{ +constexpr std::array required_client_configuration_fields{ policy_field_client_id, + client_field_signature_provider_id, + client_field_rpc_url, policy_field_chain_id, +}; +constexpr std::array client_configuration_fields_with_policy{ + policy_field_client_id, + client_field_signature_provider_id, + client_field_rpc_url, + policy_field_chain_id, + client_field_transaction_policy, +}; +constexpr std::array transaction_policy_fields{ policy_field_max_priority_fee_per_gas_wei, policy_field_max_fee_per_gas_wei, policy_field_max_gas_limit, @@ -108,6 +125,79 @@ std::string require_string_field(const fc::variant_object& object, std::string_v return chain_id.convert_to(); } +/** Load a strict versioned JSON object without exposing file contents in diagnostics. */ +template +fc::variant_object load_versioned_configuration_file( + const std::filesystem::path& configuration_file, + std::string_view option_name, + const std::array& expected_root_fields) { + std::ifstream readable_configuration_file{configuration_file}; + if (!readable_configuration_file.is_open()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_file_unreadable, + option_name, + ""); + } + + fc::variant document; + try { + document = fc::json::from_file(configuration_file, fc::json::parse_type::strict_parser); + } catch (const fc::exception&) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name, + ""); + } catch (const std::exception&) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name, + ""); + } + + if (!document.is_object()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, "root", ""); + } + const auto root = document.get_object(); + validate_exact_fields(root, expected_root_fields, "root"); + + const auto& version = root[root_field_version]; + // fc's strict parser stores non-negative JSON integers in the uint64 alternative. + const bool supported_version = + version.is_uint64() && version.as_uint64() == transaction_policy_schema_version; + if (!supported_version) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_version_unsupported, + root_field_version, + "", + std::to_string(transaction_policy_schema_version)); + } + return root; +} + +/** Parse the four finite policy limits after their enclosing object is schema-validated. */ +ethereum_transaction_policy parse_transaction_policy(const fc::variant_object& policy_object, + std::string client_id, + uint32_t chain_id) { + ethereum_transaction_policy policy{ + .client_id = std::move(client_id), + .chain_id = chain_id, + .max_priority_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), + policy_field_max_priority_fee_per_gas_wei), + .max_fee_per_gas = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_fee_per_gas_wei), + policy_field_max_fee_per_gas_wei), + .max_gas_limit = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_gas_limit), policy_field_max_gas_limit), + .max_total_native_cost = parse_canonical_uint256_decimal( + require_string_field(policy_object, policy_field_max_total_native_cost_wei), + policy_field_max_total_native_cost_wei), + }; + validate_transaction_policy_configuration(policy); + return policy; +} + /** Parse a client specification while keeping its credential-bearing URL out of diagnostics. */ ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { auto parts = fc::split(encoded_spec, ','); @@ -147,99 +237,168 @@ ethereum_client_spec parse_client_spec(const std::string& encoded_spec) { } } -ethereum_transaction_policy_map -load_ethereum_transaction_policy_file(const std::filesystem::path& policy_file) { - std::ifstream readable_policy_file{policy_file}; - if (!readable_policy_file.is_open()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, - ""); +ethereum_transaction_policy make_default_ethereum_transaction_policy(std::string client_id, + uint32_t chain_id) { + const auto& maximum = maximum_ethereum_transaction_policy_value(); + ethereum_transaction_policy policy{ + .client_id = std::move(client_id), + .chain_id = chain_id, + .max_priority_fee_per_gas = maximum, + .max_fee_per_gas = maximum, + .max_gas_limit = maximum, + .max_total_native_cost = maximum, + }; + validate_transaction_policy_configuration(policy); + return policy; +} + +ethereum_client_configuration_map +load_ethereum_client_configuration_file(const std::filesystem::path& configuration_file) { + const auto root = load_versioned_configuration_file( + configuration_file, option_name_client_configuration_file, client_configuration_root_fields); + + const auto& clients_value = root[root_field_clients]; + if (!clients_value.is_array()) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, + root_field_clients, + ""); } - fc::variant document; + ethereum_client_configuration_map result; + for (const auto& client_value : clients_value.get_array()) { + if (!client_value.is_object()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + "client", + ""); + } + const auto client_object = client_value.get_object(); + const bool has_explicit_policy = client_object.contains(client_field_transaction_policy); + if (has_explicit_policy) { + validate_exact_fields(client_object, client_configuration_fields_with_policy, "client"); + } else { + validate_exact_fields(client_object, required_client_configuration_fields, "client"); + } + + const auto client_id = require_string_field(client_object, policy_field_client_id); + if (!is_safe_transaction_policy_identifier(client_id)) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + policy_field_client_id, + ""); + } + const auto signature_provider_id = + require_string_field(client_object, client_field_signature_provider_id); + if (signature_provider_id.empty()) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + client_field_signature_provider_id, + ""); + } + const auto rpc_url = require_string_field(client_object, client_field_rpc_url); + if (rpc_url.empty()) { + throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_value_invalid, + client_field_rpc_url, + ""); + } + const auto chain_id = require_external_chain_id( + require_string_field(client_object, policy_field_chain_id), policy_field_chain_id); + + auto policy = make_default_ethereum_transaction_policy(client_id, chain_id); + if (has_explicit_policy) { + const auto& policy_value = client_object[client_field_transaction_policy]; + if (!policy_value.is_object()) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_schema_invalid, + client_field_transaction_policy, + ""); + } + const auto policy_object = policy_value.get_object(); + validate_exact_fields(policy_object, transaction_policy_fields, client_field_transaction_policy); + policy = parse_transaction_policy(policy_object, client_id, chain_id); + } + + ethereum_client_configuration configuration{ + .id = client_id, + .signature_provider_id = signature_provider_id, + .url = rpc_url, + .chain_id = chain_id, + .policy = std::move(policy), + }; + if (!result.emplace(client_id, std::move(configuration)).second) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_client_duplicate, + policy_field_client_id, + client_id); + } + } + return result; +} + +namespace { + +/** Resolve the legacy three-field client form exactly as it behaved before local policy binding. */ +uint32_t resolve_legacy_chain_id(const ethereum_client_spec& spec) { + if (spec.chain_id) return *spec.chain_id; + try { - document = fc::json::from_file(policy_file, fc::json::parse_type::strict_parser); + auto rpc_client = fc::network::json_rpc::json_rpc_client::create(spec.url); + const auto chain_id = parse_rpc_quantity(rpc_client.call("eth_chainId", fc::variants{}), "eth_chainId"); + constexpr auto max_external_chain_id = std::numeric_limits::max(); + if (chain_id == 0 || chain_id > max_external_chain_id) { + throw_transaction_policy_exception( + ethereum_transaction_policy_reason::configuration_value_invalid, + "eth_chainId", + chain_id.str(), + "1.." + std::to_string(max_external_chain_id)); + } + return chain_id.convert_to(); + } catch (const ethereum_transaction_policy_exception&) { + throw; } catch (const fc::exception&) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + "client_spec.url", + ""); } catch (const std::exception&) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_schema_invalid, - option_name_transaction_policy_file, - ""); + "client_spec.url", + ""); } +} - if (!document.is_object()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_schema_invalid, "root", ""); - } - const auto root = document.get_object(); - validate_exact_fields(root, root_fields, "root"); - - const auto& version = root[root_field_version]; - // Accept either integer representation: parser inputs and test-built variants do not share one signedness tag. - const bool supported_version = - (version.is_uint64() && version.as_uint64() == transaction_policy_schema_version) || - (version.is_int64() && version.as_int64() == transaction_policy_schema_version); - if (!supported_version) { +/** Convert backward-compatible command-line client specs to unified internal configurations. */ +ethereum_client_configuration_map +load_legacy_client_configurations(const std::vector& encoded_client_specs) { + if (encoded_client_specs.empty()) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_version_unsupported, - root_field_version, - "", - std::to_string(transaction_policy_schema_version)); - } - - const auto& policies_value = root[root_field_policies]; - if (!policies_value.is_array()) { - throw_transaction_policy_exception(ethereum_transaction_policy_reason::configuration_schema_invalid, - root_field_policies, - ""); + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, + ""); } - ethereum_transaction_policy_map result; - for (const auto& policy_value : policies_value.get_array()) { - if (!policy_value.is_object()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_schema_invalid, - "policy", - ""); - } - const auto policy_object = policy_value.get_object(); - validate_exact_fields(policy_object, policy_fields, "policy"); - - const auto client_id = require_string_field(policy_object, policy_field_client_id); - const auto chain_id = require_external_chain_id( - require_string_field(policy_object, policy_field_chain_id), policy_field_chain_id); - ethereum_transaction_policy policy{ - .client_id = client_id, + ethereum_client_configuration_map result; + for (const auto& encoded_spec : encoded_client_specs) { + auto spec = parse_client_spec(encoded_spec); + const auto chain_id = resolve_legacy_chain_id(spec); + ethereum_client_configuration configuration{ + .id = spec.id, + .signature_provider_id = spec.signature_provider_id, + .url = spec.url, .chain_id = chain_id, - .max_priority_fee_per_gas = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_priority_fee_per_gas_wei), - policy_field_max_priority_fee_per_gas_wei), - .max_fee_per_gas = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_fee_per_gas_wei), - policy_field_max_fee_per_gas_wei), - .max_gas_limit = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_gas_limit), policy_field_max_gas_limit), - .max_total_native_cost = parse_canonical_uint256_decimal( - require_string_field(policy_object, policy_field_max_total_native_cost_wei), - policy_field_max_total_native_cost_wei), + .policy = make_default_ethereum_transaction_policy(spec.id, chain_id), }; - validate_transaction_policy_configuration(policy); - - if (!result.emplace(client_id, std::move(policy)).second) { + if (!result.emplace(spec.id, std::move(configuration)).second) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_client_duplicate, policy_field_client_id, - client_id); + spec.id); } } return result; } +} // namespace + class outpost_ethereum_client_plugin_impl { std::map _clients{}; using file_abi_contracts_t = std::pair>; @@ -298,82 +457,61 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti const auto abi_files = options.at(option_abi_file).as>(); my->load_abi_files(abi_files); } - if (!options.contains(option_name_client)) { + const bool has_legacy_clients = options.contains(option_name_client); + const bool has_configuration_file = options.contains(option_name_client_configuration_file); + if (has_legacy_clients && has_configuration_file) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, - ""); + ethereum_transaction_policy_reason::configuration_schema_invalid, + option_name_client_configuration_file, + ""); } - if (!options.contains(option_name_transaction_policy_file)) { + if (!has_legacy_clients && !has_configuration_file) { throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_file_unreadable, - option_name_transaction_policy_file, + ethereum_transaction_policy_reason::configuration_client_missing, + option_name_client, ""); } - const auto policy_file = options.at(option_name_transaction_policy_file).as(); - auto policies = load_ethereum_transaction_policy_file(policy_file); + // The legacy path resolves each omitted chain id and then constructs the permanent clients, + // whose JSON-RPC transports may resolve DNS again. Keep one aggregate deadline alive across + // both phases so no legacy endpoint can stall plugin initialization indefinitely. + std::optional legacy_chain_id_deadline; + if (!has_configuration_file) { + legacy_chain_id_deadline.emplace( + fc::time_point::now() + fc::seconds(legacy_chain_id_resolution_timeout_seconds)); + } - // This required plugin has already initialized every configured provider, independent of `--plugin` ordering. - auto& signature_manager = app().get_plugin(); - const auto encoded_client_specs = options.at(option_name_client).as>(); - if (encoded_client_specs.empty()) { + ethereum_client_configuration_map configurations; + if (has_configuration_file) { + const auto configuration_file = + options.at(option_name_client_configuration_file).as(); + configurations = load_ethereum_client_configuration_file(configuration_file); + } else { + configurations = load_legacy_client_configurations( + options.at(option_name_client).as>()); + } + if (configurations.empty()) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_client_missing, - option_name_client, + has_configuration_file ? root_field_clients : option_name_client, ""); } - std::vector client_specs; - std::set configured_client_ids; - client_specs.reserve(encoded_client_specs.size()); - for (const auto& encoded_spec : encoded_client_specs) { - auto spec = parse_client_spec(encoded_spec); - if (!configured_client_ids.insert(spec.id).second) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_duplicate, - policy_field_client_id, - spec.id); - } - client_specs.emplace_back(std::move(spec)); - } - - for (const auto& [client_id, policy] : policies) { - if (!configured_client_ids.contains(client_id)) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_unknown, - policy_field_client_id, - client_id); - } - } - - for (const auto& spec : client_specs) { - const auto policy_iterator = policies.find(spec.id); - if (policy_iterator == policies.end()) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_client_missing, - policy_field_client_id, - spec.id); - } - const auto& policy = policy_iterator->second; - if (spec.chain_id && *spec.chain_id != policy.chain_id) { - throw_transaction_policy_exception( - ethereum_transaction_policy_reason::configuration_chain_id_mismatch, - "client_spec.chain_id", - std::to_string(*spec.chain_id), - std::to_string(policy.chain_id)); - } - - if (!signature_manager.has_provider(spec.signature_provider_id)) { + // This required plugin has already initialized every configured provider, independent of `--plugin` ordering. + auto& signature_manager = app().get_plugin(); + for (auto& [client_id, configuration] : configurations) { + if (!signature_manager.has_provider(configuration.signature_provider_id)) { throw_transaction_policy_exception( ethereum_transaction_policy_reason::configuration_schema_invalid, "signature_provider", ""); } - const auto signature_provider = signature_manager.get_provider(spec.signature_provider_id); + const auto signature_provider = + signature_manager.get_provider(configuration.signature_provider_id); ethereum_client_ptr client; try { - client = std::make_shared(signature_provider, spec.url, policy); + client = std::make_shared( + signature_provider, configuration.url, configuration.policy); } catch (const ethereum_transaction_policy_exception&) { throw; } catch (const std::exception&) { @@ -382,16 +520,16 @@ void outpost_ethereum_client_plugin::plugin_initialize(const variables_map& opti "client_spec.url", ""); } - my->add_client(spec.id, + my->add_client(client_id, std::make_shared( - spec.id, + client_id, signature_provider, std::move(client), - policy.chain_id)); + configuration.chain_id)); ilog("Added policy-constrained ethereum client client_id={} chain_id={}", - spec.id, - policy.chain_id); + client_id, + configuration.chain_id); } } catch (const ethereum_transaction_policy_exception& rejection) { elog("Ethereum transaction policy configuration rejected reason_code={} field={} observed={} allowed={}", @@ -415,12 +553,12 @@ void outpost_ethereum_client_plugin::set_program_options(options_description& cl cfg.add_options()( option_name_client, boost::program_options::value>()->multitoken(), - "Outpost Ethereum Client spec, the plugin supports 1 to many clients in a given process" - "`,,[,]`")( - option_name_transaction_policy_file, + "Backward-compatible Ethereum client spec. Each client receives the maximum-value default transaction " + "policy: `,,[,]`")( + option_name_client_configuration_file, boost::program_options::value(), - "Versioned JSON file containing exactly one finite transaction expenditure policy per configured " - "Ethereum client id and chain id")( + "Versioned JSON file containing Ethereum clients and optional per-client transaction policies. " + "Cannot be combined with --outpost-ethereum-client")( option_abi_file, boost::program_options::value>()->multitoken(), "Ethereum contract ABI file(s). Expects the file to have a JSON array of ABI complient contract definitions." diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp index 70c26a7984..f147825454 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -9,11 +9,15 @@ #include +#include + #include #include #include #include +#include #include +#include #include #include @@ -33,8 +37,60 @@ constexpr std::string_view signer_public_key = "47f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5"; constexpr std::string_view signer_private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; -constexpr std::string_view max_uint256_decimal = - "115792089237316195423570985008687907853269984665640564039457584007913129639935"; +using tcp = boost::asio::ip::tcp; + +/** One-shot JSON-RPC endpoint used to preserve coverage of the legacy three-field client form. */ +class chain_id_rpc_server { +public: + chain_id_rpc_server() + : _acceptor(_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)) + , _port(_acceptor.local_endpoint().port()) + , _worker([this] { serve(); }) {} + + chain_id_rpc_server(const chain_id_rpc_server&) = delete; + chain_id_rpc_server& operator=(const chain_id_rpc_server&) = delete; + + ~chain_id_rpc_server() { + boost::system::error_code error; + _acceptor.close(error); + boost::asio::io_context io; + tcp::socket socket(io); + socket.connect(tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), _port), error); + if (_worker.joinable()) _worker.join(); + } + + std::string url() const { + return "http://127.0.0.1:" + std::to_string(_port); + } + +private: + void serve() { + boost::system::error_code error; + tcp::socket socket(_io); + _acceptor.accept(socket, error); + if (error) return; + + boost::asio::streambuf request; + boost::asio::read_until(socket, request, "\r\n\r\n", error); + if (error) return; + + constexpr std::string_view response_body = + R"json({"jsonrpc":"2.0","id":1,"result":"0x7a69"})json"; + std::ostringstream response; + response << "HTTP/1.1 200 OK\r\n" + << "Content-Type: application/json\r\n" + << "Content-Length: " << response_body.size() << "\r\n" + << "Connection: close\r\n\r\n" + << response_body; + const auto response_text = response.str(); + boost::asio::write(socket, boost::asio::buffer(response_text), error); + } + + boost::asio::io_context _io; + tcp::acceptor _acceptor; + uint16_t _port; + std::thread _worker; +}; ethereum_transaction_policy bounded_policy(std::string client_id = "client-a") { return ethereum_transaction_policy{ @@ -138,8 +194,9 @@ void expect_policy_rejection(const std::function& operation) { BOOST_CHECK_THROW(operation(), ethereum_transaction_policy_exception); } -std::filesystem::path write_policy_file(fc::temp_directory& directory, std::string_view contents) { - const auto path = directory.path() / "ethereum-transaction-policy.json"; +std::filesystem::path write_client_configuration_file(fc::temp_directory& directory, + std::string_view contents) { + const auto path = directory.path() / "ethereum-client-config.json"; std::ofstream output{path}; output << contents; output.close(); @@ -147,8 +204,7 @@ std::filesystem::path write_policy_file(fc::temp_directory& directory, std::stri } void with_initialized_outpost_plugin( - const std::filesystem::path& policy_file, - const std::vector& client_specs, + const std::vector& configuration_arguments, const std::function& inspect_plugin) { auto reset_application = gsl_lite::finally([] { appbase::application::reset_app_singleton(); }); appbase::scoped_app test_application{}; @@ -160,13 +216,8 @@ void with_initialized_outpost_plugin( "test_outpost_ethereum_transaction_policy", "--signature-provider", signature_spec, - "--outpost-ethereum-transaction-policy-file", - policy_file.string(), }; - for (const auto& client_spec : client_specs) { - arguments.emplace_back("--outpost-ethereum-client"); - arguments.emplace_back(client_spec); - } + arguments.insert(arguments.end(), configuration_arguments.begin(), configuration_arguments.end()); std::vector argv; argv.reserve(arguments.size()); @@ -179,21 +230,18 @@ void with_initialized_outpost_plugin( } std::vector -initialize_outpost_plugin(const std::filesystem::path& policy_file, - const std::vector& client_specs) { +initialize_outpost_plugin(const std::vector& configuration_arguments) { std::vector clients; with_initialized_outpost_plugin( - policy_file, - client_specs, + configuration_arguments, [&](auto& plugin) { clients = plugin.get_clients(); }); return clients; } ethereum_transaction_policy_reason startup_rejection_reason( - const std::filesystem::path& policy_file, - const std::vector& client_specs) { + const std::vector& configuration_arguments) { try { - initialize_outpost_plugin(policy_file, client_specs); + initialize_outpost_plugin(configuration_arguments); BOOST_FAIL("expected startup policy rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { return rejection.reason(); @@ -237,7 +285,7 @@ BOOST_AUTO_TEST_CASE(final_signing_boundary_rejects_without_signing_or_broadcast } auto wide_policy = bounded_policy("wide-client"); - const fc::uint256 maximum{std::string(max_uint256_decimal)}; + const auto& maximum = maximum_ethereum_transaction_policy_value(); wide_policy.max_priority_fee_per_gas = maximum; wide_policy.max_fee_per_gas = maximum; wide_policy.max_gas_limit = maximum; @@ -364,105 +412,107 @@ BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { BOOST_CHECK_EQUAL(client->broadcast_count, 0u); } -BOOST_AUTO_TEST_CASE(policy_file_loads_two_independent_client_chain_entries) { +BOOST_AUTO_TEST_CASE(default_policy_uses_the_maximum_value_for_every_expenditure_cap) { + const auto policy = sysio::make_default_ethereum_transaction_policy("client-a", 31337); + const auto& maximum = maximum_ethereum_transaction_policy_value(); + BOOST_CHECK_EQUAL(policy.chain_id, 31337); + BOOST_CHECK_EQUAL(policy.max_priority_fee_per_gas, maximum); + BOOST_CHECK_EQUAL(policy.max_fee_per_gas, maximum); + BOOST_CHECK_EQUAL(policy.max_gas_limit, maximum); + BOOST_CHECK_EQUAL(policy.max_total_native_cost, maximum); +} + +BOOST_AUTO_TEST_CASE(unified_file_loads_explicit_and_default_policies) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [ - { - "client_id": "ethereum-mainnet", - "chain_id": "1", + "clients": [{ + "client_id": "ethereum-mainnet", + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "1", + "transaction_policy": { "max_priority_fee_per_gas_wei": "2000000000", "max_fee_per_gas_wei": "100000000000", "max_gas_limit": "2000000", "max_total_native_cost_wei": "250000000000000000" - }, - { - "client_id": "ethereum-sepolia", - "chain_id": "11155111", - "max_priority_fee_per_gas_wei": "3000000000", - "max_fee_per_gas_wei": "120000000000", - "max_gas_limit": "3000000", - "max_total_native_cost_wei": "500000000000000000" } - ] + }, { + "client_id": "ethereum-sepolia", + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "11155111" + }] })json"); - const auto policies = sysio::load_ethereum_transaction_policy_file(path); - BOOST_REQUIRE_EQUAL(policies.size(), 2u); - BOOST_CHECK_EQUAL(policies.at("ethereum-mainnet").chain_id, 1); - BOOST_CHECK_EQUAL(policies.at("ethereum-sepolia").chain_id, 11155111); - BOOST_CHECK(policies.at("ethereum-mainnet").max_fee_per_gas != - policies.at("ethereum-sepolia").max_fee_per_gas); + const auto configurations = sysio::load_ethereum_client_configuration_file(path); + const auto& maximum = maximum_ethereum_transaction_policy_value(); + BOOST_REQUIRE_EQUAL(configurations.size(), 2u); + BOOST_CHECK_EQUAL(configurations.at("ethereum-mainnet").chain_id, 1); + BOOST_CHECK_EQUAL(configurations.at("ethereum-mainnet").policy.max_fee_per_gas, 100000000000ULL); + BOOST_CHECK_EQUAL(configurations.at("ethereum-sepolia").chain_id, 11155111); + BOOST_CHECK_EQUAL(configurations.at("ethereum-sepolia").policy.max_fee_per_gas, maximum); } -BOOST_AUTO_TEST_CASE(policy_file_rejects_schema_duplicates_ranges_and_sensitive_unknown_fields) { +BOOST_AUTO_TEST_CASE(unified_file_rejects_schema_duplicates_ranges_and_sensitive_unknown_fields) { fc::temp_directory directory; expect_policy_rejection([&] { - sysio::load_ethereum_transaction_policy_file(directory.path() / "missing.json"); + sysio::load_ethereum_client_configuration_file(directory.path() / "missing.json"); }); - auto path = write_policy_file(directory, R"json({ + auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", "client_id": "client-b", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "31337" }] })json"); - expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + expect_policy_rejection([&] { sysio::load_ethereum_client_configuration_file(path); }); - path = write_policy_file(directory, R"json({ + path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "31337" }, { "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:2", + "chain_id": "31337" }] })json"); try { - sysio::load_ethereum_transaction_policy_file(path); - BOOST_FAIL("expected duplicate policy rejection"); + sysio::load_ethereum_client_configuration_file(path); + BOOST_FAIL("expected duplicate client rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == ethereum_transaction_policy_reason::configuration_client_duplicate); } - path = write_policy_file(directory, R"json({ + path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "4294967296", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "4294967296" }] })json"); - expect_policy_rejection([&] { sysio::load_ethereum_transaction_policy_file(path); }); + expect_policy_rejection([&] { sysio::load_ethereum_client_configuration_file(path); }); constexpr std::string_view sensitive_url = "https://user:password@example.invalid/rpc?token=secret"; - path = write_policy_file(directory, R"json({ + path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [], + "clients": [], "https://user:password@example.invalid/rpc?token=secret": true })json"); try { - sysio::load_ethereum_transaction_policy_file(path); + sysio::load_ethereum_client_configuration_file(path); BOOST_FAIL("expected unknown-field rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.observed().find(sensitive_url) == std::string::npos); @@ -470,95 +520,84 @@ BOOST_AUTO_TEST_CASE(policy_file_rejects_schema_duplicates_ranges_and_sensitive_ } } -BOOST_AUTO_TEST_CASE(plugin_startup_requires_exact_client_coverage_and_matching_chain) { - fc::temp_directory directory; - const std::vector client_specs{ - "client-a,signer-a,http://127.0.0.1:1,31337", - }; - - auto path = write_policy_file(directory, R"json({"version":1,"policies":[]})json"); - BOOST_CHECK(startup_rejection_reason(path, client_specs) == - ethereum_transaction_policy_reason::configuration_client_missing); - - path = write_policy_file(directory, R"json({ - "version": 1, - "policies": [{ - "client_id": "orphan-client", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" - }] - })json"); - BOOST_CHECK(startup_rejection_reason(path, client_specs) == - ethereum_transaction_policy_reason::configuration_client_unknown); - - path = write_policy_file(directory, R"json({ - "version": 1, - "policies": [{ - "client_id": "client-a", - "chain_id": "1", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" - }] - })json"); - BOOST_CHECK(startup_rejection_reason(path, client_specs) == - ethereum_transaction_policy_reason::configuration_chain_id_mismatch); -} - -BOOST_AUTO_TEST_CASE(plugin_startup_attaches_two_matching_client_chain_policies) { +BOOST_AUTO_TEST_CASE(plugin_startup_attaches_unified_client_policies) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "transaction_policy": { + "max_priority_fee_per_gas_wei": "10", + "max_fee_per_gas_wei": "100", + "max_gas_limit": "1000", + "max_total_native_cost_wei": "100000" + } }, { "client_id": "client-b", - "chain_id": "1", - "max_priority_fee_per_gas_wei": "20", - "max_fee_per_gas_wei": "200", - "max_gas_limit": "2000", - "max_total_native_cost_wei": "200000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "1" }] })json"); const auto clients = initialize_outpost_plugin( - path, - {"client-a,signer-a,http://127.0.0.1:1,31337", - "client-b,signer-a,http://127.0.0.1:1,1"}); + {"--outpost-ethereum-client-config-file", path.string()}); + const auto& maximum = maximum_ethereum_transaction_policy_value(); BOOST_REQUIRE_EQUAL(clients.size(), 2u); BOOST_CHECK_EQUAL(clients.front()->id, "client-a"); BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().max_total_native_cost, 100000); BOOST_CHECK_EQUAL(clients.back()->id, "client-b"); BOOST_CHECK_EQUAL(clients.back()->chain_id, 1); - BOOST_CHECK_EQUAL(clients.back()->client->transaction_policy().max_total_native_cost, 200000); + BOOST_CHECK_EQUAL(clients.back()->client->transaction_policy().max_total_native_cost, maximum); +} + +BOOST_AUTO_TEST_CASE(legacy_client_option_uses_default_policy_with_explicit_chain_id) { + const auto clients = initialize_outpost_plugin( + {"--outpost-ethereum-client", "client-a,signer-a,http://127.0.0.1:1,31337"}); + const auto& maximum = maximum_ethereum_transaction_policy_value(); + BOOST_REQUIRE_EQUAL(clients.size(), 1u); + BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); + BOOST_CHECK_EQUAL(clients.front()->client->transaction_policy().max_fee_per_gas, maximum); +} + +BOOST_AUTO_TEST_CASE(legacy_three_field_client_resolves_chain_id_from_rpc) { + chain_id_rpc_server rpc_server; + const auto clients = initialize_outpost_plugin( + {"--outpost-ethereum-client", "client-a,signer-a," + rpc_server.url()}); + BOOST_REQUIRE_EQUAL(clients.size(), 1u); + BOOST_CHECK_EQUAL(clients.front()->chain_id, 31337); + BOOST_CHECK_EQUAL(clients.front()->client->get_chain_id(), 31337); +} + +BOOST_AUTO_TEST_CASE(plugin_startup_rejects_mixed_unified_and_legacy_modes) { + fc::temp_directory directory; + const auto path = write_client_configuration_file( + directory, R"json({"version":1,"clients":[]})json"); + BOOST_CHECK(startup_rejection_reason( + {"--outpost-ethereum-client-config-file", + path.string(), + "--outpost-ethereum-client", + "client-a,signer-a,http://127.0.0.1:1,31337"}) == + ethereum_transaction_policy_reason::configuration_schema_invalid); } BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://127.0.0.1:1", + "chain_id": "31337" }] })json"); with_initialized_outpost_plugin( - path, - {"client-a,signer-a,http://127.0.0.1:1,31337"}, + {"--outpost-ethereum-client-config-file", path.string()}, [&](auto& plugin) { try { plugin.create_outpost_client( @@ -581,23 +620,20 @@ BOOST_AUTO_TEST_CASE(outpost_factory_rejects_client_policy_chain_mismatch) { BOOST_AUTO_TEST_CASE(plugin_startup_redacts_an_invalid_authenticated_rpc_url) { fc::temp_directory directory; - const auto path = write_policy_file(directory, R"json({ + constexpr std::string_view sensitive_url = + "http://user:password@localhost:not-a-port/rpc?token=secret"; + const auto path = write_client_configuration_file(directory, R"json({ "version": 1, - "policies": [{ + "clients": [{ "client_id": "client-a", - "chain_id": "31337", - "max_priority_fee_per_gas_wei": "10", - "max_fee_per_gas_wei": "100", - "max_gas_limit": "1000", - "max_total_native_cost_wei": "100000" + "signature_provider_id": "signer-a", + "rpc_url": "http://user:password@localhost:not-a-port/rpc?token=secret", + "chain_id": "31337" }] })json"); - constexpr std::string_view sensitive_url = - "http://user:password@localhost:not-a-port/rpc?token=secret"; try { initialize_outpost_plugin( - path, - {"client-a,signer-a," + std::string(sensitive_url) + ",31337"}); + {"--outpost-ethereum-client-config-file", path.string()}); BOOST_FAIL("expected invalid URL rejection"); } catch (const ethereum_transaction_policy_exception& rejection) { BOOST_CHECK(rejection.reason() == diff --git a/programs/examples/cranker-example/README.md b/programs/examples/cranker-example/README.md index 8ed101e8a0..f8d324264b 100644 --- a/programs/examples/cranker-example/README.md +++ b/programs/examples/cranker-example/README.md @@ -11,8 +11,7 @@ To run `cranker-example`, you need to provide at least one Ethereum signature pr ```shell cranker-example \ --signature-provider eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ - --outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337 \ - --outpost-ethereum-transaction-policy-file docs/ethereum-transaction-policy.example.json \ + --outpost-ethereum-client-config-file docs/ethereum-client-config.example.json \ --ethereum-abi-file tests/fixtures/ethereum-abi-counter-01.json ``` @@ -28,18 +27,17 @@ Defines a signature provider. The format is: - **public-key**: The public key string. - **private-key-provider-spec**: Specifier for the private key, typically `KEY:`. -#### Outpost Ethereum Client (`--outpost-ethereum-client`) -Defines an Ethereum client connection. The format is: -`,,[,]` +#### Outpost Ethereum Clients (`--outpost-ethereum-client-config-file`) -- **eth-client-id**: Unique identifier for this client. -- **sig-provider-id**: The name of the signature provider to use (must match a name defined in `--signature-provider`). -- **eth-node-url**: The URL of the Ethereum JSON-RPC endpoint. -- **eth-chain-id**: (Optional) A startup cross-check against the policy chain ID. The policy chain ID is authoritative. +Path to a versioned JSON file containing client ids, signature-provider references, RPC URLs, authoritative chain ids, +and optional per-client transaction policies. See +[`docs/ethereum-client-config.example.json`](../../../docs/ethereum-client-config.example.json) and the +[outpost client configuration guide](../../../docs/outpost-client-plugins.md). -#### Transaction Policy (`--outpost-ethereum-transaction-policy-file`) - -Path to a versioned JSON file containing exactly one finite policy for every configured Ethereum client. See [`docs/ethereum-transaction-policy.example.json`](../../../docs/ethereum-transaction-policy.example.json) and the [outpost client configuration guide](../../../docs/outpost-client-plugins.md) for the schema and enforced invariants. +The legacy +`--outpost-ethereum-client ,,[,]` option remains +available. It assigns maximum-value policy defaults and cannot be combined with +`--outpost-ethereum-client-config-file`. #### Ethereum ABI File (`--ethereum-abi-file`) Path to an Ethereum contract ABI file (relative from current working directory or absolute path). The file should contain a JSON array of ABI-compliant contract definitions. @@ -47,6 +45,5 @@ Path to an Ethereum contract ABI file (relative from current working directory o ## Minimum Configuration To successfully start the application, the following are required: 1. At least **one** Ethereum signature provider. -2. At least **one** Ethereum outpost client. -3. One transaction-policy file covering every Ethereum client. -4. At least **one** Ethereum ABI file reference. +2. At least **one** Ethereum outpost client, from the unified file or legacy option. +3. At least **one** Ethereum ABI file reference.