diff --git a/contracts/sysio.msgch/sysio.msgch.abi b/contracts/sysio.msgch/sysio.msgch.abi index 1a423908ef..d273339ee9 100644 --- a/contracts/sysio.msgch/sysio.msgch.abi +++ b/contracts/sysio.msgch/sysio.msgch.abi @@ -681,6 +681,18 @@ { "name": "ATTESTATION_TYPE_EMISSIONS_BLOCKED", "value": 60962 + }, + { + "name": "ATTESTATION_TYPE_SYNDICATE_LIQ", + "value": 60963 + }, + { + "name": "ATTESTATION_TYPE_LIQ_YIELD", + "value": 60964 + }, + { + "name": "ATTESTATION_TYPE_DESYNDICATE_LIQ", + "value": 60965 } ] }, diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index b581f28a4f..e625184a3c 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/sysio.roa/sysio.roa.cpp b/contracts/sysio.roa/sysio.roa.cpp index 3f996c7766..18f33656a4 100644 --- a/contracts/sysio.roa/sysio.roa.cpp +++ b/contracts/sysio.roa/sysio.roa.cpp @@ -13,6 +13,28 @@ namespace sysio { // literals -- a contract rename is one change here, not scattered across call sites). constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; constexpr name AUTHEX_RECORDLINK = "recordlink"_n; + + /// Maximum number of generated account names checked before newuser gives up. + constexpr uint32_t MAX_ACCOUNT_NAME_ATTEMPTS{100}; + + /// SplitMix64's Weyl-sequence increment, used to decorrelate account-name generator inputs. + constexpr uint64_t ACCOUNT_NAME_MIX_INCREMENT{0x9E3779B97F4A7C15ULL}; + /// First SplitMix64 avalanche multiplier. + constexpr uint64_t ACCOUNT_NAME_MIX_MULTIPLIER_1{0xBF58476D1CE4E5B9ULL}; + /// Second SplitMix64 avalanche multiplier. + constexpr uint64_t ACCOUNT_NAME_MIX_MULTIPLIER_2{0x94D049BB133111EBULL}; + /// SplitMix64's three avalanche shifts, in application order. + constexpr uint32_t ACCOUNT_NAME_MIX_SHIFT_1{30}; + constexpr uint32_t ACCOUNT_NAME_MIX_SHIFT_2{27}; + constexpr uint32_t ACCOUNT_NAME_MIX_SHIFT_3{31}; + + /// Applies the SplitMix64 finalizer to one account-name generator input. + uint64_t mix_account_name_seed(uint64_t value) { + value += ACCOUNT_NAME_MIX_INCREMENT; + value = (value ^ (value >> ACCOUNT_NAME_MIX_SHIFT_1)) * ACCOUNT_NAME_MIX_MULTIPLIER_1; + value = (value ^ (value >> ACCOUNT_NAME_MIX_SHIFT_2)) * ACCOUNT_NAME_MIX_MULTIPLIER_2; + return value ^ (value >> ACCOUNT_NAME_MIX_SHIFT_3); + } } // anonymous namespace static bool is_sysio_account(const name& account) { @@ -1032,7 +1054,7 @@ namespace sysio { check(prefix_len + 2 <= NAME_LENGTH, "Creator name is too long to generate a sub-account under it"); size_t gen_len = NAME_LENGTH - prefix_len - 1; // chars after "." - // Try up to 3 times to generate a unique username + // Try a bounded number of times to generate a unique username. name new_username; bool created = false; uint32_t block_num = current_block_number(); @@ -1050,23 +1072,17 @@ namespace sysio { 'p','q','r','s','t','u','v','w','x','y','z'}; constexpr size_t charmap_len = sizeof(charmap) / sizeof(charmap[0]); - // Cheap pseudo-random generator: a splitmix64 finalizer over nonce/attempt/block_num. No - // crypto is needed here — uniqueness is enforced by the is_account retry below; we only - // need variation — so this avoids a sha256 intrinsic call per attempt. - auto mix = [](uint64_t z) { - z += 0x9E3779B97F4A7C15ULL; - z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; - z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; - return z ^ (z >> 31); - }; + // Mix the block number before combining it with the nonce. A linear shifted combination + // lets related name-valued nonces in different blocks produce the same candidate sequence. + // No cryptographic strength is needed: is_account enforces uniqueness below. + const uint64_t seed = nonce.value ^ mix_account_name_seed(static_cast(block_num)); - for (uint8_t attempt = 0; attempt < 3; ++attempt) { - uint64_t x = nonce.value ^ (static_cast(block_num) << 32) - ^ (static_cast(attempt) * 0x9E3779B97F4A7C15ULL); + for (uint32_t attempt = 0; attempt < MAX_ACCOUNT_NAME_ATTEMPTS; ++attempt) { + uint64_t x = seed ^ (static_cast(attempt) * ACCOUNT_NAME_MIX_INCREMENT); // Fill the generated portion after "." for (size_t i = 0; i < gen_len; ++i) { - x = mix(x); + x = mix_account_name_seed(x); uname_str[prefix_len + 1 + i] = charmap[x % charmap_len]; } @@ -1078,7 +1094,10 @@ namespace sysio { break; } } - check(created, "Failed to generate a unique account name after 3 attempts"); + check(created, [] { + return "Failed to generate a unique account name after " + + std::to_string(MAX_ACCOUNT_NAME_ATTEMPTS) + " attempts"; + }); auto owner_auth = sysiosystem::authority{1, {{pubkey, 1}}, {}}; auto active_auth = sysiosystem::authority{1, {{pubkey, 1}}, {}}; diff --git a/contracts/sysio.roa/sysio.roa.hpp b/contracts/sysio.roa/sysio.roa.hpp index 71cf972ceb..93b0f14118 100644 --- a/contracts/sysio.roa/sysio.roa.hpp +++ b/contracts/sysio.roa/sysio.roa.hpp @@ -160,7 +160,9 @@ namespace sysio { * - The action will create a new account utilizing a new randomly generated username with the provided `pubkey` as its authority. * - The registration count for the `creator` will be incremented. * - The sponsor mapping (creator, nonce -> username) will be recorded in the sponsors table. - * - The action will fail if the `creator` is not a tier-1 node owner or if the account already exists. + * - The action fails if ROA is inactive, the `creator` is not a tier-1 node owner, the + * creator name cannot fit a generated suffix, the nonce was already used by that creator, + * or no unused generated name is found within the bounded candidate search. * * ### Rights Granted * - The new user account is granted access to the network with the specified public key. diff --git a/contracts/sysio.roa/sysio.roa.wasm b/contracts/sysio.roa/sysio.roa.wasm index 8a56d5de30..9dd9979dd0 100755 Binary files a/contracts/sysio.roa/sysio.roa.wasm and b/contracts/sysio.roa/sysio.roa.wasm differ diff --git a/contracts/sysio.uwrit/sysio.uwrit.abi b/contracts/sysio.uwrit/sysio.uwrit.abi index c9d953a937..709769d98f 100644 --- a/contracts/sysio.uwrit/sysio.uwrit.abi +++ b/contracts/sysio.uwrit/sysio.uwrit.abi @@ -849,6 +849,18 @@ { "name": "ATTESTATION_TYPE_EMISSIONS_BLOCKED", "value": 60962 + }, + { + "name": "ATTESTATION_TYPE_SYNDICATE_LIQ", + "value": 60963 + }, + { + "name": "ATTESTATION_TYPE_LIQ_YIELD", + "value": 60964 + }, + { + "name": "ATTESTATION_TYPE_DESYNDICATE_LIQ", + "value": 60965 } ] }, diff --git a/contracts/test_contracts/sendinline/sendinline.wasm b/contracts/test_contracts/sendinline/sendinline.wasm index 3073cc831c..f1f6de4289 100755 Binary files a/contracts/test_contracts/sendinline/sendinline.wasm and b/contracts/test_contracts/sendinline/sendinline.wasm differ diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp index 7eb4c8dc20..fa127aadc5 100644 --- a/contracts/tests/sysio.roa_tests.cpp +++ b/contracts/tests/sysio.roa_tests.cpp @@ -13,7 +13,11 @@ #include #include #include +#include +#include +#include #include +#include #include using namespace sysio::testing; @@ -31,6 +35,21 @@ using sysio::chain::compute_table_id; constexpr account_name ROA = "sysio.roa"_n; constexpr uint64_t NETWORK_GEN = 0; +/// Generated sub-account names newuser checks before giving up. +constexpr size_t NEWUSER_MAX_NAME_ATTEMPTS = 100; +/// newuser's abort when the nonce was already used by the same creator. +constexpr auto NEWUSER_DUPLICATE_NONCE = "Sponsor entry for this nonce already exists"; +/// Every character newuser can place in a generated sub-account name: the name alphabet without '.'. +constexpr std::string_view GENERATED_NAME_CHARS = "12345abcdefghijklmnopqrstuvwxyz"; +/// Longest account name supported by the contract. +constexpr size_t MAX_ACCOUNT_NAME_LENGTH = 12; + +/// Returns newuser's error after its bounded generated-name search is exhausted. +static std::string newuser_names_exhausted_message() { + return "Failed to generate a unique account name after " + + std::to_string(NEWUSER_MAX_NAME_ATTEMPTS) + " attempts"; +} + class sysio_roa_tester : public tester { public: @@ -257,6 +276,53 @@ class sysio_roa_tester : public tester { return new_name; } + /// Runs newuser with every account in `occupied` created first, committing nothing. + /// A second call with the same nonce aborts the transaction after the first call returns its + /// generated name, so every probe observes identical state in the same pending block. + transaction_trace_ptr push_newuser_probe( account_name creator, name nonce, + const vector& occupied ) + { + signed_transaction trx; + for (const auto& account : occupied) { + trx.actions.emplace_back( vector{{config::system_account_name, + config::active_name}}, + newaccount{ + .creator = config::system_account_name, + .name = account, + .owner = authority( get_public_key( account, "owner" ) ), + .active = authority( get_public_key( account, "active" ) ), + }); + } + const auto call = get_action( ROA, "newuser"_n, + vector{{creator, config::sysio_payer_name}, + {creator, config::active_name}}, + mvo()("creator", creator)("nonce", nonce) + ("pubkey", get_public_key(creator, "active")) ); + trx.actions.push_back( call ); + trx.actions.push_back( call ); + set_transaction_headers( trx ); + if (!occupied.empty()) + trx.sign( get_private_key( config::system_account_name, "active" ), control->get_chain_id() ); + trx.sign( get_private_key( creator, "active" ), control->get_chain_id() ); + return push_transaction( trx, fc::time_point::maximum(), DEFAULT_BILLED_CPU_TIME_US, + true /* no_throw */ ); + } + + /// Returns the first newuser call's generated name from a rolled-back probe transaction. + static account_name newuser_probe_name( const transaction_trace& trace ) + { + BOOST_REQUIRE( trace.except_ptr ); + BOOST_REQUIRE_EXCEPTION( std::rethrow_exception( trace.except_ptr ), + sysio_assert_message_exception, + sysio_assert_message_is( NEWUSER_DUPLICATE_NONCE ) ); + const auto it = std::find_if( trace.action_traces.begin(), trace.action_traces.end(), + []( const auto& at ) { + return at.receiver == ROA && at.act.name == "newuser"_n; + }); + BOOST_REQUIRE( it != trace.action_traces.end() ); + return fc::raw::unpack( it->return_value ); + } + action_result regnodeowner( account_name owner, uint8_t tier ) { return push_action(ROA, "forcereg"_n, mvo() @@ -350,6 +416,119 @@ BOOST_FIXTURE_TEST_CASE( newuser_twice_test, sysio_roa_tester ) try { } FC_LOG_AND_RETHROW() +/// Verifies newuser continues past the former three-attempt limit when generated names collide. +/// Each isolated tester starts from the same chain state and block number. Names returned by the +/// earlier testers are pre-created in the next tester, deterministically forcing one additional +/// collision without duplicating the contract's name-generation algorithm in test code. +BOOST_AUTO_TEST_CASE( newuser_retries_after_three_name_collisions ) try { + std::vector occupied_names; + std::optional newuser_block_num; + + for (size_t collision_count = 0; collision_count <= 3; ++collision_count) { + auto chain = std::make_unique(); + BOOST_REQUIRE_EQUAL(chain->success(), chain->regnodeowner("alice"_n, 1)); + chain->produce_blocks(1); + + for (const auto occupied_name : occupied_names) + chain->create_account(occupied_name, config::system_account_name, false, false, false, false); + + auto result = chain->newuser("alice"_n, "retrynonce"_n, + chain->get_public_key("alice"_n, "active")); + BOOST_REQUIRE(result && !result->action_traces.empty()); + + // A different block draws an unrelated sequence, so pin the generator input that makes the + // accounts collected from earlier fixtures collide in later fixtures. + if (!newuser_block_num) + newuser_block_num = result->block_num; + BOOST_REQUIRE_EQUAL(*newuser_block_num, result->block_num); + + const auto generated_name = fc::raw::unpack(result->action_traces[0].return_value); + BOOST_REQUIRE(std::find(occupied_names.begin(), occupied_names.end(), generated_name) + == occupied_names.end()); + occupied_names.push_back(generated_name); + } + + BOOST_REQUIRE_EQUAL(occupied_names.size(), 4u); +} FC_LOG_AND_RETHROW() + +/// Verifies mixing the block number prevents related name nonces in different blocks from aliasing. +BOOST_AUTO_TEST_CASE( newuser_mixes_block_number_before_combining_nonce ) try { + // In Antelope name encoding, user1 ^ user2 equals this block delta shifted left 32 bits. + // The former linear seed therefore gave both calls the same candidate sequence. + constexpr uint32_t aliasing_block_distance = 384; + auto first = std::make_unique(); + auto second = std::make_unique(); + BOOST_REQUIRE_EQUAL(first->success(), first->regnodeowner("alice"_n, 1)); + BOOST_REQUIRE_EQUAL(second->success(), second->regnodeowner("alice"_n, 1)); + first->produce_blocks(1); + second->produce_blocks(1 + aliasing_block_distance); + + const auto first_result = first->newuser("alice"_n, "user1"_n, + first->get_public_key("alice"_n, "active")); + const auto second_result = second->newuser("alice"_n, "user2"_n, + second->get_public_key("alice"_n, "active")); + BOOST_REQUIRE_EQUAL(first_result->block_num + aliasing_block_distance, second_result->block_num); + + const auto first_name = fc::raw::unpack(first_result->action_traces[0].return_value); + const auto second_name = fc::raw::unpack(second_result->action_traces[0].return_value); + BOOST_REQUIRE_NE(first_name, second_name); +} FC_LOG_AND_RETHROW() + +/// Pins newuser's generated-name search at exactly NEWUSER_MAX_NAME_ATTEMPTS attempts. +/// Probes roll back in one pending block. Occupying each returned candidate moves the next probe +/// one step farther through the deterministic sequence without duplicating the generator in test code. +BOOST_FIXTURE_TEST_CASE( newuser_tries_exactly_max_name_attempts, sysio_roa_tester ) try { + constexpr account_name creator = "alice"_n; + constexpr name nonce = "boundnonce"_n; + BOOST_REQUIRE_EQUAL(success(), regnodeowner(creator, 1)); + produce_blocks(1); + + std::vector occupied; + std::optional probe_block_num; + for (size_t attempt = 0; attempt < NEWUSER_MAX_NAME_ATTEMPTS; ++attempt) { + BOOST_TEST_CONTEXT("probe " << attempt) { + const auto trace = push_newuser_probe(creator, nonce, occupied); + if (!probe_block_num) + probe_block_num = trace->block_num; + BOOST_REQUIRE_EQUAL(*probe_block_num, trace->block_num); + + const auto generated = newuser_probe_name(*trace); + BOOST_REQUIRE(std::find(occupied.begin(), occupied.end(), generated) == occupied.end()); + occupied.push_back(generated); + } + } + + const auto trace = push_newuser_probe(creator, nonce, occupied); + BOOST_REQUIRE_EQUAL(*probe_block_num, trace->block_num); + BOOST_REQUIRE(trace->except_ptr); + BOOST_REQUIRE_EXCEPTION(std::rethrow_exception(trace->except_ptr), + sysio_assert_message_exception, + sysio_assert_message_is(newuser_names_exhausted_message())); + BOOST_REQUIRE(get_sponsorship(creator, nonce).is_null()); + BOOST_REQUIRE_EQUAL(0, get_sponsor_count(creator)); +} FC_LOG_AND_RETHROW() + +/// Verifies newuser records no sponsorship when the creator's entire generated namespace is occupied. +BOOST_FIXTURE_TEST_CASE( newuser_fails_when_generated_names_exhausted, sysio_roa_tester ) try { + constexpr account_name creator = "nodeowner1"_n; + constexpr name nonce = "fullspace"_n; + const std::string prefix = creator.to_string() + '.'; + BOOST_REQUIRE_EQUAL(prefix.size() + 1, MAX_ACCOUNT_NAME_LENGTH); + + create_account(creator, config::system_account_name, false, false, false, false); + BOOST_REQUIRE_EQUAL(success(), regnodeowner(creator, 1)); + + for (const char c : GENERATED_NAME_CHARS) + create_account(name(prefix + c), config::system_account_name, false, false, false, false); + produce_blocks(1); + + BOOST_REQUIRE_EXCEPTION(newuser(creator, nonce, get_public_key(creator, "active")), + sysio_assert_message_exception, + sysio_assert_message_is(newuser_names_exhausted_message())); + BOOST_REQUIRE(get_sponsorship(creator, nonce).is_null()); + BOOST_REQUIRE_EQUAL(0, get_sponsor_count(creator)); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( newuser_creator_permission_test, sysio_roa_tester ) try { auto result = regnodeowner("alice"_n, 2); BOOST_REQUIRE_EQUAL(success(), result);