Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
19 changes: 19 additions & 0 deletions docs/ethereum-client-config.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"schema_version": 1,
"clients": [
{
"connection": {
"client_id": "eth-anvil-local",
"signature_provider_id": "eth-01",
"rpc_url": "http://localhost:8545"
},
"chain_id": 31337,
"transaction_policy": {
"max_priority_fee_per_gas_wei": "2000000000",
"max_fee_per_gas_wei": "100000000000",
"max_gas_limit": "2000000",
"max_total_native_cost_wei": "250000000000000000"
}
}
]
}
37 changes: 28 additions & 9 deletions docs/outpost-client-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,22 @@

## Configuration

The Ethereum client plugin is configured via program options as follows:
The preferred Ethereum client configuration is a versioned protobuf-JSON file:

```sh
--signature-provider "eth-01,ethereum,ethereum,0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5,KEY:0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
--outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337
--outpost-ethereum-client-config-file /etc/wire/ethereum-client-config.json
```

See [ethereum-client-config.example.json](ethereum-client-config.example.json). The root
`schema_version` must be `1`. Each `clients` entry has a signing `connection`, a positive 32-bit
`chain_id` using standard ProtoJSON numeric forms, and an optional complete `transaction_policy`
containing canonical decimal-string caps for priority fee, maximum fee, gas limit, and total native
cost. When `transaction_policy` is omitted or `null`, ProtoJSON leaves it unset and all four caps
default to `UINT256_MAX` for backward compatibility. This maximum default is
compatibility-only: it provides no finite economic boundary, is not a production recommendation,
and production operators must configure reviewed finite `transaction_policy` values.

HTTPS endpoints use system CA roots and mandatory DNS/IP identity verification. Private PKI can be added with
`--outpost-ethereum-additional-ca-file` or `--outpost-ethereum-additional-ca-path`; an explicit proxy can be set
with `--outpost-ethereum-proxy`. The equivalent Solana options are
Expand All @@ -21,13 +30,23 @@ resource policy.

> NOTE: If you look closely, the reference to `eth-01` in the Ethereum client config, matches the signature provider configured for `Ethereum`. This mapping is what enables `1..n` clients in a single process

The signer reference is validated during startup. Each
`--outpost-ethereum-client` must reference the explicit, non-empty name of a
configured `--signature-provider`; anonymous signature-provider specs cannot be
referenced by an Ethereum client. When the optional Ethereum chain ID is
present, startup also calls `eth_chainId` on the configured RPC endpoint and
fails if the endpoint is unavailable, returns an invalid value, or reports a
different chain ID.
The signer reference is validated during startup and must identify an explicit Ethereum
`--signature-provider`; anonymous signature-provider specs cannot be referenced. File-configured
chain IDs are locally authoritative, so startup does not call `eth_chainId` for them.

The legacy option remains available and cannot be combined with the file option:

```sh
--outpost-ethereum-client eth-anvil-local,eth-01,http://localhost:8545,31337
```

The four-field legacy form also treats its chain ID as locally authoritative. The historical
three-field form remains compatible by resolving `eth_chainId` during startup under a shared
five-second deadline. Legacy clients receive `UINT256_MAX` expenditure caps, with the same
compatibility-only warning above.

Every signing-capable client enforces its local policy after the transaction is fully assembled
and immediately before signing. A rejected transaction is neither signed nor broadcast.

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

Expand Down
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/http/http_client_legacy.cpp
src/network/json_rpc/json_rpc_client.cpp
Expand Down
34 changes: 23 additions & 11 deletions libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#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>

Expand Down Expand Up @@ -328,28 +329,31 @@ 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
* @param rpc_options authenticated transport and bounded request policy
*/
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,
client_options rpc_options = {});

/** Virtual destructor supporting deterministic RPC fakes in client tests. */
virtual ~ethereum_client() = default;

/**
* @brief General method to send RPC requests.
* @param method The name of the RPC method to call (e.g., "eth_blockNumber").
* @param params The parameters for the RPC method (as a JSON object).
* @return The raw JSON response as a string, wrapped in std::optional.
*/
fc::variant execute(const std::string& method, const fc::variant& params);
virtual fc::variant execute(const std::string& method, const fc::variant& params);

/**
* @brief Execute an explicitly read-only RPC with stale-connection recovery.
*
* The request may be replayed once only when an existing cached connection
* proves stale. Transaction-submission methods must use `execute`.
*/
fc::variant execute_idempotent(const std::string& method, const fc::variant& params);
virtual fc::variant execute_idempotent(const std::string& method, const fc::variant& params);

fc::variant execute_contract_view_fn(const address& contract_address, const abi::contract& abi,
const block_number_or_tag_t& block, const contract_invoke_data_items& params);
Expand Down Expand Up @@ -525,10 +529,10 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
const block_number_or_tag_t& block = block_tag_t::pending);

/**
* @brief Retrieves the chain ID of the connected Ethereum network.
* @return The chain ID.
* @brief Returns the locally configured authoritative chain ID without querying the RPC endpoint.
* @return The chain ID used for transaction replay protection and policy validation.
*/
fc::uint256 get_chain_id();
fc::uint256 get_chain_id() const;

/**
* @brief Retrieves the version of the connected Ethereum network.
Expand Down Expand Up @@ -556,6 +560,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 @@ -590,6 +597,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 @@ -605,10 +619,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 authoritative 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,139 @@
#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_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
3 changes: 3 additions & 0 deletions libraries/libfc/include/fc/network/http/http_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ inline constexpr size_t failure_kind_count = magic_enum::enum_count<failure_kind
/** Return a stable metric label for @p failure. */
std::string_view failure_kind_name(failure_kind failure);

/** Return whether @p host is valid for HTTP authority serialization and TLS identity checks. */
bool is_safe_network_host(std::string_view host);

/** Return only the scheme, host, and explicit port of @p target for safe diagnostics. */
std::string sanitized_endpoint(const url& target);

Expand Down
Loading
Loading