diff --git a/contracts/sysio.reserv/src/sysio.reserv.cpp b/contracts/sysio.reserv/src/sysio.reserv.cpp index debdd57a0a..1351bbd141 100644 --- a/contracts/sysio.reserv/src/sysio.reserv.cpp +++ b/contracts/sysio.reserv/src/sysio.reserv.cpp @@ -13,6 +13,7 @@ #include +#include #include #include #include @@ -25,6 +26,11 @@ namespace { // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. constexpr name ram_payer = "sysio"_n; +/// Canonical raw address widths for the creator-address domains accepted by +/// reserve creation. +constexpr std::size_t evm_creator_address_bytes = 20; +constexpr std::size_t svm_creator_address_bytes = 32; + uint64_t current_time_ms() { return static_cast(current_time_point().sec_since_epoch()) * 1000; } @@ -105,13 +111,38 @@ std::optional chain_kind_for_code(sysio::slug_name chain_ return tbl.get(pk).kind; } -/// Soft-gate never-throw msgch handlers before they can emit a queueout to an -/// unregistered destination chain. `sysio.msgch::queueout` fails loudly for -/// direct callers, but dispatch callbacks must log-and-skip instead of -/// aborting the consensus-tipping delivery transaction. -bool registered_chain_or_skip(sysio::slug_name chain_code, const char* handler) { - if (chain_kind_for_code(chain_code).has_value()) return true; +/// Resolve the authoritative kind while soft-gating never-throw msgch handlers +/// before they can emit a queueout to an unregistered destination chain. +/// `sysio.msgch::queueout` fails loudly for direct callers, but dispatch +/// callbacks must log-and-skip instead of aborting the consensus-tipping +/// delivery transaction. +std::optional +registered_chain_kind_or_skip(sysio::slug_name chain_code, const char* handler) { + auto kind = chain_kind_for_code(chain_code); + if (kind.has_value()) return kind; sysio::print(handler, ": chain_code is not registered; skipping\n"); + return std::nullopt; +} + +/// Validate the raw creator address against the authoritative registry kind. +/// User-created reserves originate on supported external outposts only: EVM +/// addresses are 20 bytes and SVM addresses are 32 bytes. Unknown/depot kinds +/// are not valid creator-address domains for this handler. +bool creator_address_matches_kind(opp::types::ChainKind kind, + const std::vector& address) { + using opp::types::ChainKind; + switch (kind) { + case ChainKind::CHAIN_KIND_UNKNOWN: + case ChainKind::CHAIN_KIND_WIRE: + return false; + case ChainKind::CHAIN_KIND_EVM: + return address.size() == evm_creator_address_bytes; + case ChainKind::CHAIN_KIND_SVM: + return address.size() == svm_creator_address_bytes; + } + + // Fail closed for an invalid numeric enum value while leaving the switch + // exhaustive, so adding a declared ChainKind produces a compiler warning. return false; } @@ -410,7 +441,9 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, bool is_private, std::vector creator_pub_key) { require_auth(MSGCH_ACCOUNT); - if (!registered_chain_or_skip(chain_code, "oncrtreserve")) return; + const auto expected_chain_kind = + registered_chain_kind_or_skip(chain_code, "oncrtreserve"); + if (!expected_chain_kind.has_value()) return; // Soft-validate; silent skip per feedback_opp_handlers_never_throw. if (connector_weight_bps == 0 || connector_weight_bps > MAX_CONNECTOR_WEIGHT_BPS) { @@ -424,6 +457,13 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, // the SAME cancel/refund path as an unlinked creator below (insert a CANCELLED // row + queue RESERVE_CREATE_CANCELLED), idempotently. const bool invalid_amount = (external_token_amount == 0 || requested_wire_amount == 0); + // `chain_code` is registry-owned and authoritative. The attestation also + // carries a creator kind, but that redundant, externally supplied value + // must not select a different public-key variant or produce a reserve that + // `matchreserve` can never match against the registered chain kind. + const bool creator_chain_kind_mismatch = creator_chain_kind != *expected_chain_kind; + const bool invalid_creator_address = + !creator_address_matches_kind(*expected_chain_kind, creator_chain_addr); // Same `sysio`-billed metadata bound the privileged registrations enforce with // `check_metadata`, asked the non-throwing way: this handler must never abort, so an // over-bound string joins the reject/refund path below rather than reverting dispatch. @@ -454,14 +494,14 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, } opp::types::ChainAddress creator; - creator.kind = creator_chain_kind; + creator.kind = *expected_chain_kind; creator.address = std::move(creator_chain_addr); // Create gating: the creator must already be authex-linked to a WIRE // account ("the only requirement to create a reserve"). Reconstruct the // creator's key variant and probe `sysio.authex::links.bypubkey`. On - // any failure — malformed key bytes, no link, an invalid amount, OR - // over-bound metadata — reject by inserting a + // any failure — a chain-kind mismatch, malformed address/key bytes, no + // link, an invalid amount, OR over-bound metadata — reject by inserting a // CANCELLED row (for refund idempotency) and queueing // RESERVE_CREATE_CANCELLED so the outpost refunds the creator's escrow. // The CANCELLED row does NOT permanently burn the identity: a later, @@ -469,7 +509,7 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, // (prevents namespace squatting). Never throws. std::vector canonical_creator_key; { - auto pk_variant = pubkey_from_raw(creator_chain_kind, creator_pub_key, creator.address); + auto pk_variant = pubkey_from_raw(*expected_chain_kind, creator_pub_key, creator.address); bool linked = false; if (pk_variant) { sysio::authex::links_t links(AUTHEX_ACCOUNT); @@ -479,7 +519,8 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, canonical_creator_key = sysio::pubkey_to_bytes(*pk_variant); } } - if (!linked || invalid_amount || oversized_metadata) { + if (creator_chain_kind_mismatch || invalid_creator_address || !linked || + invalid_amount || oversized_metadata) { // A CANCELLED row already standing means this is a re-relay of the same // rejected create (an unlinked squatter OR an invalid amount). Leave it // and do NOT queue a second refund — the refund was queued when the row @@ -491,7 +532,12 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, return; } sysio::print("oncrtreserve: rejecting with RESERVE_CREATE_CANCELLED " - "(invalid amount, over-bound metadata, or unlinked / malformed creator key)\n"); + "(creator chain-kind/address mismatch, invalid amount, over-bound " + "metadata, or unlinked / malformed creator key)\n"); + // Do not persist attacker-sized address bytes in the sysio-billed + // cancellation tombstone. The outpost refunds by the reserve triple, + // so a malformed creator address is unnecessary for that handshake. + if (invalid_creator_address) creator.address.clear(); const auto now = current_time_ms(); tbl.emplace(ram_payer, pk, reserve_row{ .chain_code = chain_code, @@ -640,7 +686,9 @@ void reserve::oncnclrsv(sysio::slug_name chain_code, opp::types::ChainKind creator_chain_kind, std::vector creator_chain_addr) { require_auth(MSGCH_ACCOUNT); - if (!registered_chain_or_skip(chain_code, "oncnclrsv")) return; + const auto expected_chain_kind = + registered_chain_kind_or_skip(chain_code, "oncnclrsv"); + if (!expected_chain_kind.has_value()) return; reserves_t tbl(get_self()); auto pk = make_key(chain_code, token_code, reserve_code); @@ -656,7 +704,8 @@ void reserve::oncnclrsv(sysio::slug_name chain_code, } const bool addr_matches = - it->creator_addr.kind == creator_chain_kind && + creator_chain_kind == *expected_chain_kind && + it->creator_addr.kind == *expected_chain_kind && it->creator_addr.address == creator_chain_addr; if (!addr_matches) { sysio::print("oncnclrsv: creator_addr mismatch; silently skipping\n"); diff --git a/contracts/sysio.reserv/sysio.reserv.wasm b/contracts/sysio.reserv/sysio.reserv.wasm index e638cb5afe..300949cfbd 100755 Binary files a/contracts/sysio.reserv/sysio.reserv.wasm and b/contracts/sysio.reserv/sysio.reserv.wasm differ diff --git a/contracts/tests/sysio.reserv_tests.cpp b/contracts/tests/sysio.reserv_tests.cpp index 85e270a04b..bc81d3ded8 100644 --- a/contracts/tests/sysio.reserv_tests.cpp +++ b/contracts/tests/sysio.reserv_tests.cpp @@ -336,8 +336,8 @@ class sysio_reserve_tester : public tester { /// Seed an authex link for `pub` on `chain_kind` via the depot-only /// `recordlink` (signed by sysio.authex itself). After this, a creator /// presenting the matching raw pubkey reads as linked in oncrtreserve. - action_result recordlink_em(name account, ChainKind chain_kind, - const fc::crypto::public_key& pub) { + action_result recordlink(name account, ChainKind chain_kind, + const fc::crypto::public_key& pub) { return push_to(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, mvo() ("account", account) ("chain_kind", chain_kind) @@ -547,6 +547,139 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_unlinked_creator_is_cancelled, sysio_reserv BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); } FC_LOG_AND_RETHROW() } +// WNS-28: `chain_code` determines the authoritative ChainKind. Keep the EVM +// address, key, and authex link otherwise valid so only the redundant payload +// kind is malformed. Without the explicit registry-kind comparison this would +// create a PENDING row under ETH despite declaring its creator as SVM. +BOOST_FIXTURE_TEST_CASE(oncrtreserve_creator_chain_kind_mismatch_is_cancelled, + sysio_reserve_tester) { try { + deploy_authex(); + deploy_msgch(); + + auto creator_pub = fc::crypto::private_key::generate( + fc::crypto::private_key::key_type::em).get_public_key(); + BOOST_REQUIRE_EQUAL(success(), + recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); + const auto creator_key = em_pubkey_bytes(creator_pub); + const std::vector creator_address(20, '\x01'); + + BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() + ("chain_code", codename_mvo("ETH")) + ("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("USERRES")) + ("name", "mismatched creator") + ("description", "") + ("external_token_amount", 1000) + ("requested_wire_amount", 1000) + ("source_token_precision", 9u) + ("connector_weight_bps", 5000) + ("creator_chain_kind", ChainKind::CHAIN_KIND_SVM) + ("creator_chain_addr", creator_address) + ("is_private", false) + ("creator_pub_key", creator_key))); + + auto r = find_reserve("ETH", "ETH", "USERRES"); + BOOST_REQUIRE(!r.is_null()); + BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); + BOOST_REQUIRE(r["creator_pub_key"].as_string().empty()); + + // The mismatch must take the refund path, not merely write the tombstone. + // `queueout` starts attestation ids at 1 (id 0 is its sequence singleton). + auto queued = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, 1); + BOOST_REQUIRE(!queued.empty()); +} FC_LOG_AND_RETHROW() } + +// `oncnclrsv` must authenticate the redundant payload kind against the chain +// registry before cancelling. A mismatched callback silently preserves the +// PENDING row and queues no refund; the matching callback then wins the race, +// marks the row CANCELLED, and queues exactly one refund attestation. +BOOST_FIXTURE_TEST_CASE(oncnclrsv_requires_registry_creator_kind, + sysio_reserve_tester) { try { + deploy_authex(); + deploy_msgch(); + + auto creator_pub = fc::crypto::private_key::generate( + fc::crypto::private_key::key_type::em).get_public_key(); + BOOST_REQUIRE_EQUAL(success(), + recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); + const auto creator_key = em_pubkey_bytes(creator_pub); + const std::vector creator_address(20, '\x01'); + + BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() + ("chain_code", codename_mvo("ETH")) + ("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("CANCEL")) + ("name", "cancel kind guard") + ("description", "") + ("external_token_amount", 1000) + ("requested_wire_amount", 1000) + ("source_token_precision", 9u) + ("connector_weight_bps", 5000) + ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) + ("creator_chain_addr", creator_address) + ("is_private", false) + ("creator_pub_key", creator_key))); + + auto pending = find_reserve("ETH", "ETH", "CANCEL"); + BOOST_REQUIRE(!pending.is_null()); + BOOST_REQUIRE_EQUAL("RESERVE_STATUS_PENDING", pending["status"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncnclrsv"_n, mvo() + ("chain_code", codename_mvo("ETH")) + ("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("CANCEL")) + ("creator_chain_kind", ChainKind::CHAIN_KIND_SVM) + ("creator_chain_addr", creator_address))); + + auto after_mismatch = find_reserve("ETH", "ETH", "CANCEL"); + BOOST_REQUIRE_EQUAL("RESERVE_STATUS_PENDING", after_mismatch["status"].as_string()); + BOOST_REQUIRE(get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, + "attestations"_n, 1).empty()); + + BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncnclrsv"_n, mvo() + ("chain_code", codename_mvo("ETH")) + ("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("CANCEL")) + ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) + ("creator_chain_addr", creator_address))); + + auto cancelled = find_reserve("ETH", "ETH", "CANCEL"); + BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", cancelled["status"].as_string()); + BOOST_REQUIRE(!get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, + "attestations"_n, 1).empty()); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(oncrtreserve_invalid_creator_address_is_cancelled, + sysio_reserve_tester) { try { + deploy_authex(); + + auto creator_pub = fc::crypto::private_key::generate( + fc::crypto::private_key::key_type::em).get_public_key(); + BOOST_REQUIRE_EQUAL(success(), + recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); + const auto creator_key = em_pubkey_bytes(creator_pub); + + BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() + ("chain_code", codename_mvo("ETH")) + ("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("BADADDR")) + ("name", "malformed creator address") + ("description", "") + ("external_token_amount", 1000) + ("requested_wire_amount", 1000) + ("source_token_precision", 9u) + ("connector_weight_bps", 5000) + ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) + ("creator_chain_addr", std::vector(32, '\x01')) + ("is_private", false) + ("creator_pub_key", creator_key))); + + auto r = find_reserve("ETH", "ETH", "BADADDR"); + BOOST_REQUIRE(!r.is_null()); + BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); + BOOST_REQUIRE(r["creator_addr"]["address"].as_string().empty()); +} FC_LOG_AND_RETHROW() } + // A re-relay of the same unlinked create must be idempotent — it must NOT // re-insert the row or queue a second RESERVE_CREATE_CANCELLED refund. The // CANCELLED marker stays exactly as first written (the outpost refunds per @@ -620,7 +753,7 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_cancelled_is_reclaimable_by_linked_creator, auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); auto creator_pub = creator_priv.get_public_key(); BOOST_REQUIRE_EQUAL(success(), - recordlink_em("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); + recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() ("chain_code", codename_mvo("ETH")) @@ -663,7 +796,7 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_invalid_amount_is_cancelled, sysio_reserve_ auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); auto creator_pub = creator_priv.get_public_key(); BOOST_REQUIRE_EQUAL(success(), - recordlink_em("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); + recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); // Linked creator, but external_token_amount == 0 (the clamp result for an // invalid inbound amount). The link is valid, so the amount alone forces the @@ -710,7 +843,7 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_oversized_metadata_is_cancelled, sysio_rese auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); auto creator_pub = creator_priv.get_public_key(); BOOST_REQUIRE_EQUAL(success(), - recordlink_em("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); + recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); auto create_with_metadata = [&](std::string_view reserve_code, const std::string& name,