Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/ethereum-client-config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"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"
"max_gas_limit": "16777216",
"max_total_native_cost_wei": "1700000000000000000"
}
}
]
Expand Down
2 changes: 2 additions & 0 deletions libraries/chain/include/sysio/chain/exceptions.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ namespace sysio { namespace chain {
3110007, "Missing Trace API Plugin" )
FC_DECLARE_DERIVED_EXCEPTION( signing_transient_exception, plugin_exception,
3110008, "Transient signing-provider failure; the operation should be retried" )
FC_DECLARE_DERIVED_EXCEPTION( outpost_delivery_incomplete_exception, plugin_exception,
3110009, "Outpost delivery left the epoch open; the next tick resumes it" )


FC_DECLARE_DERIVED_EXCEPTION( wallet_exception, chain_exception,
Expand Down
55 changes: 52 additions & 3 deletions libraries/libfc/include/fc/network/ethereum/ethereum_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ struct ethereum_confirm_options {
/// Retry / backoff / deadline envelope — shared with the Solana client.
/// See `fc::task::retry_options` for per-field semantics.
fc::task::retry_options retry = fc::task::retry_option_defaults;

/// Least gas limit the transaction is funded with, applied AFTER the
/// buffered estimate; `0` leaves the estimate alone.
///
/// For a call that stops on a `gasleft()` watchdog and records where it
/// stopped rather than reverting, `eth_estimateGas` converges on the least
/// gas at which the call returns cleanly, so the estimate systematically
/// under-funds it. A floor above the client's `max_gas_limit` is a policy
/// rejection, never a silently clamped limit. Ignored when `gas_limit_cap`
/// is set.
uint64_t gas_limit_floor = 0;

/// The gas limit the transaction is sent with, and the gas the pre-flight
/// estimate runs under; `0` leaves the limit to the buffered estimate (and
/// the floor). A call that cannot succeed inside the cap is refused by the
/// node before anything is signed, instead of reverting on chain for the
/// whole budget. The estimate's value is discarded: what is simulated is
/// what is sent, so no headroom buffer or buffered-estimate ceiling check
/// applies. Must be at least the floor; a cap above the policy ceiling is a
/// policy rejection.
uint64_t gas_limit_cap = 0;
};

inline constexpr ethereum_confirm_options ethereum_confirm_option_defaults{};
Expand Down Expand Up @@ -414,7 +435,9 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
fc::uint256 estimate_gas(const address_compat_type& to,
const std::optional<fc::uint256>& value = {0}, const std::optional<gas_config_t>& gas_config = std::nullopt);

fc::uint256 estimate_gas(const address_compat_type& to, const abi::contract& contract, const data_or_params_t& params, const std::optional<gas_config_t>& gas_config = std::nullopt);
/// @param gas_limit_cap When non-zero, the `gas` the estimate is asked to
/// fit in: the node refuses a call that cannot succeed inside it.
fc::uint256 estimate_gas(const address_compat_type& to, const abi::contract& contract, const data_or_params_t& params, const std::optional<gas_config_t>& gas_config = std::nullopt, uint64_t gas_limit_cap = 0);

/**
* @brief Retrieves the current gas price.
Expand Down Expand Up @@ -481,6 +504,26 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
const ethereum_confirm_options& opts =
ethereum_confirm_option_defaults);

/**
* @brief Wait for a previously-submitted tx hash to be included and
* confirmed at `opts.confirmations` depth, and return its receipt.
*
* Its `blockNumber` lets follow-up reads pin to the block that holds the
* effect, and its `logs` carry the events the contract emitted; re-reading
* state at `latest` instead risks a lagging backend serving a block from
* before the transaction landed.
*
* Same two-phase wait as `wait_for_confirmation`.
*
* @param tx_hash Transaction hash returned by an earlier submit.
* @param opts Confirmation depth + retry/backoff envelope.
* @return The `eth_getTransactionReceipt` object, as the node returned it.
* @throws fc::timeout_exception on deadline expiry, fc::exception on an
* on-chain revert (`status == 0`).
*/
fc::variant wait_for_receipt(const std::string& tx_hash,
const ethereum_confirm_options& opts = ethereum_confirm_option_defaults);

/**
* @brief Retrieves logs based on filter parameters.
* @param params The filter parameters for fetching logs.
Expand Down Expand Up @@ -569,10 +612,15 @@ class ethereum_client : public std::enable_shared_from_this<ethereum_client> {
* @param to Recipient address (contract address for contract calls)
* @param contract ABI contract definition for encoding the call data
* @param params Parameters to pass to the contract function
* @param gas_limit_floor Least gas limit to fund the transaction with, or 0
* to use the buffered estimate; see `ethereum_confirm_options::gas_limit_floor`
* @param gas_limit_cap The gas limit to send with, and the bound the estimate
* runs under, or 0 for none; see `ethereum_confirm_options::gas_limit_cap`
* @return Configured eip1559_tx ready for signing and submission
*/
eip1559_tx create_default_tx(const address_compat_type& to, const abi::contract& contract,
const fc::variants& params = {});
const fc::variants& params = {}, uint64_t gas_limit_floor = 0,
uint64_t gas_limit_cap = 0);

/**
* @brief Gets or creates a typed contract client instance
Expand Down Expand Up @@ -700,7 +748,8 @@ ethereum_contract_tx_fn<RT, Args...> ethereum_contract_client::create_tx_and_con
abi::contract& abi = abi_map[contract.name];
return [this, &abi, opts](const Args&... args) -> RT {
contract_invoke_data_items params = {args...};
auto tx = client->create_default_tx(contract_address, abi, params);
auto tx = client->create_default_tx(contract_address, abi, params, opts.gas_limit_floor,
opts.gas_limit_cap);
auto res_var = client->execute_contract_tx_fn(tx, abi, params);

// `execute_contract_tx_fn` returns the submitted tx hash. Await
Expand Down
62 changes: 54 additions & 8 deletions libraries/libfc/src/network/ethereum/ethereum_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@ constexpr std::string_view base_fee_per_gas = "base_fee_per_gas";
constexpr std::string_view max_priority_fee_per_gas = "max_priority_fee_per_gas";
constexpr std::string_view estimated_gas = "estimated_gas";
constexpr std::string_view gas_price = "gas_price";
constexpr std::string_view gas_limit_floor = "gas_limit_floor";
constexpr std::string_view gas_limit_cap = "gas_limit_cap";
} // namespace transaction_policy_field

namespace ethereum_rpc_field {
constexpr std::string_view base_fee_per_gas = "baseFeePerGas";
constexpr std::string_view max_priority_fee_per_gas = "maxPriorityFeePerGas";
constexpr std::string_view max_fee_per_gas = "maxFeePerGas";
constexpr std::string_view gas = "gas";
} // namespace ethereum_rpc_field

constexpr std::string_view gas_configuration_operation = "get_gas_config";
Expand Down Expand Up @@ -286,23 +289,56 @@ fc::variant ethereum_client::get_syncing_status() {
* Constructs a complete EIP-1559 transaction by:
* - Fetching current gas configuration (base fee, priority fee)
* - Encoding the contract call data according to the ABI
* - Estimating gas usage and adding a 20% buffer
* - Sizing the gas limit: the cap when one is given, else the estimate plus a
* 20% buffer, raised to `gas_limit_floor`
* - Setting the nonce from the pending transaction count
*
* @param to The recipient address (contract address for contract calls)
* @param contract The ABI contract definition for encoding the call data
* @param params The parameters to pass to the contract function
* @param gas_limit_floor Least gas limit to fund with, or 0 for the buffered estimate
* @param gas_limit_cap The limit to send with, or 0 for the buffered estimate
* @return A configured eip1559_tx ready for signing and submission
* @throws fc::network::json_rpc::json_rpc_exception if any RPC call fails
* @throws fc::network::json_rpc::json_rpc_exception if any RPC call fails,
* including a capped pre-flight that cannot succeed inside the cap
* @throws ethereum_transaction_policy_exception if the estimate, the floor or
* the cap breaches the client's `max_gas_limit`
*/
eip1559_tx ethereum_client::create_default_tx(const address_compat_type& to, const abi::contract& contract,
const fc::variants& params) {
const fc::variants& params, uint64_t gas_limit_floor,
uint64_t gas_limit_cap) {
FC_ASSERT(gas_limit_cap == 0 || gas_limit_cap >= gas_limit_floor,
"gas_limit_cap ({}) is below gas_limit_floor ({})", gas_limit_cap, gas_limit_floor);
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);
// Refused, not clamped: clamping would silently send an under-funded call.
const auto require_within_policy = [&](uint64_t limit, std::string_view field) {
const fc::uint256 wide{limit};
if (wide > _transaction_policy.max_gas_limit) {
throw_transaction_policy_exception(ethereum_transaction_policy_reason::gas_limit_cap_exceeded,
field, wide.str(), _transaction_policy.max_gas_limit.str());
}
};

fc::uint256 gas_limit;
if (gas_limit_cap != 0) {
// The estimate is only a pre-flight; its value is discarded. Buffering
// a call that uses most of its cap would manufacture a policy
// rejection for a transaction the cap already carries.
require_within_policy(gas_limit_cap, transaction_policy_field::gas_limit_cap);
(void) estimate_gas(to, contract, data, gc, gas_limit_cap);
gas_limit = fc::uint256{gas_limit_cap};
} else {
auto estimated_gas = estimate_gas(to, contract, data, gc, 0);
gas_limit = derive_buffered_gas_limit(_transaction_policy, estimated_gas);
if (gas_limit_floor != 0) {
require_within_policy(gas_limit_floor, transaction_policy_field::gas_limit_floor);
const fc::uint256 floor{gas_limit_floor};
if (gas_limit < floor) gas_limit = floor;
}
}

return eip1559_tx{.chain_id = get_chain_id(),
.nonce = get_transaction_count(get_signer_address(), block_tag_t::pending),
Expand Down Expand Up @@ -510,7 +546,8 @@ ethereum_client::gas_config_t ethereum_client::get_gas_config_unlogged() {
* @throws fc::network::json_rpc::json_rpc_exception if the RPC call fails
*/
fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const abi::contract& contract,
const data_or_params_t& data_or_params, const std::optional<gas_config_t>& gas_config_opt) {
const data_or_params_t& data_or_params, const std::optional<gas_config_t>& gas_config_opt,
uint64_t gas_limit_cap) {
fc::mutable_variant_object tx;

gas_config_t gc = gas_config_opt ? *gas_config_opt : get_gas_config();
Expand All @@ -523,6 +560,9 @@ fc::uint256 ethereum_client::estimate_gas(const address_compat_type& to, const a
(std::string(ethereum_rpc_field::max_fee_per_gas), format_rpc_quantity(gc.max_fee_per_gas))
("data", data)
("input", data);
// With `gas` set, the node searches only up to it and reports a revert when
// the call cannot succeed inside it.
if (gas_limit_cap != 0) tx(std::string(ethereum_rpc_field::gas), format_rpc_quantity(fc::uint256{gas_limit_cap}));

auto resp = execute_idempotent("eth_estimateGas", fc::variants{tx});
return parse_rpc_quantity(resp, transaction_policy_field::estimated_gas);
Expand Down Expand Up @@ -578,6 +618,12 @@ std::string ethereum_client::send_raw_transaction(const std::string& raw_tx_data

std::string ethereum_client::wait_for_confirmation(const std::string& tx_hash,
const ethereum_confirm_options& opts) {
(void) wait_for_receipt(tx_hash, opts);
return tx_hash;
}

fc::variant ethereum_client::wait_for_receipt(const std::string& tx_hash,
const ethereum_confirm_options& opts) {
// Phase 1: wait for the receipt to exist. A null result means the tx
// hasn't been included yet — retry with backoff. A `status == 0` means
// the EVM executed the tx and reverted — propagate as a fatal error so
Expand All @@ -604,7 +650,7 @@ std::string ethereum_client::wait_for_confirmation(const std::string& tx_hash,
if (opts.confirmations <= 1) {
// Receipt exists: tx is in the head block. That's one confirmation by
// definition; no further wait required.
return tx_hash;
return receipt;
}

// Phase 2: wait for `opts.confirmations - 1` more blocks on top of the
Expand All @@ -627,7 +673,7 @@ std::string ethereum_client::wait_for_confirmation(const std::string& tx_hash,
return std::nullopt;
});

return tx_hash;
return receipt;
}

std::string ethereum_client::send_transaction_and_confirm(const std::string& raw_tx_data,
Expand Down
8 changes: 5 additions & 3 deletions plugins/batch_operator_plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ Run by every member of the elected group, for every active outpost chain:
**Outbound (WIRE → Outposts):**
1. Read the outpost's outbound envelope that `sysio.epoch::advance` built
(`sysio.msgch::buildenv`) from the depot's `outenvelopes` table
2. Deliver it to the Outpost contract, chunked (ETH: `OPPInbound.epochIn()`, SOL: `epoch_in`)
2. Deliver it to the Outpost contract (ETH: one `OPPInbound.epochIn()` carrying the whole envelope,
continued while its dispatch spills; SOL: `epoch_in`, chunked)
3. The outpost reaches consensus once enough group members have delivered identical bytes

**Inbound (Outposts → WIRE):**
1. The consensus-reaching delivery emits the outpost's outbound envelope
1. The outpost emits its outbound envelope once it has dispatched the inbound one (ETH: in the `epochIn`
that finishes dispatch, a continuation if it spilled; SOL: in the consensus-reaching `epoch_in`)
2. Read the latest outbound envelope from Outpost storage
3. Deliver its raw protobuf bytes to Depot (`sysio.msgch::deliver`)
4. Depot evaluates consensus across the group's deliveries
Expand Down Expand Up @@ -50,7 +52,7 @@ by the underwriter every poll (see [Underwriter](#underwriter)).
|--------|---------|-------------|
| `--batch-operator-account` | — | WIRE account name for this operator. Configuring it enables the relay |
| `--batch-epoch-poll-ms` | 15000 | How often to check epoch state (ms) |
| `--batch-delivery-timeout-ms` | 15000 | Max time to wait for chain delivery confirmation (ms) |
| `--batch-delivery-timeout-ms` | 15000 | Max time for one outbound delivery, every transaction and confirmation wait included (ms). On Ethereum a single receipt wait may use whatever of it is left, so size it past the outpost chain's block time. |
| `--batch-yield-tick-interval-ms` | 60000 | Minimum spacing between this operator's `sysio.swap::tickyield` pushes per yield pool (ms) |
| `--batch-underwriter-account` | none | `account` or `account@permission` (default `active`) the underwriter bonds from. Configuring it enables the underwriter |
| `--batch-underwriter-max-exposure` | none | Most the underwriter may have bonded at once in one shadow token, as an asset (`100.000000000 LIQETH`). Repeat per token; a token without one is not underwritten |
Expand Down
27 changes: 13 additions & 14 deletions plugins/batch_operator_plugin/src/batch_operator_plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1942,23 +1942,22 @@ void batch_operator_plugin::set_program_options(options_description& cli,
opts("batch-epoch-poll-ms", bpo::value<uint32_t>()->default_value(whole_milliseconds(EPOCH_POLL)),
"How often to check epoch state (ms)");
// SIZING RULE for batch-delivery-timeout-ms: it bounds the WHOLE outbound
// delivery, and an Ethereum delivery is now one transaction PER CHUNK
// (ETHEREUM_MAX_CHUNK_BYTES = 8192; Solana chunks at 672). Size it as
// total chunks x (target block time + confirmation margin)
// for the largest envelope the outpost is expected to carry. At the 32768
// byte platform envelope cap that is 4 Ethereum chunks, so the 15000 ms
// default covers a 1 s block time with wide margin. Undersizing is not fatal:
// the relay resumes from the on-chain per-operator high-water mark on the
// next cron tick, so a truncated tick converges rather than restarting from
// chunk zero. SHARED KNOB: this same value also bounds the depot
// push_action completion wait (see push_action above); post-WIRE-331 a
// timeout there is safe (the async_action_completion callback retains its
// own state), but raising this option lengthens BOTH waits, so raise it for
// chunking deliberately rather than as a blanket tuning step.
// delivery, a sequence of confirmed transactions. Ethereum sends the envelope
// in ONE `epochIn` plus one continuation per dispatch spill (a handful at
// most for a full-cap envelope), and each receipt wait may use whatever of
// this budget is left; Solana chunks the envelope at 672 bytes. Size it as
// expected transactions x (target block time + confirmation margin)
// for the largest expected envelope; the 15000 ms default covers a 1 s block
// time with wide margin. Undersizing is not fatal: the relay resumes from the
// outpost's on-chain cursor on the next cron tick. SHARED KNOB: this value
// also bounds the depot push_action completion wait (see push_action above).
// A timeout there is safe (the async_action_completion callback retains its
// own state), but raising this lengthens BOTH waits, so raise it deliberately
// rather than as a blanket tuning step.
// Help text below must stay ASCII with no " --" sequence (PerformanceHarness
// splits nodeop --help output on that token).
opts("batch-delivery-timeout-ms", bpo::value<uint32_t>()->default_value(whole_milliseconds(DELIVERY_TIMEOUT)),
"Max time to wait for chain delivery confirmation (ms)");
"Max time for one outbound delivery, every transaction and confirmation wait included (ms)");
opts("batch-yield-tick-interval-ms",
bpo::value<uint32_t>()->default_value(whole_milliseconds(YIELD_TICK_INTERVAL)),
"Minimum spacing between this operator's sysio.swap::tickyield pushes per yield pool (ms)");
Expand Down
Loading
Loading