From 305e6e23ce5ecce274ebd33e87a097f18ac0e8c6 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 20 Aug 2026 11:37:52 -0500 Subject: [PATCH 1/2] opp: source outpost identity from sysio.chains instead of operator config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every operator declared each outpost's remote contract addresses in its own config, which bought coordination rather than security: the addresses are not verifiable on WIRE either way, and an attacker who can rewrite the registry row can already setcode sysio.msgch. Commit 9671150f6a dropped the on-chain binding for the regchain ABI churn it cost, not for a security gain, and pre-launch that cost is near zero. sysio.chains rows carry an outpost_addrs struct again, widened to cover both daemons: OPP, OPPInbound, OperatorRegistry, and the source-deposit contract. EVM names a contract per role; SVM is one program in opp_addr with the role fields required empty; WIRE is empty. setoutpost replaces the set for a remote redeploy. Values are format-validated per kind and bounded so nothing unbounded reaches sysio-billed state. The RPC client for a chain is now the one registered under that chain's own sysio.chains code, replacing the batch operator's external_chain_id match; create_outpost_client still asserts the client's verified eth_chainId equals the row's, so a client registered under the wrong code is rejected. That removes batch-outpost, batch-sol-client-id, and both underwriter-{eth,sol}-outpost options: the underwriter now serves every active non-depot row. An elected group must deliver on every active chain, so a chain with no configured client — or a chain kind this build cannot relay — is fatal for the batch operator rather than skipped: it names them and quits, after the sync gate where sysio.chains is readable. Missing addresses stay non-fatal, being governance state fixable with setoutpost. Both daemons rebuild a handle when the row's address changes, instead of relaying to an address the outpost has moved off. get_client in both client plugins used map::at, throwing std::out_of_range on an unknown id rather than returning null; that made create_outpost_client's own Unknown-client-id assert and verify_source_deposit_sol's null guard unreachable. Both now use find. get_client_by_chain_id and find_endpoint_coverage_gap lose their last callers and are removed. --- .../include/sysio.chains/sysio.chains.hpp | 50 ++- contracts/sysio.chains/src/sysio.chains.cpp | 120 ++++++- contracts/sysio.chains/sysio.chains.abi | 49 +++ contracts/sysio.chains/sysio.chains.wasm | Bin 23461 -> 35739 bytes contracts/tests/contract_test_support.hpp | 41 +++ contracts/tests/sysio.chains_tests.cpp | 248 ++++++++++++++ contracts/tests/sysio.dispatch_tests.cpp | 57 ++-- contracts/tests/sysio.dispute_tests.cpp | 6 +- .../tests/sysio.epoch_flushwtdw_tests.cpp | 6 +- contracts/tests/sysio.epoch_tests.cpp | 2 + contracts/tests/sysio.msgch_chain_tests.cpp | 3 +- contracts/tests/sysio.msgch_tests.cpp | 3 + contracts/tests/sysio.reserv_tests.cpp | 4 +- .../include/sysio/depot/chains_registry.hpp | 62 ++++ libraries/libfc/test/CMakeLists.txt | 1 + libraries/libfc/test/test_chains_registry.cpp | 65 ++++ plugins/batch_operator_plugin/README.md | 29 +- .../batch_operator_plugin/outpost_binding.hpp | 55 --- .../src/batch_operator_plugin.cpp | 217 ++++++------ .../test/test_batch_operator_plugin.cpp | 48 +-- .../sysio/outpost_ethereum_client_plugin.hpp | 12 +- .../src/outpost_ethereum_client_plugin.cpp | 24 +- .../sysio/outpost_solana_client_plugin.hpp | 4 + .../src/outpost_solana_client_plugin.cpp | 8 +- plugins/underwriter_plugin/README.md | 33 +- .../underwriter_plugin/routing_detail.hpp | 35 -- .../src/underwriter_plugin.cpp | 312 ++++++++++-------- .../test/test_underwriter_plugin.cpp | 14 +- .../test/test_underwriter_routing.cpp | 68 ---- 29 files changed, 1068 insertions(+), 508 deletions(-) create mode 100644 contracts/tests/sysio.chains_tests.cpp create mode 100644 libraries/libfc/include/sysio/depot/chains_registry.hpp create mode 100644 libraries/libfc/test/test_chains_registry.cpp delete mode 100644 plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/outpost_binding.hpp diff --git a/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp b/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp index f001677332..0b581cf632 100644 --- a/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp +++ b/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp @@ -31,6 +31,35 @@ namespace sysio { * `sysio.epoch::advance` reads `chains` directly via cross-contract KV read * to determine the active-outpost fanout list. No mirror table required. */ + /** + * @brief Remote contract identities for one chain's outpost deployment. + * + * Grouped into one struct so `regchain` keeps a readable signature and + * `setoutpost` replaces the whole set atomically. Encoding by kind: + * * `EVM` — each field is a distinct `0x`-prefixed 20-byte hex address: + * the OPP contract, the OPPInbound contract, the OperatorRegistry + * (the `uw_commit` target), and the contract that emits the source + * swap-deposit event scanned by the underwriter's verify path. + * * `SVM` — `opp_addr` is the outpost program id (base58). The single + * program serves every role, so the other three MUST be empty; the + * daemons substitute `opp_addr` wherever they need one of them. + * * `WIRE` — all empty; the depot self-row has no remote deployment. + * + * Fields may be empty at registration (the remote contract is not deployed + * yet) and filled in later via `setoutpost`. Both operator daemons fail + * closed: a chain whose address they need but do not have is skipped, so an + * unconfigured row never rides on another chain's deployment. + */ + struct outpost_addrs { + std::string opp_addr; + std::string opp_inbound_addr; + std::string operator_registry_addr; + std::string source_deposit_addr; + + SYSLIB_SERIALIZE(outpost_addrs, + (opp_addr)(opp_inbound_addr)(operator_registry_addr)(source_deposit_addr)) + }; + class [[sysio::contract("sysio.chains")]] chains : public contract { public: using contract::contract; @@ -58,17 +87,30 @@ namespace sysio { /// * `kind=SVM` may appear at most once — Solana clusters have no /// numeric chain id, so the wire binding is kind-only for SVM and a /// second row would be indistinguishable at the outpost. + /// * `outpost` addresses, when non-empty, must match the kind's expected + /// format, and fields that are structurally meaningless for the kind + /// must be empty. See {@link outpost_addrs}. [[sysio::action]] void regchain(opp::types::ChainKind kind, sysio::slug_name code, uint32_t external_chain_id, std::string name, - std::string description); + std::string description, + outpost_addrs outpost); /// Activate a previously-registered chain (priv-gated, one-shot). [[sysio::action]] void activchain(sysio::slug_name code); + /// Replace the remote outpost contract identities for an already-registered + /// chain (priv-gated). Used when a remote contract is (re)deployed after the + /// row was registered. Same per-kind encoding and format validation as + /// `regchain`; rejects the WIRE depot self-row, which has no remote + /// deployment. The whole set is replaced, so a caller updating one address + /// must resend the others. + [[sysio::action]] + void setoutpost(sysio::slug_name code, outpost_addrs outpost); + // ----------------------------------------------------------------------- // Tables // ----------------------------------------------------------------------- @@ -89,6 +131,10 @@ namespace sysio { bool active = false; uint64_t registered_at_ms = 0; uint64_t activated_at_ms = 0; + /// Remote outpost contract identities for this chain — see {@link outpost_addrs}. + /// Read by batch_operator_plugin and underwriter_plugin; every operator + /// therefore relays through the same deployment without per-node config. + outpost_addrs outpost; uint64_t by_kind() const { return magic_enum::enum_integer(kind); } uint64_t by_external_chain_id() const { return external_chain_id; } @@ -96,7 +142,7 @@ namespace sysio { SYSLIB_SERIALIZE(chain_row, (code)(kind)(external_chain_id)(name)(description) - (is_depot)(active)(registered_at_ms)(activated_at_ms)) + (is_depot)(active)(registered_at_ms)(activated_at_ms)(outpost)) }; using chains_t = sysio::kv::table<"chains"_n, chain_key, chain_row, diff --git a/contracts/sysio.chains/src/sysio.chains.cpp b/contracts/sysio.chains/src/sysio.chains.cpp index 3820cef041..7bc3ef17eb 100644 --- a/contracts/sysio.chains/src/sysio.chains.cpp +++ b/contracts/sysio.chains/src/sysio.chains.cpp @@ -37,19 +37,120 @@ void require_priv_caller() { "sysio.chains: privileged account required"); } +// --------------------------------------------------------------------------- +// Remote-address format validation +// +// These addresses are consensus facts: every batch operator and underwriter +// reads the same row, so one malformed value breaks relay for the whole +// network rather than for a single misconfigured node. Validate the format at +// the ingress boundary, where the caller can still fix it. +// +// Empty is allowed -- a chain may be registered before its remote contracts +// are deployed and filled in later via `setoutpost`; both daemons fail closed +// and skip a row whose address they need but do not have. The exception is a +// field that is structurally meaningless for the kind, which must be empty. +// --------------------------------------------------------------------------- + +constexpr size_t EVM_ADDR_LEN = 42; // "0x" + 20 bytes hex +constexpr size_t SVM_ADDR_MIN = 32; // base58 of a 32-byte pubkey, lower bound +constexpr size_t SVM_ADDR_MAX = 44; // base58 of a 32-byte pubkey, upper bound + +// Upper bound for a kind whose address format is not constrained below. The +// EVM and SVM checks are far tighter; this exists only so an unrecognised +// future kind cannot park an unbounded string in `sysio`-billed state, the +// same concern `registry_metadata.hpp` bounds `name` and `description` for. +constexpr size_t ADDR_MAX_BYTES = 128; + +bool is_hex_digit(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} + +/// Bitcoin/Solana base58 alphabet -- [1-9A-HJ-NP-Za-km-z] (excludes 0, O, I, l). +bool is_base58_char(char c) { + return (c >= '1' && c <= '9') + || (c >= 'A' && c <= 'H') || (c >= 'J' && c <= 'N') || (c >= 'P' && c <= 'Z') + || (c >= 'a' && c <= 'k') || (c >= 'm' && c <= 'z'); +} + +void check_evm_addr(const std::string& addr, const char* label) { + sysio::check(addr.size() == EVM_ADDR_LEN && addr[0] == '0' && addr[1] == 'x', + std::string("sysio.chains: ") + label + " must be a 0x-prefixed 20-byte hex address"); + for (size_t i = 2; i < addr.size(); ++i) { + sysio::check(is_hex_digit(addr[i]), + std::string("sysio.chains: ") + label + " contains a non-hex character"); + } +} + +void check_svm_addr(const std::string& addr, const char* label) { + sysio::check(addr.size() >= SVM_ADDR_MIN && addr.size() <= SVM_ADDR_MAX, + std::string("sysio.chains: ") + label + " must be a base58 program id (32-44 chars)"); + for (char c : addr) { + sysio::check(is_base58_char(c), + std::string("sysio.chains: ") + label + " contains a non-base58 character"); + } +} + +void check_empty(const std::string& addr, const char* label, const char* why) { + sysio::check(addr.empty(), std::string("sysio.chains: ") + label + " must be empty -- " + why); +} + +/// Validate an `outpost_addrs` set against the chain kind. Non-empty values +/// must match the kind's format; structurally-unused fields must be empty. +void validate_outpost_addrs(opp::types::ChainKind kind, const outpost_addrs& o) { + // Applies to every kind, including ones with no format rule yet. + for (const auto* f : {&o.opp_addr, &o.opp_inbound_addr, + &o.operator_registry_addr, &o.source_deposit_addr}) { + sysio::check(f->size() <= ADDR_MAX_BYTES, + "sysio.chains: outpost address exceeds " + + std::to_string(ADDR_MAX_BYTES) + " bytes"); + } + + switch (kind) { + case opp::types::CHAIN_KIND_WIRE: { + constexpr auto why = "the WIRE depot self-row has no remote deployment"; + check_empty(o.opp_addr, "opp_addr", why); + check_empty(o.opp_inbound_addr, "opp_inbound_addr", why); + check_empty(o.operator_registry_addr, "operator_registry_addr", why); + check_empty(o.source_deposit_addr, "source_deposit_addr", why); + break; + } + case opp::types::CHAIN_KIND_EVM: + // Each role is its own contract on an EVM chain. + if (!o.opp_addr.empty()) check_evm_addr(o.opp_addr, "opp_addr"); + if (!o.opp_inbound_addr.empty()) check_evm_addr(o.opp_inbound_addr, "opp_inbound_addr"); + if (!o.operator_registry_addr.empty()) check_evm_addr(o.operator_registry_addr, "operator_registry_addr"); + if (!o.source_deposit_addr.empty()) check_evm_addr(o.source_deposit_addr, "source_deposit_addr"); + break; + case opp::types::CHAIN_KIND_SVM: { + // One program serves every role; the daemons substitute opp_addr. + constexpr auto why = "an SVM outpost is a single program, named by opp_addr"; + if (!o.opp_addr.empty()) check_svm_addr(o.opp_addr, "opp_addr"); + check_empty(o.opp_inbound_addr, "opp_inbound_addr", why); + check_empty(o.operator_registry_addr, "operator_registry_addr", why); + check_empty(o.source_deposit_addr, "source_deposit_addr", why); + break; + } + default: + // Future kinds: bounded above, no format constraint yet. + break; + } +} + } // namespace void chains::regchain(opp::types::ChainKind kind, sysio::slug_name code, uint32_t external_chain_id, std::string name, - std::string description) { + std::string description, + outpost_addrs outpost) { require_priv_caller(); sysio::check(kind != opp::types::CHAIN_KIND_UNKNOWN, "sysio.chains: kind must not be UNKNOWN"); // Both strings persist into a `sysio`-billed row -- bound them before emplace. opp::registry::check_metadata(name, description, "sysio.chains"); + validate_outpost_addrs(kind, outpost); chains_t tbl(get_self()); chain_key pk{code}; @@ -120,6 +221,7 @@ void chains::regchain(opp::types::ChainKind kind, .active = bootstrap, .registered_at_ms = now, .activated_at_ms = bootstrap ? now : 0, + .outpost = std::move(outpost), }); } @@ -138,4 +240,20 @@ void chains::activchain(sysio::slug_name code) { }); } +void chains::setoutpost(sysio::slug_name code, outpost_addrs outpost) { + require_priv_caller(); + + chains_t tbl(get_self()); + chain_key pk{code}; + auto it = tbl.find(pk); + sysio::check(it != tbl.end(), "sysio.chains: chain code not registered"); + sysio::check(!it->is_depot, "sysio.chains: the depot self-row has no remote deployment"); + + validate_outpost_addrs(it->kind, outpost); + + tbl.modify(ram_payer, pk, [&](auto& row) { + row.outpost = std::move(outpost); + }); +} + } // namespace sysio diff --git a/contracts/sysio.chains/sysio.chains.abi b/contracts/sysio.chains/sysio.chains.abi index f63d6ad2e7..dd2675ef8a 100644 --- a/contracts/sysio.chains/sysio.chains.abi +++ b/contracts/sysio.chains/sysio.chains.abi @@ -62,6 +62,32 @@ { "name": "activated_at_ms", "type": "uint64" + }, + { + "name": "outpost", + "type": "outpost_addrs" + } + ] + }, + { + "name": "outpost_addrs", + "base": "", + "fields": [ + { + "name": "opp_addr", + "type": "string" + }, + { + "name": "opp_inbound_addr", + "type": "string" + }, + { + "name": "operator_registry_addr", + "type": "string" + }, + { + "name": "source_deposit_addr", + "type": "string" } ] }, @@ -88,6 +114,24 @@ { "name": "description", "type": "string" + }, + { + "name": "outpost", + "type": "outpost_addrs" + } + ] + }, + { + "name": "setoutpost", + "base": "", + "fields": [ + { + "name": "code", + "type": "slug_name" + }, + { + "name": "outpost", + "type": "outpost_addrs" } ] }, @@ -112,6 +156,11 @@ "name": "regchain", "type": "regchain", "ricardian_contract": "" + }, + { + "name": "setoutpost", + "type": "setoutpost", + "ricardian_contract": "" } ], "tables": [ diff --git a/contracts/sysio.chains/sysio.chains.wasm b/contracts/sysio.chains/sysio.chains.wasm index 13a86ef60a7ae8d815f77de5d51bd7e435a1a36c..d3409e3fd52be3e679f7b7235dc4cbf6756c06d6 100755 GIT binary patch literal 35739 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 +#include +#include +#include // to_variant(ChainKind) glue for the mvo below +#include +#include +#include + +#include "contracts.hpp" +#include "contract_test_support.hpp" + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::opp::types; +using sysio_system::test_support::evm_outpost_mvo; +using sysio_system::test_support::no_outpost_mvo; +using sysio_system::test_support::svm_outpost_mvo; +using mvo = fc::mutable_variant_object; + +namespace { + +/// A `slug_name` renders in JSON/ABI as `{value: }`. +inline fc::mutable_variant_object codename_mvo(std::string_view s) { + return mvo()("value", fc::slug_name{s}.value); +} + +// Well-formed sample addresses for the accept paths. +constexpr auto EVM_OPP = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; // OPP.sol +constexpr auto EVM_INBOUND = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"; // OPPInbound.sol +constexpr auto EVM_OPREG = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0"; // OperatorRegistry.sol +constexpr auto EVM_DEPOSIT = "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9"; // SwapDeposit emitter +constexpr auto SVM_PROGRAM = "So11111111111111111111111111111111111111112"; // 43-char base58 + +} // namespace + +class sysio_chains_tester : public tester { +public: + static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; + static constexpr auto EPOCH_ACCOUNT = "sysio.epoch"_n; + + sysio_chains_tester() { + produce_blocks(2); + create_accounts({CHAINS_ACCOUNT, EPOCH_ACCOUNT}); + produce_blocks(2); + + set_code(CHAINS_ACCOUNT, contracts::chains_wasm()); + set_abi(CHAINS_ACCOUNT, contracts::chains_abi().data()); + set_privileged(CHAINS_ACCOUNT); + produce_blocks(); + + const auto* accnt = control->find_account_metadata(CHAINS_ACCOUNT); + BOOST_REQUIRE(accnt != nullptr); + abi_def abi; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt->abi, abi), true); + chains_abi.set_abi(std::move(abi), abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + action_result push_chains(name action_name, const fc::variant_object& data) { + try { + const std::string action_type = chains_abi.get_action_type(action_name); + action act; + act.account = CHAINS_ACCOUNT; + act.name = action_name; + act.data = chains_abi.variant_to_binary(action_type, data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + act.authorization = std::vector{{CHAINS_ACCOUNT, config::active_name}}; + + signed_transaction trx; + trx.actions.emplace_back(std::move(act)); + set_transaction_headers(trx); + trx.sign(get_private_key(CHAINS_ACCOUNT, "active"), control->get_chain_id()); + push_transaction(trx); + produce_block(); + return success(); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + } + + action_result regchain(ChainKind kind, std::string_view code, uint32_t external_chain_id, + const fc::variant_object& outpost) { + return push_chains("regchain"_n, mvo() + ("kind", kind) + ("code", codename_mvo(code)) + ("external_chain_id", external_chain_id) + ("name", std::string(code)) + ("description", std::string{}) + ("outpost", outpost)); + } + + action_result setoutpost(std::string_view code, const fc::variant_object& outpost) { + return push_chains("setoutpost"_n, mvo() + ("code", codename_mvo(code)) + ("outpost", outpost)); + } + + /// Read the stored `chain_row` for `code` (KV table `chains`, keyed by the + /// slug_name uint64). Returns a null variant when the row is absent. + fc::variant get_chain(std::string_view code) { + auto data = get_row_by_id(CHAINS_ACCOUNT, CHAINS_ACCOUNT, "chains"_n, fc::slug_name{code}.value); + return data.empty() ? fc::variant() : chains_abi.binary_to_variant( + "chain_row", data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// One address field off a stored row's nested `outpost` struct. + std::string stored_addr(std::string_view code, const char* field) { + auto row = get_chain(code); + BOOST_REQUIRE(!row.is_null()); + return row["outpost"][field].as_string(); + } + + abi_serializer chains_abi; +}; + +BOOST_AUTO_TEST_SUITE(sysio_chains_tests) + +// ── EVM: all four role addresses are accepted and stored verbatim ── +BOOST_FIXTURE_TEST_CASE(regchain_evm_addresses_stored, sysio_chains_tester) { try { + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, + evm_outpost_mvo(EVM_OPP, EVM_INBOUND, EVM_OPREG, EVM_DEPOSIT))); + BOOST_REQUIRE_EQUAL(std::string(EVM_OPP), stored_addr("ETH", "opp_addr")); + BOOST_REQUIRE_EQUAL(std::string(EVM_INBOUND), stored_addr("ETH", "opp_inbound_addr")); + BOOST_REQUIRE_EQUAL(std::string(EVM_OPREG), stored_addr("ETH", "operator_registry_addr")); + BOOST_REQUIRE_EQUAL(std::string(EVM_DEPOSIT), stored_addr("ETH", "source_deposit_addr")); +} FC_LOG_AND_RETHROW() } + +// ── EVM: a malformed hex address is rejected in EVERY role, not just opp_addr ── +BOOST_FIXTURE_TEST_CASE(regchain_evm_bad_hex_rejected, sysio_chains_tester) { try { + // Wrong length. + BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, + evm_outpost_mvo("0xdeadbeef", EVM_INBOUND, EVM_OPREG, EVM_DEPOSIT)) + .find("20-byte hex address") != std::string::npos); + // Non-hex character in an otherwise 42-char string (trailing 'z'), in each role. + const std::string bad_hex = "0x5FbDB2315678afecb367f032d93F642f64180aaz"; + BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, + evm_outpost_mvo(EVM_OPP, bad_hex, EVM_OPREG, EVM_DEPOSIT)) + .find("opp_inbound_addr contains a non-hex character") != std::string::npos); + BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, + evm_outpost_mvo(EVM_OPP, EVM_INBOUND, bad_hex, EVM_DEPOSIT)) + .find("operator_registry_addr contains a non-hex character") != std::string::npos); + BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, + evm_outpost_mvo(EVM_OPP, EVM_INBOUND, EVM_OPREG, bad_hex)) + .find("source_deposit_addr contains a non-hex character") != std::string::npos); + BOOST_REQUIRE(get_chain("ETH").is_null()); // nothing registered on reject +} FC_LOG_AND_RETHROW() } + +// ── EVM: empty addresses are allowed (register now, deploy and configure later) ── +BOOST_FIXTURE_TEST_CASE(regchain_evm_empty_addresses_allowed, sysio_chains_tester) { try { + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, no_outpost_mvo())); + BOOST_REQUIRE_EQUAL(std::string{}, stored_addr("ETH", "opp_addr")); + BOOST_REQUIRE_EQUAL(std::string{}, stored_addr("ETH", "source_deposit_addr")); +} FC_LOG_AND_RETHROW() } + +// ── EVM: an oversized string cannot reach sysio-billed state ── +BOOST_FIXTURE_TEST_CASE(regchain_evm_oversized_addr_rejected, sysio_chains_tester) { try { + const std::string too_long(4096, 'a'); + BOOST_REQUIRE(!regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, + evm_outpost_mvo(too_long, EVM_INBOUND, EVM_OPREG, EVM_DEPOSIT)).empty()); + BOOST_REQUIRE(get_chain("ETH").is_null()); +} FC_LOG_AND_RETHROW() } + +// ── SVM: base58 program id in opp_addr; the single program serves every role ── +BOOST_FIXTURE_TEST_CASE(regchain_svm_program_id_accepted, sysio_chains_tester) { try { + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, "SOL", 900, + svm_outpost_mvo(SVM_PROGRAM))); + BOOST_REQUIRE_EQUAL(std::string(SVM_PROGRAM), stored_addr("SOL", "opp_addr")); + BOOST_REQUIRE_EQUAL(std::string{}, stored_addr("SOL", "opp_inbound_addr")); +} FC_LOG_AND_RETHROW() } + +// ── SVM: every role other than opp_addr must be left empty ── +BOOST_FIXTURE_TEST_CASE(regchain_svm_other_roles_must_be_empty, sysio_chains_tester) { try { + const auto with = [&](const char* inbound, const char* opreg, const char* deposit) { + return regchain(ChainKind::CHAIN_KIND_SVM, "SOL", 900, + evm_outpost_mvo(SVM_PROGRAM, inbound, opreg, deposit)); + }; + BOOST_REQUIRE(with(SVM_PROGRAM, "", "").find("opp_inbound_addr must be empty") != std::string::npos); + BOOST_REQUIRE(with("", SVM_PROGRAM, "").find("operator_registry_addr must be empty") != std::string::npos); + BOOST_REQUIRE(with("", "", SVM_PROGRAM).find("source_deposit_addr must be empty") != std::string::npos); + BOOST_REQUIRE(get_chain("SOL").is_null()); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(regchain_svm_bad_base58_rejected, sysio_chains_tester) { try { + // '0', 'O', 'I', 'l' are not in the base58 alphabet. + BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_SVM, "SOL", 900, + svm_outpost_mvo("So1111111111111111111111111111111111111111O")) + .find("non-base58 character") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +// ── WIRE: the depot self-row has no remote deployment ── +BOOST_FIXTURE_TEST_CASE(regchain_wire_rejects_addresses, sysio_chains_tester) { try { + BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_WIRE, "WIRE", 0, + evm_outpost_mvo(EVM_OPP, "", "", "")) + .find("no remote deployment") != std::string::npos); + // WIRE with every field empty is fine. + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_WIRE, "WIRE", 0, no_outpost_mvo())); +} FC_LOG_AND_RETHROW() } + +// ── setoutpost: updates a registered row, validates, guards the WIRE row ── +BOOST_FIXTURE_TEST_CASE(setoutpost_updates_and_guards, sysio_chains_tester) { try { + // Register EVM with empty addresses, then fill them in — the redeploy path. + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, no_outpost_mvo())); + BOOST_REQUIRE_EQUAL(success(), + setoutpost("ETH", evm_outpost_mvo(EVM_OPP, EVM_INBOUND, EVM_OPREG, EVM_DEPOSIT))); + BOOST_REQUIRE_EQUAL(std::string(EVM_OPP), stored_addr("ETH", "opp_addr")); + BOOST_REQUIRE_EQUAL(std::string(EVM_OPREG), stored_addr("ETH", "operator_registry_addr")); + + // Same validation as regchain: a bad address is rejected and the row is unchanged. + BOOST_REQUIRE(setoutpost("ETH", evm_outpost_mvo("0xnothex", EVM_INBOUND, EVM_OPREG, EVM_DEPOSIT)) + .find("20-byte hex address") != std::string::npos); + BOOST_REQUIRE_EQUAL(std::string(EVM_OPP), stored_addr("ETH", "opp_addr")); + + // The whole set is replaced, so an omitted field is cleared rather than kept. + BOOST_REQUIRE_EQUAL(success(), setoutpost("ETH", evm_outpost_mvo(EVM_OPP, EVM_INBOUND, "", ""))); + BOOST_REQUIRE_EQUAL(std::string{}, stored_addr("ETH", "operator_registry_addr")); + + // Unregistered code is rejected. + BOOST_REQUIRE(setoutpost("NOPE", no_outpost_mvo()).find("not registered") != std::string::npos); + + // The WIRE depot self-row has no remote deployment. + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_WIRE, "WIRE", 0, no_outpost_mvo())); + BOOST_REQUIRE(setoutpost("WIRE", no_outpost_mvo()).find("no remote deployment") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +// ── Two same-kind EVM outposts each keep their own distinct deployment ── +// (the registry half of WSA-075: both operator daemons read these per row.) +BOOST_FIXTURE_TEST_CASE(two_evm_outposts_keep_distinct_bindings, sysio_chains_tester) { try { + constexpr auto BASE_OPP = "0x1111111111111111111111111111111111111111"; + constexpr auto BASE_INBOUND = "0x2222222222222222222222222222222222222222"; + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, + evm_outpost_mvo(EVM_OPP, EVM_INBOUND, EVM_OPREG, EVM_DEPOSIT))); + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, "BASE", 8453, + evm_outpost_mvo(BASE_OPP, BASE_INBOUND, EVM_OPREG, EVM_DEPOSIT))); + + BOOST_REQUIRE_EQUAL(std::string(EVM_OPP), stored_addr("ETH", "opp_addr")); + BOOST_REQUIRE_EQUAL(std::string(BASE_OPP), stored_addr("BASE", "opp_addr")); + // Distinct external_chain_id is what the batch operator matches its RPC client on. + BOOST_REQUIRE_EQUAL(1u, get_chain("ETH")["external_chain_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(8453u, get_chain("BASE")["external_chain_id"].as_uint64()); +} FC_LOG_AND_RETHROW() } + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 55ab29c60b..de12e9373e 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -539,7 +539,8 @@ class sysio_dispatch_tester : public tester { ("code", codename_mvo(outpost_code)) ("external_chain_id", 31337) ("name", std::string("outpost-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "schbatchgps"_n, mvo())); @@ -1264,7 +1265,8 @@ class sysio_dispatch_tester : public tester { ("code", codename_mvo("WIRE")) ("external_chain_id", 0) ("name", std::string("wire-depot")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); } /// Register SOLANA, seed ACTIVE ETH+SOLANA reserves, credit UWRIT_OP collateral, @@ -1275,7 +1277,8 @@ class sysio_dispatch_tester : public tester { void setup_eth_to_sol_uwreq(uint64_t att_id) { BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); @@ -1504,7 +1507,8 @@ BOOST_FIXTURE_TEST_CASE(operator_action_mismatched_source_chain_is_dropped, // were proven-delivered from ETH rather than SOLANA — the exact WSA-005 forgery. BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); auto deposit_sol = encode_operator_action( sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, @@ -1536,7 +1540,8 @@ BOOST_FIXTURE_TEST_CASE(swap_request_mismatched_source_chain_is_refunded, bootstrap_for_dispatch(); // ETH source outpost BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); // ACTIVE ETH/ETH/PRIMARY + SOLANA/SOL/PRIMARY reserves BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); @@ -1572,7 +1577,8 @@ BOOST_FIXTURE_TEST_CASE(swap_request_identical_reserve_identity_is_refunded, bootstrap_for_dispatch(); BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); const auto eth = fc::slug_name{"ETH"}.value; @@ -1605,7 +1611,8 @@ BOOST_FIXTURE_TEST_CASE(underwrite_commit_mismatched_source_chain_is_dropped, bootstrap_for_dispatch(); BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); @@ -1663,12 +1670,14 @@ BOOST_FIXTURE_TEST_CASE(underwrite_commit_two_evm_chains_route_per_chain, // A SECOND active EVM chain — same VM family, distinct chain_code. BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_EVM)("code", codename_mvo("POLYGON")) - ("external_chain_id", 137)("name", std::string("polygon-test"))("description", std::string{}))); + ("external_chain_id", 137)("name", std::string("polygon-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); // SOLANA is registered only because the shared reserve-setup helper seeds a // SOLANA/SOL reserve; it is otherwise unused by this two-EVM scenario. BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); BOOST_REQUIRE_EQUAL(success(), regreserve_active("POLYGON", "POL", "PRIMARY")); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); @@ -1957,7 +1966,8 @@ BOOST_FIXTURE_TEST_CASE(node_owner_reg_from_other_evm_outpost_is_dropped, sysio_ // Register the real node-owner source too, so the ONLY thing wrong below is the delivering outpost. BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_EVM)("code", codename_mvo("ETHEREUM")) - ("external_chain_id", 1)("name", std::string("ethereum-mainnet"))("description", std::string{}))); + ("external_chain_id", 1)("name", std::string("ethereum-mainnet"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const auto other_evm = fc::slug_name{"ETH"}.value; // active EVM outpost, but not "ETHEREUM" auto wire_key = k1_pubkey_bytes(get_public_key(CLAIM_ACCOUNT, "active")); @@ -2208,7 +2218,8 @@ BOOST_FIXTURE_TEST_CASE(swap_missing_dst_authex_recovers_after_exact_uic_replay, ("code", codename_mvo("SOLANA")) ("external_chain_id", 900) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const uint64_t eth = fc::slug_name{"ETH"}.value; const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; @@ -2291,7 +2302,8 @@ BOOST_FIXTURE_TEST_CASE(swap_zero_quote_from_active_reserve_fails_closed, ("code", codename_mvo("SOLANA")) ("external_chain_id", 900) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const uint64_t eth = fc::slug_name{"ETH"}.value; const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; @@ -3651,7 +3663,8 @@ BOOST_FIXTURE_TEST_CASE(swap_malformed_destination_signature_preserves_valid_sou ("code", codename_mvo("SOLANA")) ("external_chain_id", 900) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); const uint64_t eth = fc::slug_name{"ETH"}.value; @@ -3771,7 +3784,8 @@ BOOST_FIXTURE_TEST_CASE(swap_forged_claim_cannot_overwrite_honest_candidate, ("code", codename_mvo("SOLANA")) ("external_chain_id", 900) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const auto solana_link_key = fc::crypto::private_key::generate( fc::crypto::private_key::key_type::ed).get_public_key(); BOOST_REQUIRE_EQUAL(success(), push( @@ -4019,7 +4033,8 @@ BOOST_FIXTURE_TEST_CASE(swap_request_malformed_bytes_do_not_abort_consensus_deli bootstrap_for_dispatch(); // ETH source outpost BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); @@ -4075,7 +4090,8 @@ BOOST_FIXTURE_TEST_CASE(createuwreq_duplicate_attestation_id_is_idempotent, bootstrap_for_dispatch(); BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_SVM)("code", codename_mvo("SOLANA")) - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}))); + ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); setup_wire_token_and_reserves(); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); @@ -4202,7 +4218,8 @@ BOOST_FIXTURE_TEST_CASE(swap_request_negative_source_is_reverted, ("code", codename_mvo("SOLANA")) ("external_chain_id", 900) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const uint64_t eth = fc::slug_name{"ETH"}.value; const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; @@ -4321,7 +4338,8 @@ BOOST_FIXTURE_TEST_CASE(swap_race_time_reserve_drain_rejects_request, ("code", codename_mvo("SOLANA")) ("external_chain_id", 900) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const auto solana_link_key = fc::crypto::private_key::generate( fc::crypto::private_key::key_type::ed).get_public_key(); BOOST_REQUIRE_EQUAL(success(), push( @@ -4394,7 +4412,8 @@ BOOST_FIXTURE_TEST_CASE(swap_replayed_uic_variance_drift_rejects_request, ("code", codename_mvo("SOLANA")) ("external_chain_id", 900) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const uint64_t eth = fc::slug_name{"ETH"}.value; const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; diff --git a/contracts/tests/sysio.dispute_tests.cpp b/contracts/tests/sysio.dispute_tests.cpp index 620dcd166e..981c4aa088 100644 --- a/contracts/tests/sysio.dispute_tests.cpp +++ b/contracts/tests/sysio.dispute_tests.cpp @@ -214,7 +214,8 @@ class sysio_dispute_tester : public tester { BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_EVM)("code", codename_mvo("ETH")) - ("external_chain_id", 31337)("name", std::string("ethereum-test"))("description", std::string{}))); + ("external_chain_id", 31337)("name", std::string("ethereum-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "schbatchgps"_n, mvo())); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); @@ -893,7 +894,8 @@ BOOST_FIXTURE_TEST_CASE(chkdispute_unpauses_only_after_last_open_dispute, sysio_ // exist concurrently with the ETH one. BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_EVM)("code", codename_mvo("BASE")) - ("external_chain_id", 8453)("name", std::string("base-test"))("description", std::string{}))); + ("external_chain_id", 8453)("name", std::string("base-test"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); const uint64_t base_code = fc::slug_name{"BASE"}.value; const uint32_t epoch = current_epoch(); diff --git a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp index a0888cc1fb..72895a0c0f 100644 --- a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp +++ b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp @@ -244,14 +244,16 @@ class sysio_epoch_flushwtdw_tester : public tester { ("code", codename_mvo("SOL")) ("external_chain_id", 1) ("name", std::string("solana-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() ("kind", ChainKind::CHAIN_KIND_EVM) ("code", codename_mvo("ETH")) ("external_chain_id", 31337) ("name", std::string("ethereum-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "schbatchgps"_n, mvo())); diff --git a/contracts/tests/sysio.epoch_tests.cpp b/contracts/tests/sysio.epoch_tests.cpp index f8ca221d2f..4b1bf95b85 100644 --- a/contracts/tests/sysio.epoch_tests.cpp +++ b/contracts/tests/sysio.epoch_tests.cpp @@ -6,6 +6,7 @@ #include #include "contracts.hpp" +#include "contract_test_support.hpp" #include using namespace sysio::testing; @@ -110,6 +111,7 @@ class sysio_epoch_tester : public tester { ("external_chain_id", external_chain_id) ("name", name_str) ("description", description) + ("outpost", sysio_system::test_support::no_outpost_mvo()) ); } diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index e107e0cfbc..2e4a21eac5 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -327,7 +327,8 @@ class sysio_msgch_chain_tester : public tester { ("code", codename_mvo(code)) ("external_chain_id", chain_id) ("name", std::string("outpost-test")) - ("description", std::string{}))); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()))); } uint32_t advance_one_epoch() { diff --git a/contracts/tests/sysio.msgch_tests.cpp b/contracts/tests/sysio.msgch_tests.cpp index d733fc459c..e237de42bd 100644 --- a/contracts/tests/sysio.msgch_tests.cpp +++ b/contracts/tests/sysio.msgch_tests.cpp @@ -7,6 +7,7 @@ #include #include "contracts.hpp" +#include "contract_test_support.hpp" #include using namespace sysio::testing; @@ -101,6 +102,7 @@ class sysio_msgch_tester : public tester { ("external_chain_id", chain_id) ("name", std::string("outpost")) ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()) ); return success(); } @@ -270,6 +272,7 @@ class sysio_msgch_envlog_tester : public tester { ("external_chain_id", chain_id) ("name", std::string("outpost")) ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo()) )); } diff --git a/contracts/tests/sysio.reserv_tests.cpp b/contracts/tests/sysio.reserv_tests.cpp index ecc9666fe4..78a91a8f01 100644 --- a/contracts/tests/sysio.reserv_tests.cpp +++ b/contracts/tests/sysio.reserv_tests.cpp @@ -10,6 +10,7 @@ #include #include "contracts.hpp" +#include "contract_test_support.hpp" using namespace sysio::testing; using namespace sysio; @@ -137,7 +138,8 @@ class sysio_reserve_tester : public tester { ("code", codename_mvo(code)) ("external_chain_id", external_chain_id) ("name", std::string("outpost")) - ("description", std::string{})); + ("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo())); } action_result push_to(name account, abi_serializer& ser, name signer, diff --git a/libraries/libfc/include/sysio/depot/chains_registry.hpp b/libraries/libfc/include/sysio/depot/chains_registry.hpp new file mode 100644 index 0000000000..0fab029b92 --- /dev/null +++ b/libraries/libfc/include/sysio/depot/chains_registry.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +/** + * @file + * Depot-side view of the `sysio.chains::chains` registry row. + * + * Both `batch_operator_plugin` and `underwriter_plugin` read the same rows to + * discover the chains they serve and the remote contracts they talk to. The + * field spellings and the one non-obvious decoding rule live here so the two + * daemons cannot drift apart — no plugin dependency, no duplicated literals. + * + * Keep in lockstep with `chain_row` in + * `contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp`. + */ +namespace sysio::depot::chains { + +/// Account and table the registry lives on. +inline constexpr auto account = "sysio.chains"; +inline constexpr auto table_chains = "chains"; + +/// Field names on a `chain_row` as they surface through the ABI serializer. +namespace field { + inline constexpr auto code = "code"; // {value: uint64} slug_name + inline constexpr auto kind = "kind"; // ChainKind enum (string spelling) + inline constexpr auto external_chain_id = "external_chain_id"; // uint32 + inline constexpr auto is_depot = "is_depot"; // bool — the single WIRE-self row + inline constexpr auto active = "active"; // bool + inline constexpr auto outpost = "outpost"; // nested outpost_addrs struct + + /// Field names on the nested `outpost` struct. `sysio.chains` validates the + /// set against the row's kind before it is stored, so a non-empty value here + /// is already well-formed for that chain; a reader only has to check presence. + namespace outpost_addr { + inline constexpr auto opp_addr = "opp_addr"; + inline constexpr auto opp_inbound_addr = "opp_inbound_addr"; + inline constexpr auto operator_registry_addr = "operator_registry_addr"; + inline constexpr auto source_deposit_addr = "source_deposit_addr"; + } +} + +/** + * @brief Resolve the address of one remote role, applying the single-program rule. + * + * An EVM outpost deploys a separate contract per role, so each role field names + * its own address. An SVM outpost is ONE program serving every role, so the + * registry stores it in `opp_addr` and `sysio.chains` REQUIRES the role fields + * to be empty. A reader that wants a specific role therefore falls back to + * `opp_addr` whenever the role field is empty, and both deployment shapes are + * handled without branching on `ChainKind` at every call site. + * + * @param role_addr Role-specific field from the row's `outpost` struct. + * @param opp_addr The row's `opp_addr` field. + * @return The role's address, or empty when the row is not configured yet. + */ +inline std::string resolve_role_addr(std::string_view role_addr, std::string_view opp_addr) { + return role_addr.empty() ? std::string{opp_addr} : std::string{role_addr}; +} + +} // namespace sysio::depot::chains diff --git a/libraries/libfc/test/CMakeLists.txt b/libraries/libfc/test/CMakeLists.txt index 7c8b7eb94f..f682f1523c 100644 --- a/libraries/libfc/test/CMakeLists.txt +++ b/libraries/libfc/test/CMakeLists.txt @@ -42,6 +42,7 @@ add_executable( test_fc test_bls.cpp test_bitset.cpp test_ordered_diff.cpp + test_chains_registry.cpp test_opreg_status.cpp test_system_timer.cpp parallel/test_worker_task_queue.cpp diff --git a/libraries/libfc/test/test_chains_registry.cpp b/libraries/libfc/test/test_chains_registry.cpp new file mode 100644 index 0000000000..0b29fa32f5 --- /dev/null +++ b/libraries/libfc/test/test_chains_registry.cpp @@ -0,0 +1,65 @@ +/// Pure-logic unit tests for the shared `sysio.chains::chains` row view +/// consumed by `batch_operator_plugin` and `underwriter_plugin`. +/// +/// The chain read itself happens through `chain_plugin::read_table_rows` +/// (integration territory; covered by the flow tests in `wire-tools-ts`). +/// What is pinned here is the part a refactor can silently break: the field +/// spellings the two daemons decode rows with, and the single-program rule +/// that lets one code path serve both EVM and SVM deployment shapes. + +#include + +#include + +#include + +namespace c = sysio::depot::chains; + +BOOST_AUTO_TEST_SUITE(chains_registry_tests) + +/// EVM: each role names its own contract, so the role field wins. +BOOST_AUTO_TEST_CASE(role_specific_address_is_used_when_present) { + constexpr auto opp = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; + constexpr auto opreg = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0"; + BOOST_REQUIRE_EQUAL(std::string{opreg}, c::resolve_role_addr(opreg, opp)); +} + +/// SVM: one program serves every role, so `sysio.chains` requires the role +/// fields to be empty and a reader falls back to `opp_addr`. +BOOST_AUTO_TEST_CASE(empty_role_address_falls_back_to_opp_addr) { + constexpr auto program = "So11111111111111111111111111111111111111112"; + BOOST_REQUIRE_EQUAL(std::string{program}, c::resolve_role_addr("", program)); +} + +/// A row registered before its remote contracts were deployed carries neither, +/// and must resolve to empty so the caller can fail closed rather than sign to +/// the zero address. +BOOST_AUTO_TEST_CASE(unconfigured_row_resolves_to_empty) { + BOOST_REQUIRE_EQUAL(std::string{}, c::resolve_role_addr("", "")); +} + +/// Spelling regression guard — these must match `chain_row` and its nested +/// `outpost_addrs` struct in `contracts/sysio.chains`. A contract-side rename +/// that misses this header would otherwise surface as both daemons quietly +/// reading empty addresses and skipping every chain. +BOOST_AUTO_TEST_CASE(field_spellings_match_the_contract_row) { + BOOST_REQUIRE_EQUAL(std::string{"sysio.chains"}, std::string{c::account}); + BOOST_REQUIRE_EQUAL(std::string{"chains"}, std::string{c::table_chains}); + BOOST_REQUIRE_EQUAL(std::string{"code"}, std::string{c::field::code}); + BOOST_REQUIRE_EQUAL(std::string{"kind"}, std::string{c::field::kind}); + BOOST_REQUIRE_EQUAL(std::string{"external_chain_id"}, std::string{c::field::external_chain_id}); + BOOST_REQUIRE_EQUAL(std::string{"is_depot"}, std::string{c::field::is_depot}); + BOOST_REQUIRE_EQUAL(std::string{"active"}, std::string{c::field::active}); + BOOST_REQUIRE_EQUAL(std::string{"outpost"}, std::string{c::field::outpost}); + + BOOST_REQUIRE_EQUAL(std::string{"opp_addr"}, + std::string{c::field::outpost_addr::opp_addr}); + BOOST_REQUIRE_EQUAL(std::string{"opp_inbound_addr"}, + std::string{c::field::outpost_addr::opp_inbound_addr}); + BOOST_REQUIRE_EQUAL(std::string{"operator_registry_addr"}, + std::string{c::field::outpost_addr::operator_registry_addr}); + BOOST_REQUIRE_EQUAL(std::string{"source_deposit_addr"}, + std::string{c::field::outpost_addr::source_deposit_addr}); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/batch_operator_plugin/README.md b/plugins/batch_operator_plugin/README.md index d6a2a9dadf..eadd40bd29 100644 --- a/plugins/batch_operator_plugin/README.md +++ b/plugins/batch_operator_plugin/README.md @@ -25,11 +25,34 @@ All 21 batch operators run this plugin in perpetuity. The epoch scheduler (`sysi | Option | Default | Description | |--------|---------|-------------| | `--batch-operator-account` | — | WIRE account name for this operator | -| `--batch-epoch-poll-ms` | 5000 | How often to check epoch state (ms) | -| `--batch-outpost-poll-ms` | 3000 | How often to poll outpost for new messages (ms) | -| `--batch-delivery-timeout-ms` | 30000 | Max time to wait for chain delivery confirmation (ms) | +| `--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-enabled` | false | Enable batch operator functionality | +### Outpost wiring + +Nothing about an outpost is declared per node. + +* **Which chains** — every active non-depot `sysio.chains` row. +* **Where each one lives** — the row's own `outpost` struct (`opp_addr` / + `opp_inbound_addr`), so every operator relays a chain through the same + deployment. +* **How to reach it** — the RPC client registered under that chain's **own + code**. `--outpost-ethereum-client` / `--outpost-solana-client` take the + client id as their first field, and for an outpost that id must be the chain + code (`ETHEREUM`, `SOLANA`, ...). The Ethereum client's verified `eth_chainId` + is additionally asserted against the row's `external_chain_id`, so a client + registered under the wrong code is rejected rather than relayed through. + +An elected group must deliver on **every** active chain, so a missing RPC client +is fatal: the node logs the chains it cannot serve and shuts down. The check runs +after the sync gate, where `sysio.chains` is readable. Missing contract +*addresses* are not fatal — they are governance state, fixable with +`sysio.chains::setoutpost` without touching a node — so such a chain is skipped +fail-closed and picked up on a later tick. A `setoutpost` redeploy is likewise +picked up on the next epoch tick: the relay job is rebuilt against the new +address rather than left pointing at the old one. + ## Dependencies - `chain_plugin` — blockchain state access diff --git a/plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/outpost_binding.hpp b/plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/outpost_binding.hpp deleted file mode 100644 index b1d5b80ae5..0000000000 --- a/plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/outpost_binding.hpp +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once -/** - * @file outpost_binding.hpp - * @brief Per-chain-code remote OPP contract bindings supplied via the - * `--batch-outpost` config option. - */ - -#include -#include - -#include -#include -#include - -namespace sysio::batch_operator_detail { - -/// Repeatable config option binding one active `sysio.chains` row (by its -/// chain code) to the exact remote OPP contract identity this operator -/// relays it through. See `parse_outpost_binding` for the spec format. -inline constexpr auto BATCH_OUTPOST_OPTION = "batch-outpost"; - -/// One `--batch-outpost` binding: the exact remote OPP contract identity -/// for a single active `sysio.chains` row, keyed by the row's chain code. -/// Which fields a row requires is enforced in `build_opp_jobs`, where the -/// row's on-chain `ChainKind` is known: -/// * EVM — `opp_addr` = OPP contract, `opp_inbound_addr` = OPPInbound -/// contract, both required (0x-hex). -/// * SVM — `opp_addr` = the outpost program id (base58); the single -/// program serves both directions, so no inbound address. -struct outpost_binding { - std::string opp_addr; - std::string opp_inbound_addr; -}; - -/// Parse one `--batch-outpost` spec — `,[,]` -/// — into (packed chain code, binding). Comma-separated like the -/// `--outpost-ethereum-client` spec. Throws on a malformed spec so a -/// misconfigured node refuses to start rather than relaying an outpost -/// with a wrong or partial remote identity. -inline std::pair parse_outpost_binding(const std::string& spec) { - auto parts = fc::split(spec, ','); - FC_ASSERT(parts.size() == 2 || parts.size() == 3, - "Invalid {} spec '{}': expected ,[,]", - BATCH_OUTPOST_OPTION, spec); - FC_ASSERT(!parts[0].empty() && !parts[1].empty() && (parts.size() == 2 || !parts[2].empty()), - "Invalid {} spec '{}': empty field", BATCH_OUTPOST_OPTION, spec); - const fc::slug_name code{parts[0]}; // throws unless [A-Z0-9_], <= 8 chars - outpost_binding binding; - binding.opp_addr = parts[1]; - if (parts.size() == 3) - binding.opp_inbound_addr = parts[2]; - return {code.value, std::move(binding)}; -} - -} // namespace sysio::batch_operator_detail diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index d8ff24b065..5adf88de97 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -16,9 +16,9 @@ #include #include -#include #include #include +#include #include #include #include @@ -107,19 +107,9 @@ namespace { /// v6: chain registry was split out of `sysio.epoch` onto its own /// `sysio.chains` contract. The `outposts` table was replaced by the - /// `chains` KV table, keyed by slug_name (uint64 packed). - namespace chains { - constexpr auto account = "sysio.chains"; - constexpr auto table_chains = "chains"; - /// Field names on `Chain` row (proto-mirror schema). - namespace field { - constexpr auto code = "code"; // {value: uint64} slug_name - constexpr auto kind = "kind"; // ChainKind enum (string spelling) - constexpr auto external_chain_id = "external_chain_id"; // uint32 - constexpr auto is_depot = "is_depot"; // bool — the single WIRE-self row - constexpr auto active = "active"; // bool - } - } + /// `chains` KV table, keyed by slug_name (uint64 packed). Field spellings + /// are shared with underwriter_plugin, which reads the same rows. + namespace chains = sysio::depot::chains; } // --------------------------------------------------------------------------- @@ -129,6 +119,12 @@ struct outpost_descriptor { uint64_t id = 0; ChainKind chain_kind = CHAIN_KIND_UNKNOWN; uint32_t chain_id = 0; + /// Remote contract identities from the row's nested `outpost` struct. EVM + /// rows carry both; an SVM row carries `opp_addr` alone, because the single + /// outpost program serves both directions. Either may be empty while the + /// remote contract is not yet deployed — `build_opp_jobs` fails closed. + std::string opp_addr; + std::string opp_inbound_addr; }; // --------------------------------------------------------------------------- @@ -140,14 +136,6 @@ struct batch_operator_plugin::impl { bool enabled = false; uint32_t epoch_poll_ms = EPOCH_POLL_MS; uint32_t delivery_timeout_ms = DELIVERY_TIMEOUT_MS; - // SVM RPC client id (one Solana cluster serves all SVM programs). The EVM - // client is selected per outpost by external_chain_id, and each outpost's - // OPP contract addresses come from its `--batch-outpost` binding — see - // build_opp_jobs. - std::string sol_client_id; - /// Remote OPP contract bindings from `--batch-outpost`, keyed by packed - /// chain code (matches `outpost_descriptor::id`). - std::map outpost_bindings; // Epoch state tracked across polls uint32_t current_epoch = 0; @@ -282,7 +270,17 @@ struct batch_operator_plugin::impl { std::unique_ptr depot_ops_backing{ std::make_unique(*this)}; - std::map> opp_jobs; + /// A built relay job together with the remote contract identities it was + /// built from. Addresses now come from `sysio.chains`, where governance can + /// change them under a running node via `setoutpost`; keeping the pair here + /// lets `prune_stale_opp_jobs` notice a redeploy and rebuild, instead of + /// leaving the job relaying to an address the outpost has moved off. + struct built_opp_job { + std::shared_ptr job; + std::string opp_addr; + std::string opp_inbound_addr; + }; + std::map opp_jobs; // ----------------------------------------------------------------------- // Table read helper @@ -573,10 +571,21 @@ struct batch_operator_plugin::impl { od.id = code_val; // slug_name uint64 doubles as outpost id od.chain_kind = obj[chains::field::kind].as(); od.chain_id = static_cast(obj[chains::field::external_chain_id].as_uint64()); + // The remote contract identities live in a nested struct on the row. + // Absent (pre-upgrade row) reads as empty, which fails closed below + // exactly like a row governance has not configured yet. + if (auto out_it = obj.find(chains::field::outpost); + out_it != obj.end() && out_it->value().is_object()) { + const auto& out_obj = out_it->value().get_object(); + if (auto a = out_obj.find(chains::field::outpost_addr::opp_addr); a != out_obj.end()) + od.opp_addr = a->value().as_string(); + if (auto a = out_obj.find(chains::field::outpost_addr::opp_inbound_addr); a != out_obj.end()) + od.opp_inbound_addr = a->value().as_string(); + } outposts.push_back(std::move(od)); } ilog("batch_operator: loaded {} outposts (v6 sysio.chains)", outposts.size()); - prune_inactive_opp_jobs(); + prune_stale_opp_jobs(); build_opp_jobs(); schedule_opp_jobs(); } @@ -585,82 +594,105 @@ struct batch_operator_plugin::impl { /// chain-specific plugin factories. Idempotent: already-built jobs stay. /// Called from `refresh_outposts`; scheduling is handled separately so /// startup-created jobs and governance-added jobs share the same path. + /// + /// An elected group fans the epoch cycle out across EVERY active chain, so a + /// batch operator that cannot reach one of them cannot do its job — it would + /// simply withhold deliveries for that chain and drag its group below + /// consensus. A missing RPC client is therefore FATAL, not skippable: the + /// node names the chains it cannot serve and quits, so the failure is + /// visible to a supervisor instead of hiding behind a running process. Only + /// the operator can fix it (it is local config), and this runs after the + /// sync gate, where `sysio.chains` is actually readable. + /// + /// Missing CONTRACT ADDRESSES are different and stay non-fatal: those are + /// governance state on the row, fixable with `sysio.chains::setoutpost` + /// without touching any node, so the chain is skipped fail-closed and picked + /// up on a later tick. void build_opp_jobs() { if (!depot_ops_backing) return; // plugin not initialized yet + // (chain code, why it cannot be served) for the fatal check below. + std::vector> unserviceable; for (auto& op : outposts) { if (opp_jobs.contains(op.id)) continue; + const auto code_str = fc::slug_name{op.id}.to_string(); std::shared_ptr client; try { if (op.chain_kind == CHAIN_KIND_EVM) { // Bind this exact outpost to its own remote identity: the RPC - // client is the one whose configured chain id matches this - // row's external_chain_id (never a shared per-kind tuple), and - // the OPP / OPPInbound contract addresses come from the row's - // `--batch-outpost` binding. Anything missing => skip the job - // (fail closed) so an outpost is never relayed through another - // chain's endpoint. - auto entry = eth_plug->get_client_by_chain_id(op.chain_id); + // client is the one registered under this chain's OWN code, and + // the OPP / OPPInbound contract addresses come from the row + // itself. `create_outpost_client` additionally asserts the + // client's verified eth_chainId equals the row's + // external_chain_id, so a client registered under the wrong code + // is caught rather than relayed through. + auto entry = eth_plug->get_client(code_str); if (!entry) { - wlog("batch_operator: no unique configured Ethereum client for chain_id {} " - "(outpost {}); skipping until one is configured", - op.chain_id, fc::slug_name{op.id}.to_string()); + unserviceable.emplace_back(code_str, "no Ethereum RPC client is registered " + "under this chain code"); continue; } - auto bound = outpost_bindings.find(op.id); - if (bound == outpost_bindings.end() || bound->second.opp_inbound_addr.empty()) { - wlog("batch_operator: outpost {} (EVM) has no {}=,, " - "binding; skipping until one is configured", - fc::slug_name{op.id}.to_string(), batch_operator_detail::BATCH_OUTPOST_OPTION); + if (op.opp_addr.empty() || op.opp_inbound_addr.empty()) { + wlog("batch_operator: outpost {} (EVM) has no OPP/OPPInbound address on its " + "sysio.chains row; skipping until sysio.chains::setoutpost supplies both", + code_str); continue; } - client = eth_plug->create_outpost_client(entry->id, op.id, op.chain_id, - bound->second.opp_addr, - bound->second.opp_inbound_addr); + client = eth_plug->create_outpost_client(code_str, op.id, op.chain_id, + op.opp_addr, op.opp_inbound_addr); } else if (op.chain_kind == CHAIN_KIND_SVM) { - // SVM: one Solana cluster (RPC client) serves every program, so - // the shared sol client is correct; the per-outpost identity is - // the program id from the row's `--batch-outpost` binding. - auto bound = outpost_bindings.find(op.id); - if (bound == outpost_bindings.end()) { - wlog("batch_operator: outpost {} (SVM) has no {}=, binding; " - "skipping until one is configured", - fc::slug_name{op.id}.to_string(), batch_operator_detail::BATCH_OUTPOST_OPTION); + // SVM: the RPC client is likewise registered under the chain's + // own code. The per-outpost identity is the program id on the + // row; `sysio.chains` already rejects an SVM row that carries a + // separate inbound address, so only presence is checked here. + if (!sol_plug->get_client(code_str)) { + unserviceable.emplace_back(code_str, "no Solana RPC client is registered " + "under this chain code"); continue; } - if (!bound->second.opp_inbound_addr.empty()) { - wlog("batch_operator: outpost {} (SVM) binding must not carry an inbound " - "address (the single program serves both directions); skipping", - fc::slug_name{op.id}.to_string()); + if (op.opp_addr.empty()) { + wlog("batch_operator: outpost {} (SVM) has no program id on its sysio.chains " + "row; skipping until sysio.chains::setoutpost supplies one", + code_str); continue; } - client = sol_plug->create_outpost_client(sol_client_id, op.id, op.chain_id, - bound->second.opp_addr, + client = sol_plug->create_outpost_client(code_str, op.id, op.chain_id, + op.opp_addr, solana_outpost_role::batch_operator); } else { - wlog("batch_operator: outpost {} has unsupported chain_kind, skipping job build", - op.id); + // A chain kind this build does not know how to relay is just as + // unserviceable as a missing client, and equally unfixable + // on-chain — the operator needs a newer nodeop. + unserviceable.emplace_back(code_str, + std::format("chain kind {} is not supported by this build", + ChainKind_Name(op.chain_kind))); continue; } } catch (const fc::exception& e) { wlog("batch_operator: failed to build outpost_client for outpost {}: {}", - op.id, e.to_string()); + code_str, e.to_string()); continue; } auto job = std::make_shared( client, *depot_ops_backing, fc::milliseconds(delivery_timeout_ms)); - opp_jobs.emplace(op.id, std::move(job)); + opp_jobs.emplace(op.id, built_opp_job{std::move(job), op.opp_addr, op.opp_inbound_addr}); ilog("batch_operator: built outpost_opp_job for {}", client->to_string()); } - } - /// Returns true when a chain code is present in the latest active outpost list. - bool is_current_outpost(uint64_t chain_code) const { - for (const auto& outpost : outposts) { - if (outpost.id == chain_code) return true; + if (!unserviceable.empty()) { + for (const auto& [code, why] : unserviceable) { + elog("batch_operator: cannot serve active chain {} — {}", code, why); + } + elog("batch_operator: cannot serve {} of {} active chain(s) — shutting down node " + "(an elected group must deliver on every active chain)", + unserviceable.size(), outposts.size()); + // build_opp_jobs also runs from the private cron_service (the epoch + // tick refreshes the active set), so hop to the app thread rather than + // tearing the executor down from a worker. + app().executor().post(appbase::priority::high, appbase::exec_queue::read_write, + []() { app().quit(); }); } - return false; } /// Forget a cron job ID after the job has been cancelled individually. @@ -682,15 +714,33 @@ struct batch_operator_plugin::impl { scheduled_opp_jobs.erase(sched_it); } - /// Remove relay jobs for outposts that are no longer active on `sysio.chains`. - void prune_inactive_opp_jobs() { + /// The current descriptor for a chain code, or nullptr when the chain is no + /// longer in the active set read from `sysio.chains`. + const outpost_descriptor* find_current_outpost(uint64_t chain_code) const { + for (const auto& outpost : outposts) { + if (outpost.id == chain_code) return &outpost; + } + return nullptr; + } + + /// Drop relay jobs that no longer match `sysio.chains`: the chain went + /// inactive, or governance moved its remote deployment with `setoutpost`. + /// `build_opp_jobs` rebuilds what is dropped here on the same refresh, so an + /// address change costs one tick rather than an operator restart — without + /// it a redeployed outpost would keep receiving deliveries at a dead + /// address, and inbound reads would keep polling the old contract. + void prune_stale_opp_jobs() { for (auto it = opp_jobs.begin(); it != opp_jobs.end(); ) { - if (is_current_outpost(it->first)) { + const auto* current = find_current_outpost(it->first); + if (current != nullptr + && current->opp_addr == it->second.opp_addr + && current->opp_inbound_addr == it->second.opp_inbound_addr) { ++it; continue; } cancel_scheduled_opp_job(it->first); - ilog("batch_operator: removed outpost_opp_job for inactive outpost {}", it->first); + ilog("batch_operator: removed outpost_opp_job for {} outpost {}", + current == nullptr ? "inactive" : "redeployed", fc::slug_name{it->first}.to_string()); it = opp_jobs.erase(it); } } @@ -731,8 +781,8 @@ struct batch_operator_plugin::impl { /// Schedule every built active outpost job that does not already have cron entries. void schedule_opp_jobs() { - for (const auto& [chain_code, job] : opp_jobs) { - schedule_opp_job(chain_code, job); + for (const auto& [chain_code, built] : opp_jobs) { + schedule_opp_job(chain_code, built.job); } } @@ -948,18 +998,6 @@ void batch_operator_plugin::set_program_options(options_description& cli, "Max time to wait for chain delivery confirmation (ms)"); opts("batch-enabled", bpo::value()->default_value(false), "Enable batch operator functionality"); - opts("batch-sol-client-id", bpo::value()->default_value("sol-default"), - "Solana outpost client ID (RPC connection) for SVM outpost rows"); - // Help text must not contain a " --" sequence (or non-ASCII): the - // PerformanceHarness plugin-args generator splits nodeop's --help output on - // " --", so option names referenced below are spelled without the dashes. - opts(batch_operator_detail::BATCH_OUTPOST_OPTION, bpo::value>()->multitoken(), - "Remote OPP contract binding for one active sysio.chains row, repeatable once per " - "chain code. Spec: CHAIN_CODE,opp_addr[,opp_inbound_addr]. EVM rows require the OPP " - "and OPPInbound contract addresses (0x-hex); SVM rows require only the outpost " - "program id (base58). The Ethereum RPC client for a row is selected by matching the " - "row's external_chain_id against the chain ids of the configured Ethereum clients; " - "an active row with no binding or no matching client is skipped (fail closed)."); } void batch_operator_plugin::plugin_initialize(const variables_map& options) { @@ -968,17 +1006,6 @@ void batch_operator_plugin::plugin_initialize(const variables_map& options) { _impl->epoch_poll_ms = options["batch-epoch-poll-ms"].as(); _impl->delivery_timeout_ms = options["batch-delivery-timeout-ms"].as(); _impl->enabled = options["batch-enabled"].as(); - _impl->sol_client_id = options["batch-sol-client-id"].as(); - if (options.count(batch_operator_detail::BATCH_OUTPOST_OPTION)) { - for (const auto& spec : - options[batch_operator_detail::BATCH_OUTPOST_OPTION].as>()) { - auto [code, binding] = batch_operator_detail::parse_outpost_binding(spec); - FC_ASSERT(_impl->outpost_bindings.emplace(code, std::move(binding)).second, - "Duplicate {} binding for chain code {}", - batch_operator_detail::BATCH_OUTPOST_OPTION, fc::slug_name{code}.to_string()); - } - } - _impl->chain_plug = &app().get_plugin(); _impl->cron_plug = &app().get_plugin(); _impl->eth_plug = &app().get_plugin(); diff --git a/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp b/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp index c2cc28a57c..0b2ed1a1c2 100644 --- a/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp @@ -13,7 +13,6 @@ #include #include -#include #include #include @@ -58,7 +57,11 @@ BOOST_AUTO_TEST_CASE(plugin_options_are_registered) try { BOOST_CHECK(option_names.count("batch-epoch-poll-ms") > 0); BOOST_CHECK(option_names.count("batch-delivery-timeout-ms") > 0); BOOST_CHECK(option_names.count("batch-enabled") > 0); - BOOST_CHECK(option_names.count(sysio::batch_operator_detail::BATCH_OUTPOST_OPTION) > 0); + // Nothing about an outpost is declared per node any more: the remote contract + // identities come from the chain's sysio.chains row, and the RPC client for a + // chain is the one registered under that chain's own code. + BOOST_CHECK(option_names.count("batch-outpost") == 0); + BOOST_CHECK(option_names.count("batch-sol-client-id") == 0); } FC_LOG_AND_RETHROW(); BOOST_AUTO_TEST_CASE(default_options_are_correct) try { @@ -258,45 +261,4 @@ BOOST_AUTO_TEST_CASE(cron_service_accepts_dynamic_outpost_jobs_after_start) try BOOST_CHECK(svc->list({meta.label}).empty()); } FC_LOG_AND_RETHROW(); -// ── `--batch-outpost` spec parsing ── -// The binding ties one active `sysio.chains` row (by chain code) to the exact -// remote OPP contract identity this operator relays it through; a malformed -// spec must refuse startup rather than relay with a wrong/partial identity. - -BOOST_AUTO_TEST_CASE(batch_outpost_evm_spec_parses_both_addresses) try { - constexpr auto evm_opp = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; - constexpr auto evm_inbound = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"; - const std::string spec = std::string("ETH,") + evm_opp + "," + evm_inbound; - - auto [code, binding] = sysio::batch_operator_detail::parse_outpost_binding(spec); - BOOST_CHECK_EQUAL(code, fc::slug_name{"ETH"}.value); - BOOST_CHECK_EQUAL(binding.opp_addr, evm_opp); - BOOST_CHECK_EQUAL(binding.opp_inbound_addr, evm_inbound); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(batch_outpost_svm_spec_parses_program_id_only) try { - constexpr auto svm_program = "So11111111111111111111111111111111111111112"; - const std::string spec = std::string("SOLANA,") + svm_program; - - auto [code, binding] = sysio::batch_operator_detail::parse_outpost_binding(spec); - BOOST_CHECK_EQUAL(code, fc::slug_name{"SOLANA"}.value); - BOOST_CHECK_EQUAL(binding.opp_addr, svm_program); - BOOST_CHECK_EQUAL(binding.opp_inbound_addr, ""); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(batch_outpost_malformed_specs_are_rejected) try { - using sysio::batch_operator_detail::parse_outpost_binding; - // Wrong field count. - BOOST_CHECK_THROW(parse_outpost_binding(""), fc::exception); - BOOST_CHECK_THROW(parse_outpost_binding("ETH"), fc::exception); - BOOST_CHECK_THROW(parse_outpost_binding("ETH,0xaa,0xbb,0xcc"), fc::exception); - // Empty fields (fc::split preserves empty tokens). - BOOST_CHECK_THROW(parse_outpost_binding(",0xaa,0xbb"), fc::exception); - BOOST_CHECK_THROW(parse_outpost_binding("ETH,,0xbb"), fc::exception); - BOOST_CHECK_THROW(parse_outpost_binding("ETH,0xaa,"), fc::exception); - // Chain code outside the slug_name alphabet [A-Z0-9_] or longer than 8. - BOOST_CHECK_THROW(parse_outpost_binding("eth,0xaa,0xbb"), fc::exception); - BOOST_CHECK_THROW(parse_outpost_binding("TOOLONGCODE,0xaa,0xbb"), fc::exception); -} FC_LOG_AND_RETHROW(); - 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 05306f3abc..c61fb08ff5 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 @@ -129,16 +129,12 @@ class outpost_ethereum_client_plugin : public appbase::plugin get_clients(); + /// Return the configured client registered under `id`, or nullptr when there + /// is none. For an outpost the id is the chain's `sysio.chains` code, so a + /// null result means that chain has no endpoint configured on this node and + /// the caller must fail closed rather than fall back to another client. ethereum_client_entry_ptr get_client(const std::string& id); - /// Return the single configured client whose authoritative 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. - ethereum_client_entry_ptr get_client_by_chain_id(uint32_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 476bd49bd2..ff67eb9d4e 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 @@ -435,18 +435,10 @@ class outpost_ethereum_client_plugin_impl { return std::views::values(_clients) | std::ranges::to(); } - /** Return the published client identified by @p id. */ - ethereum_client_entry_ptr get_client(const std::string& id) { return _clients.at(id); } - - /** Return the unique client for @p chain_id, or null when the id is ambiguous. */ - ethereum_client_entry_ptr get_client_by_chain_id(uint32_t chain_id) { - ethereum_client_entry_ptr match; - for (const auto& entry : std::views::values(_clients)) { - if (entry->chain_id != chain_id) continue; - if (match) return nullptr; - match = entry; - } - return match; + /** Return the published client identified by @p id, or null when there is none. */ + ethereum_client_entry_ptr get_client(const std::string& id) { + auto it = _clients.find(id); + return it == _clients.end() ? nullptr : it->second; } /** Return all loaded ABI files and their parsed contracts. */ @@ -514,7 +506,9 @@ void outpost_ethereum_client_plugin::set_program_options(options_description& cl boost::program_options::value>()->multitoken(), "Legacy outpost Ethereum client spec: " ",,[,]. A three-field spec resolves " - "eth_chainId during startup; a four-field chain id controls signing and is verified against the endpoint.") + "eth_chainId during startup; a four-field chain id controls signing and is verified against the endpoint. " + "For a client serving an OPP outpost the client-id MUST be that chain's sysio.chains code " + "(e.g. ETHEREUM): the operator daemons look their RPC client up under the chain code.") (option_name_client_config_file, boost::program_options::value(), "Versioned protobuf-JSON outpost Ethereum client configuration file. Cannot be combined " @@ -537,10 +531,6 @@ ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client(const std:: return my->get_client(id); } -ethereum_client_entry_ptr outpost_ethereum_client_plugin::get_client_by_chain_id(uint32_t chain_id) { - return my->get_client_by_chain_id(chain_id); -} - const std::vector>>& outpost_ethereum_client_plugin::get_abi_files() { diff --git a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp index 3318cda388..8c9a9214c9 100644 --- a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp +++ b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp @@ -380,6 +380,10 @@ class outpost_solana_client_plugin : public appbase::plugin get_clients(); + /// Return the configured client registered under `id`, or nullptr when there + /// is none. For an outpost the id is the chain's `sysio.chains` code, so a + /// null result means that chain has no endpoint configured on this node and + /// the caller must fail closed rather than fall back to another client. solana_client_entry_ptr get_client(const std::string& id); const std::vector>>& get_idl_files(); diff --git a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp index 8b357cc9f7..ca76b93ffd 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp @@ -66,8 +66,10 @@ class outpost_solana_client_plugin_impl { return std::views::values(_clients) | std::ranges::to(); } + /** Return the published client identified by @p id, or null when there is none. */ solana_client_entry_ptr get_client(const std::string& id) { - return _clients.at(id); + auto it = _clients.find(id); + return it == _clients.end() ? nullptr : it->second; } void add_client(const std::string& id, solana_client_entry_ptr client) { @@ -160,7 +162,9 @@ void outpost_solana_client_plugin::set_program_options(options_description& cli, boost::program_options::value>()->multitoken(), "Outpost Solana Client spec, the plugin supports 1 to many clients in a given process. " "Format: `,,`. The signer id must " - "match an explicitly named --signature-provider with the Solana target chain and key type")( + "match an explicitly named --signature-provider with the Solana target chain and key type. " + "For a client serving an OPP outpost the sol-client-id MUST be that chain's sysio.chains " + "code (e.g. SOLANA): the operator daemons look their RPC client up under the chain code")( option_idl_file, boost::program_options::value>()->multitoken(), "Solana program IDL file(s). Expects each file to be a JSON IDL (Anchor format) program definition.")( diff --git a/plugins/underwriter_plugin/README.md b/plugins/underwriter_plugin/README.md index 5b24d8972d..316ef38be4 100644 --- a/plugins/underwriter_plugin/README.md +++ b/plugins/underwriter_plugin/README.md @@ -160,20 +160,33 @@ authenticated depot submitter. | `--underwriter-scan-interval-ms` | 5000 | How often to scan for pending uwreqs (ms) | | `--underwriter-action-timeout-ms` | 15000 | Timeout for outpost RPC calls and table reads (ms) | | `--underwriter-enabled` | false | Enable underwriter functionality | -| `--underwriter-eth-outpost` | — | Per-EVM-chain outpost wiring (repeatable, one per served EVM chain). Format `,,,` — keyed by exact `chain_code`, so two EVM chains are wired independently | -| `--underwriter-sol-outpost` | — | Per-SVM-chain outpost wiring (repeatable, one per served SVM chain). Format `,,` | | `--underwriter-eth-source-deposit-function` | — | Name of the ETH swap-deposit function; the chain-agnostic 4-byte selector is resolved at preflight from the loaded `--ethereum-abi-file` ABIs (required) | | `--underwriter-sol-source-deposit-instruction` | — | Name of the SOL swap-deposit instruction; the 8-byte anchor discriminator is resolved at preflight from the loaded `--solana-idl-file` IDLs (required) | | `--underwriter-eth-source-deposit-lookback-blocks` | 7200 | Recent finalized ETH blocks searched per source deposit | -> SEC-13/WSA-027: the former single `--underwriter-eth-client-id`, -> `--underwriter-sol-client-id`, `--underwriter-eth-opreg-addr`, and -> `--underwriter-sol-program-id` options are replaced by the repeatable, -> exact-`chain_code`-keyed `--underwriter-{eth,sol}-outpost` options above. -> One entry is required for **every active** non-depot chain in -> `sysio.chains::chains` (inactive/not-yet-activated chains are skipped); -> the underwriter's per-chain contract / program address now lives in that -> entry rather than in a per-family scalar option. +### Outpost wiring + +Nothing about an outpost is declared per node. + +* **Which chains** — every active non-depot `sysio.chains` row. There is no + per-chain config entry to keep in sync with the registry. +* **Where each one lives** — the row's `outpost` struct: the EVM + OperatorRegistry (the `uw_commit` target) and source-deposit contract, or the + single SVM outpost program, which stands in for both roles. Every underwriter + therefore commits against the same deployment. +* **How to reach it** — the RPC client registered under that chain's **own + code**. `--outpost-ethereum-client` / `--outpost-solana-client` take the client + id as their first field, and for an outpost that id must be the chain code + (`ETHEREUM`, `SOLANA`, ...). + +Preflight fails closed when an active chain has no RPC client registered under +its code, or when its row carries no contract addresses. A +`sysio.chains::setoutpost` redeploy is picked up on the next scan tick — the +client handle is rebuilt against the new address. + +SEC-13/WSA-027 is preserved by construction: the client is keyed by exact chain +code, so two chains of the same VM family are wired independently and a +wrong-family entry is simply a client that is not there. ## HTTP diagnostics diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp index 45960f4647..2a9a060bcf 100644 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp +++ b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp @@ -21,7 +21,6 @@ #include #include #include -#include namespace sysio::underwriter_detail { @@ -173,38 +172,4 @@ inline stored_commit_plan plan_stored_commits(bool candidate_exists, }; } -/// One outpost chain whose active registry row and configured endpoint disagree. -struct endpoint_coverage_gap { - uint64_t chain_code = 0; ///< `fc::slug_name::value` of the offending chain. - std::optional registry_kind; ///< Registry kind, or empty when inactive/unregistered. - std::optional config_kind; ///< Configured kind, or empty when unconfigured. -}; - -/// Verify a one-to-one match between active registry chains and configured endpoints. -/// -/// SEC-13 / WSA-027: the underwriter derives its served set from the on-chain -/// registry (`sysio.chains`) but builds its outpost_client handles only from -/// operator config. A registered chain that is unconfigured, or configured -/// under the wrong VM family, lets the scan loop select a request whose leg has -/// no (or a wrong-kind) client and land only the OTHER leg. Preflight uses this -/// to fail closed before scheduling the scan job. Kinds are compared as raw -/// integers so this header stays free of opp/fc dependencies (the plugin passes -/// `magic_enum::enum_integer(kind)` at the boundary). -inline std::optional -find_endpoint_coverage_gap(const std::map& registered_kinds, - const std::map& configured_kinds) { - for (const auto& [chain_code, reg_kind] : registered_kinds) { - auto it = configured_kinds.find(chain_code); - if (it == configured_kinds.end()) - return endpoint_coverage_gap{chain_code, reg_kind, std::nullopt}; - if (it->second != reg_kind) - return endpoint_coverage_gap{chain_code, reg_kind, it->second}; - } - for (const auto& [chain_code, config_kind] : configured_kinds) { - if (!registered_kinds.contains(chain_code)) - return endpoint_coverage_gap{chain_code, std::nullopt, config_kind}; - } - return std::nullopt; -} - } // namespace sysio::underwriter_detail diff --git a/plugins/underwriter_plugin/src/underwriter_plugin.cpp b/plugins/underwriter_plugin/src/underwriter_plugin.cpp index f0f4e0c5a3..53da36eafc 100644 --- a/plugins/underwriter_plugin/src/underwriter_plugin.cpp +++ b/plugins/underwriter_plugin/src/underwriter_plugin.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +55,9 @@ using namespace chain_apis; using namespace sysio::opp::types; namespace eth = fc::network::ethereum; namespace opp_att = sysio::opp::attestations; +/// Field spellings for the `sysio.chains::chains` rows this plugin reads, +/// shared with batch_operator_plugin so the two daemons cannot drift apart. +namespace depot_chains = sysio::depot::chains; // SEC-13/WSA-027: exact-(chain_code, token_code, reserve_code) routing / // accounting keys, lifted to a testable detail header. These replace the @@ -224,6 +228,10 @@ struct credit_line { struct outpost_endpoint { ChainKind kind = ChainKind::CHAIN_KIND_UNKNOWN; std::string client_id; ///< RPC connection id in the outpost client plugin + /// Remote contract identities, filled by `read_outpost_registry` from the + /// chain's `sysio.chains` row rather than from this node's config, so every + /// underwriter commits against the same deployment. Empty until governance + /// configures the row; the preflight and the wiring step both fail closed. std::string commit_addr; ///< ETH OperatorRegistry addr / SOL program id std::string source_deposit_addr; ///< ETH SwapDeposit contract / SOL program id }; @@ -238,10 +246,10 @@ struct underwriter_plugin::impl { uint32_t scan_interval_ms = underwriter_defaults::scan_interval_ms; uint32_t action_timeout_ms = underwriter_defaults::action_timeout_ms; /// SEC-13/WSA-027: per-chain outpost wiring, keyed by EXACT `chain_code` - /// slug value. One entry per chain the underwriter serves (operator-supplied - /// via `--underwriter-{eth,sol}-outpost`). Replaces the former single - /// eth/sol client-id + address, which could not distinguish two chains of - /// the same VM family. + /// slug value — one entry per ACTIVE non-depot chain. Built entirely by + /// `read_outpost_registry` from `sysio.chains`: the registry names which + /// chains are served and where their contracts live, and the RPC client id + /// is the chain's own code. Nothing here comes from this node's config. std::map outpost_endpoints; /// Per-chain external (numeric) chain id, captured from `sysio.chains` /// (`external_chain_id`) by `read_outpost_registry`, fed to @@ -365,6 +373,9 @@ struct underwriter_plugin::impl { /// discovery, address encoding, on-chain confirmation) lives in the /// concrete. Per `outpost-client-spi.md`. std::map outpost_by_chain; + /// The `commit_addr` each entry of `outpost_by_chain` was built against, so + /// a `sysio.chains::setoutpost` redeploy is noticed and the handle rebuilt. + std::map wired_commit_addrs; /// v6 cross-walk: token slug_name → TokenKind enum. Refreshed each /// scan cycle by `read_credit_lines` (which reads `sysio.tokens::tokens` /// for the lookup); used by `scan_pending_requests` to translate the @@ -542,52 +553,49 @@ struct underwriter_plugin::impl { // the link + balance coverage checks know what to look for. read_outpost_registry(); - // -- Check 2: outpost-client wiring covers every active chain -- + // -- Check 2: an RPC client is registered for every active chain -- // // The served set is `outpost_chain_kinds` (ACTIVE non-depot chains only, - // per `read_outpost_registry`) as consumed by `select_coverable`, but - // the outpost_client handles are built only from - // operator-supplied `--underwriter-{eth,sol}-outpost` config - // (`outpost_endpoints`). An active chain that is unconfigured, or - // configured under the wrong VM family, would let the scan loop SELECT a - // request for it and land one leg before discovering the other leg has no - // (or a wrong-kind) client (SEC-13/WSA-027). Fail closed here so a + // per `read_outpost_registry`) as consumed by `select_coverable`. An + // active chain with no RPC client would let the scan loop SELECT a + // request for it and land one leg before discovering the other leg has + // nowhere to commit (SEC-13/WSA-027). Fail closed here so a // misconfigured underwriter never starts committing partial swaps. - { - std::map registered_kinds; - for (const auto& [code, kind] : outpost_chain_kinds) - registered_kinds[code] = magic_enum::enum_integer(kind); - std::map configured_kinds; - for (const auto& [code, ep] : outpost_endpoints) - configured_kinds[code] = magic_enum::enum_integer(ep.kind); - - if (auto gap = underwriter_detail::find_endpoint_coverage_gap( - registered_kinds, configured_kinds)) { - const auto code_str = fc::slug_name{gap->chain_code}.to_string(); - if (!gap->registry_kind) { - elog("underwriter preflight: configured outpost chain {} has no active " - "sysio.chains::chains row; run activchain for this chain or remove its " - "--underwriter-*-outpost flag", - code_str); - return false; - } - // Re-derive the typed ChainKind names from the source maps rather - // than reverse-casting the raw ints; the generated `_Name` helper - // is the CLAUDE.md-mandated spelling for proto enums. - const ChainKind reg_kind = outpost_chain_kinds.at(gap->chain_code); - if (!gap->config_kind) { - elog("underwriter preflight: active outpost chain {} (kind={}) has no " - "--underwriter-eth-outpost / --underwriter-sol-outpost entry; configure " - "one endpoint for every active outpost chain", - code_str, std::string{sysio::opp::types::ChainKind_Name(reg_kind)}); - } else { - const ChainKind cfg_kind = outpost_endpoints.at(gap->chain_code).kind; - elog("underwriter preflight: outpost chain {} is registered as kind={} but " - "configured as kind={}; fix --underwriter-*-outpost to match the registry", - code_str, - std::string{sysio::opp::types::ChainKind_Name(reg_kind)}, - std::string{sysio::opp::types::ChainKind_Name(cfg_kind)}); - } + // + // The client is looked up under the chain's OWN code: an + // outpost-ethereum-client / outpost-solana-client entry is registered + // with the chain code as its client id, which makes the binding exact by + // construction rather than inferred, and makes "wrong VM family" simply a + // client that is not there. + for (const auto& [chain_code, ep] : outpost_endpoints) { + const auto code_str = fc::slug_name{chain_code}.to_string(); + const bool have_client = + ep.kind == ChainKind::CHAIN_KIND_EVM ? eth_plug->get_client(ep.client_id) != nullptr + : ep.kind == ChainKind::CHAIN_KIND_SVM ? sol_plug->get_client(ep.client_id) != nullptr + : false; + if (!have_client) { + elog("underwriter preflight: active outpost chain {} (kind={}) has no RPC client " + "registered under that chain code — add an outpost-ethereum-client / " + "outpost-solana-client entry whose client id is {}", + code_str, + std::string{sysio::opp::types::ChainKind_Name(ep.kind)}, + code_str); + return false; + } + } + + // -- Check 2b: the registry supplies every served chain's remote addresses -- + // + // The addresses are no longer per-node config: `sysio.chains` carries them + // so every underwriter commits against the same deployment. A row + // registered before its remote contracts existed leaves them empty, and + // committing against an empty address would mean signing to the zero + // address. Fail closed until governance runs `sysio.chains::setoutpost`. + for (const auto& [chain_code, ep] : outpost_endpoints) { + if (ep.commit_addr.empty() || ep.source_deposit_addr.empty()) { + elog("underwriter preflight: outpost chain {} carries no remote contract addresses " + "on its sysio.chains row — run sysio.chains::setoutpost for this chain", + fc::slug_name{chain_code}.to_string()); return false; } } @@ -970,46 +978,18 @@ struct underwriter_plugin::impl { return; } - // Materialize one outpost_client SPI handle per CONFIGURED chain - // (SEC-13/WSA-027: keyed by EXACT chain_code, so two chains of the same VM - // family each get their own client + RPC). The underwriter never sees raw - // `ethereum_client` / `solana_client` instances after this point — every - // outpost-side action goes through the SPI virtuals. Per `outpost-client-spi.md`: - // * ETH client carries only the OperatorRegistry address (the uw_commit - // target); the underwriter neither consumes nor emits OPP envelopes, so - // OPP / OPPInbound addresses are left empty. - // * SOL client carries the opp-outpost program id; the typed wrapper - // exposes `commit_underwrite` directly. - // `external_chain_id` comes from the matching ACTIVE `sysio.chains` row. - // The preflight above enforces that inverse coverage for both EVM and SVM - // endpoints before either client plugin is asked to build a handle. + // The preflight above already established that every active chain has both + // an RPC client and contract addresses; wire_outpost_clients documents the + // handles it builds. read_outpost_registry(); try { - for (const auto& [chain_code, ep] : outpost_endpoints) { - const auto code_str = fc::slug_name{chain_code}.to_string(); - const uint32_t ext_id = outpost_external_chain_ids.at(chain_code); - if (ep.kind == ChainKind::CHAIN_KIND_EVM) { - outpost_by_chain[chain_code] = - eth_plug->create_outpost_client(ep.client_id, chain_code, ext_id, - /*opp_addr=*/"", /*opp_inbound_addr=*/"", - ep.commit_addr); - ilog("underwriter_plugin: wired ETH outpost_client chain={} (client_id='{}', opreg={})", - code_str, ep.client_id, ep.commit_addr); - } else if (ep.kind == ChainKind::CHAIN_KIND_SVM) { - outpost_by_chain[chain_code] = - sol_plug->create_outpost_client(ep.client_id, chain_code, ext_id, - ep.commit_addr /*program_id*/, - solana_outpost_role::underwriter); - ilog("underwriter_plugin: wired SOL outpost_client chain={} (client_id='{}', program={})", - code_str, ep.client_id, ep.commit_addr); - } else { - wlog("underwriter_plugin: outpost_endpoint chain={} has unknown kind — skipped", - code_str); - } - } + wire_outpost_clients(); if (outpost_by_chain.empty()) { - wlog("underwriter_plugin: NO outpost_clients wired — pass " - "--underwriter-eth-outpost / --underwriter-sol-outpost for each served chain"); + // Preflight passed, so every ACTIVE chain had a client and addresses + // — reaching here means the registry holds no active non-depot chain + // at all, which the preflight reports separately. Nothing to serve yet. + wlog("underwriter_plugin: NO outpost_clients wired — sysio.chains lists no active " + "non-depot chain"); } } catch (const fc::exception& e) { gate_state = underwriter_detail::startup_state::wiring_failed; @@ -1090,8 +1070,18 @@ struct underwriter_plugin::impl { poll_own_status(); if (!is_active) return; - // Step 1: Read outpost registry for chain_kind mappings + // Step 1: Read outpost registry for chain_kind mappings, then re-wire any + // outpost whose remote deployment moved since the handle was built. read_outpost_registry(); + try { + wire_outpost_clients(); + } catch (const fc::exception& e) { + // A rebuild that fails leaves the previous handle in place; the next + // tick retries. Losing the whole scan cycle over one chain's RPC + // hiccup would stall commits on every OTHER chain too. + wlog("underwriter_plugin: outpost re-wire failed, keeping existing clients: {}", + e.to_detail_string()); + } // Step 2: Read our credit lines from sysio.opreg::operators read_credit_lines(); @@ -1198,6 +1188,7 @@ struct underwriter_plugin::impl { void read_outpost_registry() { outpost_chain_kinds.clear(); outpost_external_chain_ids.clear(); + outpost_endpoints.clear(); // v6 refactor: chain rows moved from `sysio.epoch::outposts` to // `sysio.chains::chains`. Each row is a `Chain` with fields: // `code` — slug_name (the universal chain identifier; the @@ -1240,9 +1231,106 @@ struct underwriter_plugin::impl { outpost_chain_kinds[chain_code] = obj["kind"].as(); outpost_external_chain_ids[chain_code] = static_cast(obj["external_chain_id"].as_uint64()); + build_endpoint_from_row(chain_code, obj); } } + /// Build one `outpost_client` SPI handle per CONFIGURED chain (SEC-13/WSA-027: + /// keyed by EXACT chain_code, so two chains of the same VM family each get + /// their own client + RPC). The underwriter never sees raw `ethereum_client` + /// / `solana_client` instances after this point — every outpost-side action + /// goes through the SPI virtuals. Per `outpost-client-spi.md`: + /// * ETH client carries only the OperatorRegistry address (the uw_commit + /// target); the underwriter neither consumes nor emits OPP envelopes, so + /// OPP / OPPInbound addresses are left empty. + /// * SOL client carries the opp-outpost program id; the typed wrapper + /// exposes `commit_underwrite` directly. + /// `external_chain_id` and the contract addresses both come from the chain's + /// ACTIVE `sysio.chains` row, read by {@link read_outpost_registry}. + /// + /// Idempotent, and re-run every scan tick: a handle is rebuilt only when the + /// registry now names a DIFFERENT remote address than the one it was built + /// with. Governance can move a deployment under a running underwriter with + /// `sysio.chains::setoutpost`, and without this the daemon would keep + /// committing to the address the outpost has moved off. + void wire_outpost_clients() { + for (const auto& [chain_code, ep] : outpost_endpoints) { + const auto code_str = fc::slug_name{chain_code}.to_string(); + // Skip chains with no active registry row: the preflight already + // failed the startup path on those, and on a rescan a chain can be + // deactivated without invalidating the clients still in use. + auto ext = outpost_external_chain_ids.find(chain_code); + if (ext == outpost_external_chain_ids.end()) continue; + if (ep.commit_addr.empty()) { + wlog("underwriter_plugin: outpost chain {} has no remote contract address on its " + "sysio.chains row — not wiring until sysio.chains::setoutpost supplies one", + code_str); + continue; + } + if (auto wired = wired_commit_addrs.find(chain_code); + wired != wired_commit_addrs.end() && wired->second == ep.commit_addr + && outpost_by_chain.contains(chain_code)) { + continue; // already wired against this exact deployment + } + const bool rebuilt = outpost_by_chain.contains(chain_code); + if (ep.kind == ChainKind::CHAIN_KIND_EVM) { + outpost_by_chain[chain_code] = + eth_plug->create_outpost_client(ep.client_id, chain_code, ext->second, + /*opp_addr=*/"", /*opp_inbound_addr=*/"", + ep.commit_addr); + ilog("underwriter_plugin: {} ETH outpost_client chain={} (client_id='{}', opreg={})", + rebuilt ? "rewired" : "wired", code_str, ep.client_id, ep.commit_addr); + } else if (ep.kind == ChainKind::CHAIN_KIND_SVM) { + outpost_by_chain[chain_code] = + sol_plug->create_outpost_client(ep.client_id, chain_code, ext->second, + ep.commit_addr /*program_id*/, + solana_outpost_role::underwriter); + ilog("underwriter_plugin: {} SOL outpost_client chain={} (client_id='{}', program={})", + rebuilt ? "rewired" : "wired", code_str, ep.client_id, ep.commit_addr); + } else { + wlog("underwriter_plugin: outpost_endpoint chain={} has unknown kind — skipped", + code_str); + continue; + } + wired_commit_addrs[chain_code] = ep.commit_addr; + } + } + + /// Build this chain's endpoint entirely from its `chains` row. + /// + /// Nothing about an outpost is per-node any more: the registry names WHICH + /// chains are served (every active non-depot row) and WHERE each one lives, + /// and the RPC client is the one registered under the chain's own code. That + /// leaves the operator responsible only for supplying the endpoints + /// themselves, and makes it impossible for one underwriter to commit against + /// a deployment the rest of the network disagrees on. + void build_endpoint_from_row(uint64_t chain_code, const fc::variant_object& obj) { + outpost_endpoint ep; + ep.kind = outpost_chain_kinds.at(chain_code); + // The chain code IS the RPC client id — see outpost-ethereum-client / + // outpost-solana-client. The preflight fails closed when no client is + // registered under it. + ep.client_id = fc::slug_name{chain_code}.to_string(); + + std::string opp_addr, operator_registry_addr, source_deposit_addr; + if (auto out_it = obj.find(depot_chains::field::outpost); + out_it != obj.end() && out_it->value().is_object()) { + const auto& out = out_it->value().get_object(); + const auto read = [&](const char* f) -> std::string { + auto it = out.find(f); + return it == out.end() ? std::string{} : it->value().as_string(); + }; + opp_addr = read(depot_chains::field::outpost_addr::opp_addr); + operator_registry_addr = read(depot_chains::field::outpost_addr::operator_registry_addr); + source_deposit_addr = read(depot_chains::field::outpost_addr::source_deposit_addr); + } + // An SVM outpost is one program serving every role, so the role fields + // are empty on its row and resolve back to opp_addr. + ep.commit_addr = depot_chains::resolve_role_addr(operator_registry_addr, opp_addr); + ep.source_deposit_addr = depot_chains::resolve_role_addr(source_deposit_addr, opp_addr); + outpost_endpoints[chain_code] = std::move(ep); + } + /// True iff `code` is the WIRE depot's own chain code. Exact compare /// against the registry's `is_depot` row — never inferred from /// CHAIN_KIND_UNKNOWN (which also matches unregistered chains). @@ -2804,21 +2892,6 @@ void underwriter_plugin::set_program_options(options_description& cli, "Timeout for outpost contract calls and table reads (ms)"); opts("underwriter-enabled", bpo::value()->default_value(underwriter_defaults::enabled), "Enable underwriter functionality"); - opts("underwriter-eth-outpost", - bpo::value>()->composing(), - "Per-EVM-chain outpost wiring (repeatable, one per EVM chain served). Format: " - "`,,,` — " - "chain_code is the sysio.chains codename (e.g. ETHEREUM); client_id names the RPC " - "connection of a configured Ethereum client; operator_registry_addr is the OPP " - "OperatorRegistry (uw_commit target); source_deposit_contract_addr is the SwapDeposit-" - "emitting contract scanned by the verify path. SEC-13/WSA-027: keyed by exact chain_code, " - "so two EVM chains are wired independently."); - opts("underwriter-sol-outpost", - bpo::value>()->composing(), - "Per-SVM-chain outpost wiring (repeatable, one per SVM chain served). Format: " - "`,,` — client_id names the RPC connection " - "registered via --outpost-solana-client; program_id is the opp-outpost program (used for " - "both commit_underwrite and the source-deposit scan)."); opts("underwriter-eth-source-deposit-function", bpo::value(), "Name of the ETH swap-deposit function. Resolved at preflight against the ABI " "files registered with --ethereum-abi-file; the matching `function` entry's keccak256 " @@ -2843,45 +2916,6 @@ void underwriter_plugin::plugin_initialize(const variables_map& options) { _impl->scan_interval_ms = options["underwriter-scan-interval-ms"].as(); _impl->action_timeout_ms = options["underwriter-action-timeout-ms"].as(); _impl->enabled = options["underwriter-enabled"].as(); - // SEC-13/WSA-027: parse the repeatable per-chain outpost wiring into - // `outpost_endpoints`, keyed by EXACT chain_code. Each entry is a - // comma-separated `,,`. - { - auto split_csv = [](const std::string& s) { - std::vector out; - for (size_t start = 0;;) { - const size_t comma = s.find(',', start); - out.push_back(s.substr(start, comma == std::string::npos ? comma : comma - start)); - if (comma == std::string::npos) break; - start = comma + 1; - } - return out; - }; - auto parse_outpost = [&](const char* opt, ChainKind kind, size_t min_fields) { - if (!options.count(opt)) return; - for (const auto& spec : options[opt].as>()) { - const auto f = split_csv(spec); - if (f.size() < min_fields || f[0].empty() || f[1].empty() || f[2].empty()) { - elog("underwriter: ignoring malformed {} entry '{}' (need " - ">= {} non-empty comma-separated fields)", opt, spec, min_fields); - continue; - } - try { - outpost_endpoint ep; - ep.kind = kind; - ep.client_id = f[1]; - ep.commit_addr = f[2]; - ep.source_deposit_addr = (f.size() > 3 && !f[3].empty()) ? f[3] : f[2]; - _impl->outpost_endpoints[fc::slug_name{f[0]}.value] = ep; - } catch (const fc::exception& e) { - elog("underwriter: ignoring {} entry '{}' — bad chain_code '{}': {}", - opt, spec, f[0], e.to_detail_string()); - } - } - }; - parse_outpost("underwriter-eth-outpost", ChainKind::CHAIN_KIND_EVM, /*min_fields=*/4); - parse_outpost("underwriter-sol-outpost", ChainKind::CHAIN_KIND_SVM, /*min_fields=*/3); - } if (options.count("underwriter-eth-source-deposit-function")) _impl->eth_source_deposit_function_name = options["underwriter-eth-source-deposit-function"].as(); diff --git a/plugins/underwriter_plugin/test/test_underwriter_plugin.cpp b/plugins/underwriter_plugin/test/test_underwriter_plugin.cpp index e8c0a319fa..7e7a426b54 100644 --- a/plugins/underwriter_plugin/test/test_underwriter_plugin.cpp +++ b/plugins/underwriter_plugin/test/test_underwriter_plugin.cpp @@ -40,6 +40,13 @@ namespace { /// the option-surface test catch any future non-finality escape hatch. constexpr std::string_view removed_eth_min_confirmations_option = "underwriter-eth-min-confirmations"; +/// Removed per-chain outpost wiring options. Nothing about an outpost is +/// declared per node any more: `sysio.chains` names which chains are served and +/// where their contracts live, and the RPC client for a chain is the one +/// registered under that chain's own code. +constexpr std::string_view removed_eth_outpost_option = "underwriter-eth-outpost"; +constexpr std::string_view removed_sol_outpost_option = "underwriter-sol-outpost"; + /// Program id used by the scanner tests to model the configured opp-outpost. const std::string test_sol_program_id = "OppOutpost11111111111111111111111111111111"; @@ -347,10 +354,10 @@ BOOST_AUTO_TEST_CASE(plugin_options_are_registered) try { BOOST_CHECK(option_names.count("underwriter-scan-interval-ms") > 0); BOOST_CHECK(option_names.count("underwriter-action-timeout-ms") > 0); BOOST_CHECK(option_names.count("underwriter-enabled") > 0); - BOOST_CHECK(option_names.count("underwriter-eth-outpost") > 0); - BOOST_CHECK(option_names.count("underwriter-sol-outpost") > 0); BOOST_CHECK(option_names.count(std::string{ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION}) > 0); BOOST_CHECK_EQUAL(option_names.count(std::string{removed_eth_min_confirmations_option}), 0); + BOOST_CHECK_EQUAL(option_names.count(std::string{removed_eth_outpost_option}), 0); + BOOST_CHECK_EQUAL(option_names.count(std::string{removed_sol_outpost_option}), 0); } FC_LOG_AND_RETHROW(); BOOST_AUTO_TEST_CASE(default_options_are_correct) try { @@ -366,9 +373,6 @@ BOOST_AUTO_TEST_CASE(default_options_are_correct) try { BOOST_CHECK_EQUAL(vm["underwriter-scan-interval-ms"].as(), scan_interval_ms); BOOST_CHECK_EQUAL(vm["underwriter-action-timeout-ms"].as(), action_timeout_ms); BOOST_CHECK_EQUAL(vm["underwriter-enabled"].as(), enabled); - // SEC-13/WSA-027: the former single --underwriter-{eth,sol}-client-id were - // replaced by repeatable per-chain --underwriter-{eth,sol}-outpost (no scalar - // default to assert; presence is checked in the option-registration case). BOOST_CHECK_EQUAL( vm[std::string{ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION}].as(), ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS); diff --git a/plugins/underwriter_plugin/test/test_underwriter_routing.cpp b/plugins/underwriter_plugin/test/test_underwriter_routing.cpp index d53f99bf68..bb79ff3b75 100644 --- a/plugins/underwriter_plugin/test/test_underwriter_routing.cpp +++ b/plugins/underwriter_plugin/test/test_underwriter_routing.cpp @@ -230,72 +230,4 @@ BOOST_AUTO_TEST_CASE(mixed_stored_local_candidate_reserves_full_bond) { remaining, leg_bond{B_ETH_USDC, 1}, NO_LEG)); } -// -- endpoint coverage: config must serve every registered chain -- - -namespace { -// Stand-in `ChainKind` integers. The helper compares raw ints (the plugin -// passes `magic_enum::enum_integer(ChainKind)` at the boundary); these two -// distinct values model two different VM families. -constexpr int KIND_EVM = 2; -constexpr int KIND_SVM = 3; -} // namespace - -BOOST_AUTO_TEST_CASE(endpoint_coverage_all_registered_chains_configured) { - // Two registered EVM chains, both configured with the matching kind -> no gap. - const std::map registered{{ETH, KIND_EVM}, {EVM2, KIND_EVM}}; - const std::map configured{{ETH, KIND_EVM}, {EVM2, KIND_EVM}}; - BOOST_CHECK(!find_endpoint_coverage_gap(registered, configured).has_value()); -} - -BOOST_AUTO_TEST_CASE(endpoint_coverage_flags_unconfigured_chain) { - // A second EVM chain is registered but the operator forgot its endpoint. The - // pre-fix wiring would start the cron anyway and fail the EVM2 leg mid-swap; - // preflight must instead flag EVM2 as unconfigured and refuse to start. - const std::map registered{{ETH, KIND_EVM}, {EVM2, KIND_EVM}}; - const std::map configured{{ETH, KIND_EVM}}; - const auto gap = find_endpoint_coverage_gap(registered, configured); - BOOST_REQUIRE(gap.has_value()); - BOOST_CHECK_EQUAL(gap->chain_code, EVM2); - BOOST_REQUIRE(gap->registry_kind.has_value()); - BOOST_CHECK_EQUAL(*gap->registry_kind, KIND_EVM); - BOOST_CHECK(!gap->config_kind.has_value()); -} - -BOOST_AUTO_TEST_CASE(endpoint_coverage_flags_wrong_family) { - // A chain registered as EVM but configured under --underwriter-sol-outpost - // (kind SVM). The chain_code lookup would find a client of the wrong type; - // preflight must flag the family mismatch. - const std::map registered{{ETH, KIND_EVM}}; - const std::map configured{{ETH, KIND_SVM}}; - const auto gap = find_endpoint_coverage_gap(registered, configured); - BOOST_REQUIRE(gap.has_value()); - BOOST_CHECK_EQUAL(gap->chain_code, ETH); - BOOST_REQUIRE(gap->registry_kind.has_value()); - BOOST_REQUIRE(gap->config_kind.has_value()); - BOOST_CHECK_EQUAL(*gap->registry_kind, KIND_EVM); - BOOST_CHECK_EQUAL(*gap->config_kind, KIND_SVM); -} - -BOOST_AUTO_TEST_CASE(endpoint_coverage_flags_configured_inactive_chain) { - const std::map registered{{ETH, KIND_EVM}}; - const std::map configured{{ETH, KIND_EVM}, {EVM2, KIND_EVM}}; - const auto gap = find_endpoint_coverage_gap(registered, configured); - BOOST_REQUIRE(gap.has_value()); - BOOST_CHECK_EQUAL(gap->chain_code, EVM2); - BOOST_CHECK(!gap->registry_kind.has_value()); - BOOST_REQUIRE(gap->config_kind.has_value()); - BOOST_CHECK_EQUAL(*gap->config_kind, KIND_EVM); -} - -BOOST_AUTO_TEST_CASE(endpoint_coverage_empty_registry_flags_configured_chain) { - const std::map registered; - const std::map configured{{ETH, KIND_EVM}}; - const auto gap = find_endpoint_coverage_gap(registered, configured); - BOOST_REQUIRE(gap.has_value()); - BOOST_CHECK_EQUAL(gap->chain_code, ETH); - BOOST_CHECK(!gap->registry_kind.has_value()); - BOOST_REQUIRE(gap->config_kind.has_value()); - BOOST_CHECK_EQUAL(*gap->config_kind, KIND_EVM); -} - BOOST_AUTO_TEST_SUITE_END() From f86cb32c5b0b0a297e14365b3e4608e28419af97 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 20 Aug 2026 13:35:34 -0500 Subject: [PATCH 2/2] opp: fail closed on partial EVM sets, retired addresses, and unbuildable clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all cases where a daemon kept going with wrong or stale outpost state instead of failing closed. resolve_role_addr fell back to opp_addr whenever a role field was empty, which is only correct for a single-program SVM outpost. setoutpost accepts a PARTIAL EVM set, so a row carrying the OPP address before its OperatorRegistry is deployed would resolve commit_addr to the OPP contract — a plausible, non-empty, wrong address that passes the is-it-configured preflight and then sends uw_commit to the wrong contract. The helper now takes the deployment shape explicitly: SVM resolves every role to opp_addr, EVM resolves a role to its own field and nothing else, so an unset EVM role stays empty and the preflight fails closed. wire_outpost_clients logged and continued when a row lost its address, keeping the handle in outpost_by_chain and wired_commit_addrs. Because setoutpost replaces the whole set, clearing a role retires that deployment, and a later destination leg would still have committed to it. The cached handle and its recorded address are now erased. build_opp_jobs swallowed client-factory exceptions, so a client registered under a chain code whose eth_chainId disagrees with the row never reached the unserviceable list and the node did not quit — the exact fail-fast policy the function documents. Factory failures now count as unserviceable. --- .../include/sysio/depot/chains_registry.hpp | 32 +++++++++------ libraries/libfc/test/test_chains_registry.cpp | 39 +++++++++++++------ .../src/batch_operator_plugin.cpp | 10 ++++- .../src/underwriter_plugin.cpp | 32 +++++++++++---- 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/libraries/libfc/include/sysio/depot/chains_registry.hpp b/libraries/libfc/include/sysio/depot/chains_registry.hpp index 0fab029b92..10b21dd0eb 100644 --- a/libraries/libfc/include/sysio/depot/chains_registry.hpp +++ b/libraries/libfc/include/sysio/depot/chains_registry.hpp @@ -42,21 +42,29 @@ namespace field { } /** - * @brief Resolve the address of one remote role, applying the single-program rule. + * @brief Resolve the address of one remote role for a chain's deployment shape. * - * An EVM outpost deploys a separate contract per role, so each role field names - * its own address. An SVM outpost is ONE program serving every role, so the - * registry stores it in `opp_addr` and `sysio.chains` REQUIRES the role fields - * to be empty. A reader that wants a specific role therefore falls back to - * `opp_addr` whenever the role field is empty, and both deployment shapes are - * handled without branching on `ChainKind` at every call site. + * An SVM outpost is ONE program serving every role: the registry stores it in + * `opp_addr` and `sysio.chains` REQUIRES the role fields to be empty, so every + * role resolves to `opp_addr`. An EVM outpost deploys a SEPARATE contract per + * role, so a role resolves to its own field and NOTHING else. * - * @param role_addr Role-specific field from the row's `outpost` struct. - * @param opp_addr The row's `opp_addr` field. - * @return The role's address, or empty when the row is not configured yet. + * The EVM case must not fall back to `opp_addr`. `setoutpost` accepts a partial + * EVM set — a row may legitimately carry the OPP address before its + * OperatorRegistry is deployed — and substituting `opp_addr` there would yield a + * plausible, non-empty, WRONG address: it passes any is-it-configured check and + * then sends commits or verification reads to the OPP contract. An unset EVM + * role stays empty so the caller fails closed and waits for `setoutpost`. + * + * @param role_addr Role-specific field from the row's `outpost` struct. + * @param opp_addr The row's `opp_addr` field. + * @param single_program True when one program serves every role (SVM). + * @return The role's address, or empty when this role is not configured yet. */ -inline std::string resolve_role_addr(std::string_view role_addr, std::string_view opp_addr) { - return role_addr.empty() ? std::string{opp_addr} : std::string{role_addr}; +inline std::string resolve_role_addr(std::string_view role_addr, + std::string_view opp_addr, + bool single_program) { + return single_program ? std::string{opp_addr} : std::string{role_addr}; } } // namespace sysio::depot::chains diff --git a/libraries/libfc/test/test_chains_registry.cpp b/libraries/libfc/test/test_chains_registry.cpp index 0b29fa32f5..dd1b59f572 100644 --- a/libraries/libfc/test/test_chains_registry.cpp +++ b/libraries/libfc/test/test_chains_registry.cpp @@ -17,25 +17,42 @@ namespace c = sysio::depot::chains; BOOST_AUTO_TEST_SUITE(chains_registry_tests) +namespace { +constexpr auto EVM_OPP = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; +constexpr auto EVM_OPREG = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0"; +constexpr auto SVM_PROGRAM = "So11111111111111111111111111111111111111112"; +constexpr bool ONE_PROGRAM = true; // SVM +constexpr bool PER_ROLE = false; // EVM +} // namespace + /// EVM: each role names its own contract, so the role field wins. BOOST_AUTO_TEST_CASE(role_specific_address_is_used_when_present) { - constexpr auto opp = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; - constexpr auto opreg = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0"; - BOOST_REQUIRE_EQUAL(std::string{opreg}, c::resolve_role_addr(opreg, opp)); + BOOST_REQUIRE_EQUAL(std::string{EVM_OPREG}, + c::resolve_role_addr(EVM_OPREG, EVM_OPP, PER_ROLE)); } /// SVM: one program serves every role, so `sysio.chains` requires the role -/// fields to be empty and a reader falls back to `opp_addr`. -BOOST_AUTO_TEST_CASE(empty_role_address_falls_back_to_opp_addr) { - constexpr auto program = "So11111111111111111111111111111111111111112"; - BOOST_REQUIRE_EQUAL(std::string{program}, c::resolve_role_addr("", program)); +/// fields to be empty and every role resolves to `opp_addr`. +BOOST_AUTO_TEST_CASE(single_program_outpost_resolves_every_role_to_opp_addr) { + BOOST_REQUIRE_EQUAL(std::string{SVM_PROGRAM}, + c::resolve_role_addr("", SVM_PROGRAM, ONE_PROGRAM)); +} + +/// The regression this signature exists to prevent: `setoutpost` accepts a +/// partial EVM set, so a row can carry the OPP address while its +/// OperatorRegistry is still undeployed. Substituting `opp_addr` there would be +/// a plausible, non-empty, WRONG address — it passes an is-it-configured check +/// and then commits to the OPP contract. The role must stay empty. +BOOST_AUTO_TEST_CASE(per_role_outpost_never_falls_back_to_opp_addr) { + BOOST_REQUIRE_EQUAL(std::string{}, c::resolve_role_addr("", EVM_OPP, PER_ROLE)); } -/// A row registered before its remote contracts were deployed carries neither, -/// and must resolve to empty so the caller can fail closed rather than sign to -/// the zero address. +/// A row registered before any remote contract was deployed carries neither, +/// and must resolve to empty under either shape so the caller fails closed +/// rather than signing to the zero address. BOOST_AUTO_TEST_CASE(unconfigured_row_resolves_to_empty) { - BOOST_REQUIRE_EQUAL(std::string{}, c::resolve_role_addr("", "")); + BOOST_REQUIRE_EQUAL(std::string{}, c::resolve_role_addr("", "", PER_ROLE)); + BOOST_REQUIRE_EQUAL(std::string{}, c::resolve_role_addr("", "", ONE_PROGRAM)); } /// Spelling regression guard — these must match `chain_row` and its nested diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index 5adf88de97..bd331d0577 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -669,8 +669,14 @@ struct batch_operator_plugin::impl { continue; } } catch (const fc::exception& e) { - wlog("batch_operator: failed to build outpost_client for outpost {}: {}", - code_str, e.to_string()); + // The factory throws on a client that cannot serve this row at all — + // most importantly when the client registered under this chain code + // reports a different eth_chainId than the row's external_chain_id. + // That is a misconfiguration only the operator can fix, so it is + // unserviceable on the same terms as a missing client; swallowing it + // here would leave an elected operator silently unable to deliver. + unserviceable.emplace_back(code_str, + std::format("outpost client could not be built: {}", e.top_message())); continue; } diff --git a/plugins/underwriter_plugin/src/underwriter_plugin.cpp b/plugins/underwriter_plugin/src/underwriter_plugin.cpp index 53da36eafc..c32574909f 100644 --- a/plugins/underwriter_plugin/src/underwriter_plugin.cpp +++ b/plugins/underwriter_plugin/src/underwriter_plugin.cpp @@ -1262,9 +1262,22 @@ struct underwriter_plugin::impl { auto ext = outpost_external_chain_ids.find(chain_code); if (ext == outpost_external_chain_ids.end()) continue; if (ep.commit_addr.empty()) { - wlog("underwriter_plugin: outpost chain {} has no remote contract address on its " - "sysio.chains row — not wiring until sysio.chains::setoutpost supplies one", - code_str); + // A row can LOSE an address: `setoutpost` replaces the whole set, so + // clearing a role retires that deployment. Drop any handle built + // against it — keeping one would let a later destination leg commit + // to a deployment the registry no longer names, which is worse than + // not committing at all. + if (outpost_by_chain.erase(chain_code) > 0) { + wlog("underwriter_plugin: outpost chain {} no longer carries a remote contract " + "address — retired the wired client; not committing on this chain until " + "sysio.chains::setoutpost supplies one", + code_str); + } else { + wlog("underwriter_plugin: outpost chain {} has no remote contract address on its " + "sysio.chains row — not wiring until sysio.chains::setoutpost supplies one", + code_str); + } + wired_commit_addrs.erase(chain_code); continue; } if (auto wired = wired_commit_addrs.find(chain_code); @@ -1324,10 +1337,15 @@ struct underwriter_plugin::impl { operator_registry_addr = read(depot_chains::field::outpost_addr::operator_registry_addr); source_deposit_addr = read(depot_chains::field::outpost_addr::source_deposit_addr); } - // An SVM outpost is one program serving every role, so the role fields - // are empty on its row and resolve back to opp_addr. - ep.commit_addr = depot_chains::resolve_role_addr(operator_registry_addr, opp_addr); - ep.source_deposit_addr = depot_chains::resolve_role_addr(source_deposit_addr, opp_addr); + // An SVM outpost is one program serving every role; an EVM outpost names a + // distinct contract per role and must NEVER fall back to opp_addr, or a + // row whose OperatorRegistry is not deployed yet would commit to the OPP + // contract instead of failing closed. + const bool single_program = ep.kind == ChainKind::CHAIN_KIND_SVM; + ep.commit_addr = + depot_chains::resolve_role_addr(operator_registry_addr, opp_addr, single_program); + ep.source_deposit_addr = + depot_chains::resolve_role_addr(source_deposit_addr, opp_addr, single_program); outpost_endpoints[chain_code] = std::move(ep); }