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 13a86ef60a..d3409e3fd5 100755 Binary files a/contracts/sysio.chains/sysio.chains.wasm and b/contracts/sysio.chains/sysio.chains.wasm differ diff --git a/contracts/tests/contract_test_support.hpp b/contracts/tests/contract_test_support.hpp index a30cb9a7ec..05637c0cda 100644 --- a/contracts/tests/contract_test_support.hpp +++ b/contracts/tests/contract_test_support.hpp @@ -76,4 +76,45 @@ typename Tester::action_result push_contract_action_and_produce_block( } } +// --------------------------------------------------------------------------- +// sysio.chains::outpost_addrs builders +// +// `regchain` and `setoutpost` both take the remote outpost contract identities +// as one nested struct. These build its variant form so a test states only the +// addresses it cares about; the contract validates the set against the row's +// ChainKind (see validate_outpost_addrs in sysio.chains.cpp). +// --------------------------------------------------------------------------- + +/// Every field empty — a chain registered before its remote contracts exist. +/// Valid for any kind; both operator daemons fail closed and skip such a row. +inline fc::mutable_variant_object no_outpost_mvo() { + return fc::mutable_variant_object() + ("opp_addr", std::string{}) + ("opp_inbound_addr", std::string{}) + ("operator_registry_addr", std::string{}) + ("source_deposit_addr", std::string{}); +} + +/// EVM form: each role is a distinct 0x-prefixed 20-byte hex contract address. +inline fc::mutable_variant_object evm_outpost_mvo(std::string_view opp, + std::string_view opp_inbound, + std::string_view operator_registry, + std::string_view source_deposit) { + return fc::mutable_variant_object() + ("opp_addr", std::string{opp}) + ("opp_inbound_addr", std::string{opp_inbound}) + ("operator_registry_addr", std::string{operator_registry}) + ("source_deposit_addr", std::string{source_deposit}); +} + +/// SVM form: one base58 program id serves every role, so the other three fields +/// must stay empty — the contract rejects a set that fills them in. +inline fc::mutable_variant_object svm_outpost_mvo(std::string_view program_id) { + return fc::mutable_variant_object() + ("opp_addr", std::string{program_id}) + ("opp_inbound_addr", std::string{}) + ("operator_registry_addr", std::string{}) + ("source_deposit_addr", std::string{}); +} + } // namespace sysio_system::test_support diff --git a/contracts/tests/sysio.chains_tests.cpp b/contracts/tests/sysio.chains_tests.cpp new file mode 100644 index 0000000000..d6a89d631b --- /dev/null +++ b/contracts/tests/sysio.chains_tests.cpp @@ -0,0 +1,248 @@ +/// Address-validation coverage for `sysio.chains`. The registration SEMANTICS +/// (code/kind reservation, EVM external_chain_id uniqueness, SVM cardinality, +/// metadata bounds) are covered by the `regchain_*` cases in +/// `sysio.epoch_tests.cpp`; this suite covers only the remote outpost contract +/// identities that `regchain` and `setoutpost` carry. + +#include +#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..10b21dd0eb --- /dev/null +++ b/libraries/libfc/include/sysio/depot/chains_registry.hpp @@ -0,0 +1,70 @@ +#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 for a chain's deployment shape. + * + * 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. + * + * 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, + bool single_program) { + return single_program ? 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..dd1b59f572 --- /dev/null +++ b/libraries/libfc/test/test_chains_registry.cpp @@ -0,0 +1,82 @@ +/// 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) + +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) { + 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 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 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("", "", PER_ROLE)); + BOOST_REQUIRE_EQUAL(std::string{}, c::resolve_role_addr("", "", ONE_PROGRAM)); +} + +/// 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..bd331d0577 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,111 @@ 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()); + // 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; } 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 +720,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 +787,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 +1004,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 +1012,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..c32574909f 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,124 @@ 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()) { + // 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); + 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; 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); + } + /// 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 +2910,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 +2934,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()