Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/ethereum-client-config.example.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
71 changes: 67 additions & 4 deletions docs/outpost-client-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,78 @@

## 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-config-file /etc/wire/ethereum-clients.json
```

The unified file can configure one or more EVM outposts:

```json
{
"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"
}
}
]
}
```

`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
```
> 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

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
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:

- `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. 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, 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` and plugin config, you can access the Ethereum client configured
with name/id `eth-anvil-local` as follows:

```cpp
// GET `outpost_ethereum_client_plugin`
Expand All @@ -24,7 +87,7 @@ auto client_entry = eth_plug.get_clients()[0];
// CLIENT IS A `std::shared_ptr<ethereum_client>`
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();
```
1 change: 1 addition & 0 deletions libraries/libfc/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
#include <fc/exception/exception.hpp>
#include <fc/int256.hpp>
#include <fc/network/ethereum/ethereum_abi.hpp>
#include <fc/network/ethereum/ethereum_transaction_policy.hpp>
#include <fc/network/json_rpc/json_rpc_client.hpp>
#include <fc/task/retry.hpp>

#include <string_view>

namespace fc::network::ethereum {
using namespace fc::crypto;
using namespace fc::crypto::ethereum;
Expand Down Expand Up @@ -328,18 +331,21 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
* @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<std::string, fc::url>& url_source,
std::optional<fc::uint256> 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.
* @param method The name of the RPC method to call (e.g., "eth_blockNumber").
* @param params The parameters for the RPC method (as a JSON object).
* @return The raw JSON response as a string, wrapped in std::optional.
*/
fc::variant execute(const std::string& method, const fc::variant& params);
virtual fc::variant execute(const std::string& method, const fc::variant& params);

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);
Expand Down Expand Up @@ -518,7 +524,7 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
* @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.
Expand Down Expand Up @@ -546,6 +552,9 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
*/
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
*
Expand Down Expand Up @@ -580,6 +589,13 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
}

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
*/
Expand All @@ -595,10 +611,8 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
*/
json_rpc_client _client;

/**
* @brief Cached chain ID (fetched once and reused)
*/
std::optional<fc::uint256> _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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint8_t>((value >> shift) & 0xff);
if (byte == 0 && !started)
continue;
Expand Down Expand Up @@ -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
} // namespace fc::network::ethereum::rlp
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#pragma once

#include <fc/crypto/ethereum/ethereum_types.hpp>
#include <fc/exception/exception.hpp>
#include <fc/variant.hpp>

#include <magic_enum/magic_enum.hpp>

#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <string_view>

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<std::string> allowed = std::nullopt);

/** Copy this exception without slicing its structured fields. */
std::shared_ptr<fc::exception> 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<std::string>& allowed() const { return _allowed; }

private:
ethereum_transaction_policy_reason _reason;
std::string _field;
std::string _observed;
std::optional<std::string> _allowed;
};

/** Throw a structured transaction-policy exception from shared library or plugin validation. */
[[noreturn]] void throw_transaction_policy_exception(
ethereum_transaction_policy_reason reason,
std::string_view field,
std::string observed,
std::optional<std::string> allowed = std::nullopt);

/** Immutable local expenditure policy for one configured Ethereum client and uint32 outpost chain. */
struct ethereum_transaction_policy {
std::string client_id;
uint32_t chain_id;
fc::uint256 max_priority_fee_per_gas;
fc::uint256 max_fee_per_gas;
fc::uint256 max_gas_limit;
fc::uint256 max_total_native_cost;
};

/** Return the maximum value accepted by any uint256 transaction-policy cap. */
const fc::uint256& maximum_ethereum_transaction_policy_value();

/**
* Parse a canonical unsigned decimal uint256 configuration value.
*
* Canonical values contain only ASCII decimal digits and have no leading zeroes. Zero is accepted
* only when `allow_zero` is true. Values wider than uint256 are rejected before conversion.
*/
fc::uint256 parse_canonical_uint256_decimal(std::string_view value,
std::string_view field,
bool allow_zero = false);

/**
* Parse a canonical Ethereum JSON-RPC QUANTITY without truncation.
*
* The accepted form is `0x0` or `0x` followed by at most 64 hexadecimal digits with no leading zero.
*/
fc::uint256 parse_rpc_quantity(const fc::variant& value, std::string_view field);

/** Format a uint256 as a canonical Ethereum JSON-RPC QUANTITY. */
std::string format_rpc_quantity(const fc::uint256& value);

/** Validate the policy itself before it is attached to a signing-capable client. */
void validate_transaction_policy_configuration(const ethereum_transaction_policy& policy);

/** Derive and cap `2 * base_fee + priority_fee` using checked uint256 arithmetic. */
fc::uint256 derive_max_fee_per_gas(const ethereum_transaction_policy& policy,
const fc::uint256& priority_fee,
const fc::uint256& base_fee);

/** Apply checked `(estimated_gas * 6) / 5` headroom and enforce the gas cap. */
fc::uint256 derive_buffered_gas_limit(const ethereum_transaction_policy& policy,
const fc::uint256& estimated_gas);

/** Validate every settled EIP-1559 transaction field before a private-key operation. */
void validate_transaction_against_policy(const ethereum_transaction_policy& policy,
const fc::crypto::ethereum::eip1559_tx& transaction);

} // namespace fc::network::ethereum
Loading
Loading