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
6 changes: 3 additions & 3 deletions plugins/batch_operator_plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ All 21 batch operators run this plugin in perpetuity. The epoch scheduler (`sysi
4. All 7 independently verify the delivered chain

**Phase 2 — Inbound (Outposts → WIRE):**
1. Crank Outpost to finalize epoch (`OPP.finalizeEpoch()` / `finalize_epoch`)
2. Read inbound chain from Outpost (ETH: event logs, SOL: transaction logs)
3. Deliver to Depot (`sysio.msgch::deliver`) with chain hash
1. The consensus-reaching delivery emits the outpost's outbound envelope
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 all 7 deliveries

## Configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ class outpost_client {
* @brief OPP OUTBOUND — submit a single envelope to the remote chain.
*
* Must enforce `deadline` internally; a hung chain RPC must not block the
* caller beyond this duration. Implementations that issue multiple chain
* transactions (e.g. Solana's `epoch_in` + `emit_outbound_envelope` pair)
* apply the deadline to the overall sequence.
* caller beyond this duration. Solana may chunk one envelope across
* multiple `epoch_in` transactions; the consensus-reaching transaction
* performs the outpost's outbound emit internally.
*
* @param epoch_index The current WIRE epoch this envelope belongs to.
* @param envelope_bytes Raw protobuf `opp::Envelope` bytes.
Expand All @@ -83,20 +83,21 @@ class outpost_client {
fc::microseconds deadline) = 0;

/**
* @brief OPP INBOUND — pull envelope(s) the remote chain has produced for
* this epoch and return the concatenated raw protobuf bytes.
* @brief OPP INBOUND — pull the envelope the remote chain has produced for
* this epoch and return its raw protobuf bytes.
*
* Filters by `epoch_index` internally — both ETH's event log and Solana's
* signature history retain stale envelopes from prior epochs, and delivering
* a stale envelope to `sysio.msgch::deliver` trips an
* Filters by `epoch_index` internally. Ethereum and Solana each expose a
* single latest-outbound storage slot, so a poll may still observe the
* preceding epoch until the consensus-reaching delivery overwrites it.
* Delivering that stale envelope to `sysio.msgch::deliver` trips an
* `envelope epoch_index mismatch` assertion.
*
* @param epoch_index Only envelopes whose `epoch_index` field matches this
* value are returned; all others are silently dropped.
* @param deadline Upper bound on the total time spent talking to the
* remote chain for this call.
* @return Concatenated raw `opp::Envelope` bytes ready for
* `sysio.msgch::deliver`, or an empty vector if none matched.
* @return Raw `opp::Envelope` bytes ready for `sysio.msgch::deliver`, or an
* empty vector if the latest slot did not match.
* @throws fc::exception on RPC failure or deadline expiry.
*/
virtual std::vector<char> read_inbound_envelope(uint32_t epoch_index,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@ using ethereum_client_entry_ptr = std::shared_ptr<ethereum_client_entry_t>;
/// not silently drop (see epoch-859 stall RCA); the confirmed factory
/// awaits `eth_getTransactionReceipt` + N blocks before returning.
struct opp_contract_client : ethereum_contract_client {
ethereum_contract_tx_fn<fc::variant> emit_outbound_envelope;
ethereum_contract_tx_fn<fc::variant> finalize_epoch;
/// Recovery-only write matching `emitOutboundEnvelope(uint32)` on the
/// Ethereum outpost. No in-tree steady-state caller invokes this wrapper:
/// normal operation emits during inbound consensus. It remains available
/// for explicit operator recovery tooling that must advance a stalled
/// outpost with the expected WIRE epoch.
ethereum_contract_tx_fn<fc::variant, uint32_t> emit_outbound_envelope;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage — recovery emitOutboundEnvelope(uint32) is never invoked or behaviorally tested

This retype (emitOutboundEnvelope()emitOutboundEnvelope(uint32)) is the PR's core code change, but emit_outbound_envelope has no in-tree caller and the new tests only assert its ABI-encoding shape and that the std::function is bound (BOOST_CHECK(client->emit_outbound_envelope)). Nothing verifies that invoking it with a uint32 epoch actually produces correct recovery call data.

Per wire-sysio/CLAUDE.md: "Tests should verify behavior, not just that code compiles." Consider driving the recovery wrapper end-to-end against a mocked tx sink and asserting the epoch lands in the encoded args — or, if it is genuinely operator-tooling-only, a comment pointing at the out-of-tree caller. Non-blocking.

/// View: latest outbound envelope's raw bytes + epoch — overwritten
/// on every `emitOutboundEnvelope`. Read by the WIRE batch operator
/// to relay the envelope back to WIRE.
Expand All @@ -40,8 +44,7 @@ struct opp_contract_client : ethereum_contract_client {
const address_compat_type& contract_address,
const std::vector<fc::network::ethereum::abi::contract>& contracts)
: ethereum_contract_client(client, contract_address, contracts)
, emit_outbound_envelope(create_tx_and_confirm<fc::variant>(get_abi("emitOutboundEnvelope")))
, finalize_epoch(create_tx_and_confirm<fc::variant>(get_abi("finalizeEpoch")))
, emit_outbound_envelope(create_tx_and_confirm<fc::variant, uint32_t>(get_abi("emitOutboundEnvelope")))
, get_latest_outbound_envelope(create_call<fc::variant>(get_abi("getLatestOutboundEnvelope"))) {}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@

#include <atomic>
#include <chrono>
#include <iomanip>
#include <thread>
#include <optional>
#include <set>
#include <sstream>

#include <fc/crypto/ethereum/ethereum_types.hpp>
#include <fc/crypto/ethereum/ethereum_utils.hpp>
Expand All @@ -24,6 +26,9 @@
#include <fc-test/crypto_utils.hpp>

#include <sysio/outpost_ethereum_client_plugin.hpp>
#include <sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp>
#include <sysio/opp/opp.hpp>
#include <sysio/opp/opp.pb.h>

using namespace std::literals;

Expand Down Expand Up @@ -152,12 +157,66 @@ namespace {

constexpr std::string_view opp_abi_fixture = "ethereum-abi-opp-current.json";
constexpr std::string_view opp_inbound_abi_fixture = "ethereum-abi-opp-inbound-current.json";
constexpr std::string_view hex_prefix = "0x";
constexpr std::string_view emit_outbound_envelope_abi_name = "emitOutboundEnvelope";
constexpr std::string_view emit_outbound_envelope_selector = "a3ad9cc3";
constexpr std::string_view test_opp_address = "5FbDB2315678afecb367f032d93F642f64180aa3";
constexpr std::string_view latest_slot_test_rpc_url = "http://127.0.0.1:1";
constexpr std::string_view latest_slot_test_entry_id = "latest-slot-test";
constexpr std::string_view latest_slot_test_private_key =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
constexpr std::string_view latest_slot_test_public_key =
"0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed7535"
"47f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5";
constexpr size_t evm_abi_word_bytes = 32;
constexpr size_t hex_chars_per_byte = 2;
constexpr size_t evm_abi_word_hex_chars = evm_abi_word_bytes * hex_chars_per_byte;
constexpr size_t evm_function_selector_bytes = 4;
constexpr size_t evm_function_selector_hex_chars = evm_function_selector_bytes * hex_chars_per_byte;
constexpr size_t latest_outbound_return_head_words = 2;
constexpr uint64_t latest_outbound_data_offset_bytes = latest_outbound_return_head_words * evm_abi_word_bytes;
constexpr size_t emit_outbound_envelope_call_hex_chars =
evm_function_selector_hex_chars + evm_abi_word_hex_chars;
constexpr uint64_t test_outpost_chain_code = 1;
constexpr uint32_t test_evm_chain_id = 31337;
constexpr uint32_t test_wire_epoch = 7;
constexpr uint32_t test_stale_wire_epoch = test_wire_epoch - 1;
constexpr uint32_t test_different_wire_epoch = test_wire_epoch + 1;
constexpr int64_t test_rpc_deadline_seconds = 1;
constexpr size_t rpc_length_oversized_envelope_bytes = sysio::OPP_MAX_ENVELOPE_BYTES + 1;
constexpr char malformed_envelope_byte = static_cast<char>(0xff);
constexpr char oversized_envelope_fill_byte = static_cast<char>(0x01);

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()));
}

/// Encode an unsigned integer as one 32-byte Ethereum ABI word.
std::string abi_word(uint64_t value) {
std::ostringstream stream;
stream << std::hex << std::setfill('0') << std::setw(evm_abi_word_hex_chars) << value;
return stream.str();
}

/// Encode the raw return bytes for `getLatestOutboundEnvelope()`.
std::string encode_latest_outbound_result(uint32_t epoch, const std::vector<char>& data) {
auto data_hex = data.empty() ? std::string{} : fc::to_hex(data.data(), data.size());
data_hex.append(
(evm_abi_word_hex_chars - (data_hex.size() % evm_abi_word_hex_chars)) % evm_abi_word_hex_chars,
'0');
return std::string(hex_prefix) + abi_word(epoch) + abi_word(latest_outbound_data_offset_bytes) +
abi_word(data.size()) + data_hex;
}

/// Serialize a minimal protobuf envelope carrying only its epoch index.
std::vector<char> serialize_envelope(uint32_t epoch) {
sysio::opp::Envelope envelope;
envelope.set_epoch_index(epoch);
const auto serialized = envelope.SerializeAsString();
return {serialized.begin(), serialized.end()};
}

} // anonymous namespace

BOOST_AUTO_TEST_SUITE(outpost_ethereum_client_plugin)
Expand All @@ -170,14 +229,26 @@ BOOST_AUTO_TEST_CASE(opp_contract_client_construction) try {
auto abis = load_abi_fixture(opp_abi_fixture);
BOOST_CHECK(!abis.empty());

// Verify the expected function ABIs are found
bool has_emit = false, has_finalize = false;
// Construction resolves every required ABI entry. A null RPC client is
// sufficient here because the generated callables are not invoked.
auto client = std::make_shared<sysio::opp_contract_client>(
ethereum_client_ptr{},
address_compat_type{std::string(test_opp_address)},
abis);
BOOST_REQUIRE(client);
BOOST_CHECK(client->emit_outbound_envelope);
BOOST_CHECK(client->get_latest_outbound_envelope);

// Verify the live relay surface is present and the retired finalizer is not.
bool has_emit = false, has_latest = false, has_finalize = false;
for (auto& c : abis) {
if (c.name == "emitOutboundEnvelope") has_emit = true;
if (c.name == emit_outbound_envelope_abi_name) has_emit = true;
if (c.name == "getLatestOutboundEnvelope") has_latest = true;
if (c.name == "finalizeEpoch") has_finalize = true;
}
BOOST_CHECK(has_emit);
BOOST_CHECK(has_finalize);
BOOST_CHECK(has_latest);
BOOST_CHECK(!has_finalize);
} FC_LOG_AND_RETHROW();

BOOST_AUTO_TEST_CASE(opp_inbound_contract_client_construction) try {
Expand Down Expand Up @@ -220,21 +291,145 @@ BOOST_AUTO_TEST_CASE(epoch_in_abi_encoding_with_bytes_param) try {
);
} FC_LOG_AND_RETHROW();

BOOST_AUTO_TEST_CASE(emit_outbound_envelope_abi_encoding_zero_params) try {
BOOST_AUTO_TEST_CASE(emit_outbound_envelope_abi_encoding_wire_epoch) try {
auto abis = load_abi_fixture(opp_abi_fixture);

const eth::abi::contract* emit_abi = nullptr;
for (auto& c : abis) {
if (c.name == "emitOutboundEnvelope") { emit_abi = &c; break; }
if (c.name == emit_outbound_envelope_abi_name) { emit_abi = &c; break; }
}
BOOST_REQUIRE(emit_abi != nullptr);
BOOST_CHECK_EQUAL(emit_abi->inputs.size(), 0u);
BOOST_REQUIRE_EQUAL(emit_abi->inputs.size(), 1u);
BOOST_CHECK(emit_abi->inputs[0].type == eth::abi::data_type::uint32);

// Encoding with 0 params should succeed (no inputs expected)
auto encoded = contract_encode_data(*emit_abi, std::vector<fc::variant>{});
// Encoding carries the WIRE epoch expected by the Solidity recovery call.
auto encoded = contract_encode_data(
*emit_abi,
std::vector<fc::variant>{fc::variant(uint64_t{test_wire_epoch})});
BOOST_CHECK(!encoded.empty());
// Should be just the 4-byte selector
BOOST_CHECK_EQUAL(encoded.size(), 8u); // hex chars = 4 bytes * 2
BOOST_CHECK(encoded.substr(0, evm_function_selector_hex_chars) == emit_outbound_envelope_selector);
BOOST_CHECK_EQUAL(encoded.size(), emit_outbound_envelope_call_hex_chars);
} FC_LOG_AND_RETHROW();

BOOST_AUTO_TEST_CASE(emit_outbound_envelope_recovery_wrapper_forwards_wire_epoch) try {
auto abis = load_abi_fixture(opp_abi_fixture);
auto client = std::make_shared<sysio::opp_contract_client>(
ethereum_client_ptr{},
address_compat_type{std::string(test_opp_address)},
abis);

uint32_t observed_epoch = 0;
std::string observed_call_data;
client->emit_outbound_envelope =
[&](uint32_t& wire_epoch) -> fc::variant {
observed_epoch = wire_epoch;
observed_call_data = contract_encode_data(
client->get_abi(std::string(emit_outbound_envelope_abi_name)),
std::vector<fc::variant>{fc::variant(uint64_t{wire_epoch})});
return fc::variant(observed_call_data);
};

// Replace network submission at the typed callable boundary, then invoke
// the recovery surface exposed for operator tooling. The mock sink encodes
// with the production ABI so the assertion covers both argument forwarding
// and the exact transaction call data without requiring a live EVM node.
uint32_t wire_epoch = test_wire_epoch;
const auto result = client->emit_outbound_envelope(wire_epoch);
const auto expected_call_data =
std::string(emit_outbound_envelope_selector) + abi_word(test_wire_epoch);
BOOST_CHECK_EQUAL(observed_epoch, test_wire_epoch);
BOOST_CHECK_EQUAL(observed_call_data, expected_call_data);
BOOST_CHECK_EQUAL(result.as_string(), expected_call_data);
} FC_LOG_AND_RETHROW();

BOOST_AUTO_TEST_CASE(read_inbound_envelope_validates_latest_slot) try {
auto clean_app = gsl_lite::finally([]() {
appbase::application::reset_app_singleton();
});
auto tester = create_app();
auto private_key_spec = to_private_key_spec(std::string(latest_slot_test_private_key));
auto sig_provider = tester->plugin().create_provider(
std::string(latest_slot_test_entry_id),
chain_kind_ethereum,
chain_key_type_ethereum,
std::string(latest_slot_test_public_key),
private_key_spec);

const std::string rpc_url{latest_slot_test_rpc_url};
auto eth_client = std::make_shared<ethereum_client>(
sig_provider,
std::variant<std::string, fc::url>{rpc_url},
fc::uint256{test_evm_chain_id});
auto abis = load_abi_fixture(opp_abi_fixture);
const std::string opp_address{test_opp_address};
auto typed_opp = eth_client->get_contract<sysio::opp_contract_client>(opp_address, abis);

auto entry = std::make_shared<sysio::ethereum_client_entry_t>();
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;

sysio::outpost_ethereum_client outpost(
entry,
opp_address,
"",
"",
abis,
test_outpost_chain_code,
test_evm_chain_id);

auto set_response = [&](std::string response) {
typed_opp->get_latest_outbound_envelope =
[response = std::move(response)](const block_number_or_tag_t& block) -> fc::variant {
BOOST_CHECK(std::holds_alternative<block_tag_t>(block));
BOOST_CHECK(std::get<block_tag_t>(block) == block_tag_t::finalized);
return fc::variant(response);
};
};

const auto matching = serialize_envelope(test_wire_epoch);
set_response(encode_latest_outbound_result(test_wire_epoch, matching));
BOOST_CHECK(outpost.read_inbound_envelope(
test_wire_epoch,
fc::seconds(test_rpc_deadline_seconds)) == matching);

set_response(encode_latest_outbound_result(test_stale_wire_epoch, matching));
BOOST_CHECK(outpost.read_inbound_envelope(
test_wire_epoch,
fc::seconds(test_rpc_deadline_seconds)).empty());

set_response(encode_latest_outbound_result(test_wire_epoch, {}));
BOOST_CHECK(outpost.read_inbound_envelope(
test_wire_epoch,
fc::seconds(test_rpc_deadline_seconds)).empty());

set_response(encode_latest_outbound_result(
test_wire_epoch,
std::vector<char>{malformed_envelope_byte}));
BOOST_CHECK(outpost.read_inbound_envelope(
test_wire_epoch,
fc::seconds(test_rpc_deadline_seconds)).empty());

set_response(encode_latest_outbound_result(
test_wire_epoch,
serialize_envelope(test_different_wire_epoch)));
BOOST_CHECK(outpost.read_inbound_envelope(
test_wire_epoch,
fc::seconds(test_rpc_deadline_seconds)).empty());

// A bytes value one byte over the envelope cap necessarily makes the
// complete `(uint32, bytes)` ABI result exceed the RPC hex-length cap.
// This case therefore verifies the pre-decode RPC boundary, not the later
// decoded-byte defense-in-depth check.
std::vector<char> rpc_length_oversized(
rpc_length_oversized_envelope_bytes,
oversized_envelope_fill_byte);
set_response(encode_latest_outbound_result(test_wire_epoch, rpc_length_oversized));
BOOST_CHECK(outpost.read_inbound_envelope(
test_wire_epoch,
fc::seconds(test_rpc_deadline_seconds)).empty());
} FC_LOG_AND_RETHROW();

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client {
/// `epoch_index`. Rent returns to the original uploader.
solana_program_tx_fn<std::string, uint32_t> cleanup_envelope_chunks;
/// `emit_outbound_envelope(wire_epoch_index: u32) -> signature`.
/// Recovery/admin escape hatch only. The steady-state batch-operator relay
/// never calls it because the consensus-reaching terminal `epoch_in` emits
/// the outbound envelope inline.
solana_program_tx_fn<std::string, uint32_t> emit_outbound_envelope;
/// `add_attestation(attestation_type: i32, data: bytes) -> signature`.
solana_program_tx_fn<std::string, int32_t, std::vector<uint8_t>> add_attestation;
Expand Down Expand Up @@ -321,6 +324,8 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client {
program_invoke_data_items params = {fc::variant(epoch_index)};
return execute_tx_and_confirm(instr, resolve_accounts(instr, params, overrides), params);
})
// Retained as the program's explicit recovery/admin escape hatch. The
// steady-state relay intentionally uses only terminal `epoch_in`.
, emit_outbound_envelope([this](uint32_t wire_epoch_index) -> std::string {
account_overrides_t overrides = {
{"config", config_pda},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@ inline constexpr size_t SOLANA_MAX_CHUNK_BYTES = 672;
* signature provider) with the outpost program id + IDL to implement the
* chain-agnostic SPI.
*
* The `deliver_outbound_envelope` implementation preserves the two-step
* Solana pattern: call `epoch_in` to stage the incoming envelope, then
* `emit_outbound_envelope` so the outpost emits any queued outgoing ones —
* the return value is the signature of the second call (the one that signals
* "work done for this epoch").
* `deliver_outbound_envelope` stages chunks through `epoch_in`, then sends a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistency — this doc now says the inline emit replaces the separate call, but the plugin still binds it

This rewrite (and the outpost_solana_client.cpp "No separate emit_outbound_envelope" comment) states the terminal epoch_in emits the outbound envelope inline. However outpost_solana_client_plugin.hpp:324 still binds solana_program_tx_fn<std::string, uint32_t> emit_outbound_envelope, which a grep shows is never invoked. The prose and the retained binding disagree.

That binding is in a file this PR doesn't touch, so it's pre-existing — but since this doc change is what surfaces the inconsistency, worth either dropping the dead binding or noting why it's retained (recovery parity with the ETH side?). Non-blocking.

* zero-data terminal `epoch_in` call. When that call reaches consensus the
* program emits its queued outbound envelope inline; the return value is the
* terminal call's signature.
*
* Constructed by `outpost_solana_client_plugin::create_outpost_client` —
* `batch_operator_plugin` never builds one directly.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,9 @@ std::string outpost_solana_client::deliver_outbound_envelope(
// attestations into a packed envelope and writes it to the
// `latest_outbound_envelope` PDA), and (c) self-closes this
// operator's chunk_buffer. No separate `emit_outbound_envelope` or
// `cleanup_envelope_chunks` tx is needed in the relay.
// `cleanup_envelope_chunks` tx is needed in the steady-state relay; the
// typed program client retains them as explicit recovery and maintenance
// surfaces.
std::string last_sig;
for (uint16_t i = 0; i < total_chunks; ++i) {
throw_if_past_deadline(deadline_abs, OP_EPOCH_IN);
Expand Down
Loading
Loading