diff --git a/contracts/sysio.system/include/sysio.system/snapshot_attest.hpp b/contracts/sysio.system/include/sysio.system/snapshot_attest.hpp index 8724179b5f..5d5a2dace5 100644 --- a/contracts/sysio.system/include/sysio.system/snapshot_attest.hpp +++ b/contracts/sysio.system/include/sysio.system/snapshot_attest.hpp @@ -58,7 +58,7 @@ struct snap_provider_key_t { SYSLIB_SERIALIZE(snap_provider_key_t, (snap_account)) }; -/** One producer-to-snapshot-account delegation. */ +/** One producer-to-snapshot-account delegation, billed to sysio; the table holds at most max_snap_providers. */ struct [[sysio::table("snapprovs"), sysio::contract("sysio.system")]] snap_provider { /// Account authorized to submit snapshot votes. name snap_account; @@ -88,7 +88,10 @@ struct snap_vote_key_t { SYSLIB_SERIALIZE(snap_vote_key_t, (id)) }; -/** One pending snapshot tuple and its monotonic producer voters. */ +/** + * One pending snapshot tuple and its monotonic producer voters, billed to sysio. Rows exist only for the + * last snapshot_attestation::max_pending_vote_heights scheduled heights, at most max_snap_providers each. + */ struct [[sysio::table("snapvotes"), sysio::contract("sysio.system")]] snap_vote { /// Pending tuple identifier. uint64_t id; @@ -187,6 +190,10 @@ struct [[sysio::contract("sysio.system")]] snapshot_attest : public sysio::contr * the height differs by block id or snapshot hash. A height without its own final record cannot * be reopened below the latest attested height after pending rows have been purged. * + * Only the last max_pending_vote_heights scheduled heights at or below the current block take + * votes, retries included; each accepted vote purges rows below that window. A matching or + * disagreeing vote on a final record is answered as above at any height. + * * Every vote, including a no-op vote on a final tuple, requires a registered snap_account. A new * vote, or a retry while the tuple is pending, also requires live operator standing: an ACTIVE * PRODUCER in sysio.opreg that clears the live collateral minimum, bootstrapped exempt. Votes diff --git a/contracts/sysio.system/include/sysio.system/snapshot_attest_constants.hpp b/contracts/sysio.system/include/sysio.system/snapshot_attest_constants.hpp index 9f5a3f909f..e0e96f8ac5 100644 --- a/contracts/sysio.system/include/sysio.system/snapshot_attest_constants.hpp +++ b/contracts/sysio.system/include/sysio.system/snapshot_attest_constants.hpp @@ -25,6 +25,20 @@ constexpr uint32_t snapshot_attestation_grace_blocks(uint32_t block_spacing) { return (block_spacing < default_block_spacing ? block_spacing : default_block_spacing) / 2; } +/// Most recent scheduled heights that may hold pending votes; older heights are closed and their rows purged. +/// Two periods keep a height votable for at least 4x the verification grace above, which already bounds how +/// late an honest attestation is expected. +inline constexpr uint32_t max_pending_vote_heights = 2; + +/** + * Return the lowest height still open for pending votes at `current_block`: the window holds the last + * `max_pending_vote_heights` scheduled heights at or below it. 64-bit so a large cadence cannot wrap. + */ +constexpr uint64_t pending_vote_floor(uint32_t current_block, uint32_t block_spacing) { + const uint64_t window_blocks = uint64_t{max_pending_vote_heights} * block_spacing; + return current_block >= window_blocks ? current_block - window_blocks + 1 : 0; +} + /// Contract error code emitted when a provider's local tuple conflicts with a final record. inline constexpr uint64_t disagreement_error_code = 9001; diff --git a/contracts/sysio.system/src/snapshot_attest.cpp b/contracts/sysio.system/src/snapshot_attest.cpp index 299ed7a989..c8548f3b03 100644 --- a/contracts/sysio.system/src/snapshot_attest.cpp +++ b/contracts/sysio.system/src/snapshot_attest.cpp @@ -29,6 +29,7 @@ constexpr char snapshot_config_unset_error[] = "snapshot attestation configurati constexpr char invalid_block_id_error[] = "invalid block_id"; constexpr char unscheduled_block_id_error[] = "snapshot block is not a scheduled attestation height"; constexpr char historical_block_id_error[] = "snapshot block is older than latest attested snapshot height"; +constexpr char stale_block_id_error[] = "snapshot block is older than the pending vote window"; constexpr char future_block_id_error[] = "snapshot block cannot be in the future"; constexpr char min_providers_range_error[] = "min_providers must be at least 1"; constexpr char min_providers_capacity_error[] = "min_providers exceeds the maximum registrable providers"; @@ -38,6 +39,10 @@ constexpr char stale_provider_prune_log_prefix[] = "regsnapprov: pruned stale ma constexpr char stale_provider_prune_log_infix[] = " and snapshot provider "; constexpr char log_line_ending[] = "\n"; +/// Stale vote rows one vote may erase: a full window, so a steady cadence clears its backlog in one call while +/// rows a cadence change strands drain over later votes. +constexpr uint32_t max_stale_vote_purge_rows = max_snap_providers * snapshot_attestation::max_pending_vote_heights; + /// Classifies the producer-table state gating a producer that currently holds no mapping. enum class snapshot_producer_eligibility { eligible, @@ -214,9 +219,23 @@ bool matches_final_snapshot_record(snap_records_table& records, snap_record_key_ return true; } -/// Writes one final attestation and removes every pending tuple through its height. +/// Erases pending rows below the window, at most max_stale_vote_purge_rows per call. +void purge_stale_snapshot_votes(snap_votes_table& votes, uint64_t pending_floor) { + auto by_block_num = votes.get_index(); + auto purge_itr = by_block_num.begin(); + uint32_t purged_rows = 0; + while (purge_itr != by_block_num.end() && purge_itr->block_num < pending_floor + && purged_rows < max_stale_vote_purge_rows) { + purge_itr = by_block_num.erase(std::move(purge_itr)); + ++purged_rows; + } +} + +/// Writes one final attestation and removes the pending tuples from the window floor through its height. +/// +/// Starts at the floor so the work stays bounded by the window; anything below it is the stale purge's. void finalize_snapshot_vote(name self, uint32_t block_num, const checksum256& block_id, - const checksum256& snapshot_hash) { + const checksum256& snapshot_hash, uint64_t pending_floor) { snap_records_table records(self); const uint32_t current_block = static_cast(sysio::current_block_number()); records.emplace(self, snap_record_key_t{static_cast(block_num)}, [&](auto& row) { @@ -228,7 +247,7 @@ void finalize_snapshot_vote(name self, uint32_t block_num, const checksum256& bl snap_votes_table votes(self); auto by_block_num = votes.get_index(); - auto purge_itr = by_block_num.begin(); + auto purge_itr = by_block_num.lower_bound(pending_floor); while (purge_itr != by_block_num.end() && purge_itr->block_num <= block_num) { purge_itr = by_block_num.erase(std::move(purge_itr)); } @@ -268,7 +287,8 @@ void snapshot_attest::regsnapprov(name producer, name snap_account) { } check(count_snapshot_providers(providers) < max_snap_providers, provider_capacity_error); - providers.emplace(producer, snap_provider_key_t{snap_account.value}, [&](auto& row) { + // Contract-paid: the table is capped at max_snap_providers and gated on rank, so no spam path. + providers.emplace(get_self(), snap_provider_key_t{snap_account.value}, [&](auto& row) { row.snap_account = snap_account; row.producer = producer; }); @@ -301,7 +321,8 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec check(config.min_providers > 0, snapshot_config_unset_error); check(snapshot_attestation::is_scheduled_block(block_num, config.block_spacing), unscheduled_block_id_error); - check(block_num <= sysio::current_block_number(), future_block_id_error); + const uint32_t current_block = static_cast(sysio::current_block_number()); + check(block_num <= current_block, future_block_id_error); snap_records_table records(get_self()); const snap_record_key_t record_key{static_cast(block_num)}; @@ -321,6 +342,9 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec const auto latest_record = --records.cend(); check(block_num > latest_record->block_num, historical_block_id_error); } + // sysio pays for vote rows, so heights older than the window are closed: that bounds what a withheld quorum leaves. + const uint64_t pending_floor = snapshot_attestation::pending_vote_floor(current_block, config.block_spacing); + check(block_num >= pending_floor, stale_block_id_error); const name producer = provider_itr->producer; // Each vote re-checks the producer's live operator standing: ACTIVE in sysio.opreg and clear of the live @@ -329,6 +353,7 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec check(producer_rank::has_live_producer_standing(producer), producer_no_live_standing_error); snap_votes_table votes(get_self()); + purge_stale_snapshot_votes(votes, pending_floor); auto by_block_num = votes.get_index(); std::optional matching_vote_id; uint32_t voter_count = 0; @@ -352,7 +377,7 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec if (exact_retry) { if (voter_count >= config.min_providers) { credit_snapshot_attestations(get_self(), quorum_voters); - finalize_snapshot_vote(get_self(), block_num, block_id, snapshot_hash); + finalize_snapshot_vote(get_self(), block_num, block_id, snapshot_hash, pending_floor); } return; } @@ -361,13 +386,14 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec if (matching_vote_id) { const auto matching_vote = votes.get(snap_vote_key_t{*matching_vote_id}); voter_count = static_cast(matching_vote.voters.size()) + 1; - votes.modify(same_payer, snap_vote_key_t{*matching_vote_id}, [&](auto& row) { + // Billed to sysio, not to whoever opened the row. + votes.modify(get_self(), snap_vote_key_t{*matching_vote_id}, [&](auto& row) { row.voters.push_back(producer); quorum_voters = row.voters; }); } else { const uint64_t new_id = votes.available_primary_key(); - votes.emplace(snap_account, snap_vote_key_t{new_id}, [&](auto& row) { + votes.emplace(get_self(), snap_vote_key_t{new_id}, [&](auto& row) { row.id = new_id; row.block_num = block_num; row.block_id = block_id; @@ -379,7 +405,7 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec if (voter_count >= config.min_providers) { credit_snapshot_attestations(get_self(), quorum_voters); - finalize_snapshot_vote(get_self(), block_num, block_id, snapshot_hash); + finalize_snapshot_vote(get_self(), block_num, block_id, snapshot_hash, pending_floor); } } diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 87d9f2a0ba..574b40da62 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index bd4f64b815..c3e9126638 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -6160,9 +6160,6 @@ struct producer_score_tester : public producer_eligibility_tester { /// Attestable heights for `credit_snapshot_attestation` are multiples of this. static constexpr uint32_t snapshot_test_spacing = 10; - /// RAM a producer needs for the snapshot-provider rows `regsnapprov` bills to it. - static constexpr int64_t snapshot_provider_ram_bytes = 1'000'000; - /// A synthetic block id carrying `block_num` big-endian in its first four bytes, where /// `votesnaphash` reads the height from. static fc::sha256 snapshot_block_id(uint32_t block_num) { @@ -6184,8 +6181,6 @@ struct producer_score_tester : public producer_eligibility_tester { create_accounts({snap_account}, false, false, false, true); produce_blocks(1); } - BOOST_REQUIRE_EQUAL(success(), push_system_action(config::system_account_name, "setacctram"_n, mvo() - ("account", producer)("ram_bytes", snapshot_provider_ram_bytes))); BOOST_REQUIRE_EQUAL(success(), push_system_action(producer, "regsnapprov"_n, mvo() ("producer", producer)("snap_account", snap_account))); BOOST_REQUIRE_EQUAL(success(), push_system_action(config::system_account_name, "setsnpcfg"_n, mvo() diff --git a/contracts/tests/sysio.snapshot_attest_tests.cpp b/contracts/tests/sysio.snapshot_attest_tests.cpp index 600fc1d143..c527ea212c 100644 --- a/contracts/tests/sysio.snapshot_attest_tests.cpp +++ b/contracts/tests/sysio.snapshot_attest_tests.cpp @@ -11,8 +11,10 @@ #include #include +#include #include #include +#include #include "sysio.system_tester.hpp" #include "finalizer_test_keys.hpp" @@ -32,6 +34,14 @@ class snapshot_attest_tester : public sysio_system_tester { return {"producer1"_n, "producer2"_n, "producer3"_n, "producer4"_n, "producer5"_n}; } + /// The five snapshot-provider accounts the fixture creates, paired by index with `fixture_producers`. + static std::vector fixture_snapshot_providers() { + return {"snapprov1"_n, "snapprov2"_n, "snapprov3"_n, "snapprov4"_n, "snapprov5"_n}; + } + + /// The contract's `max_snap_providers`, which a host test cannot include. + static constexpr uint32_t max_snapshot_providers = 30; + snapshot_attest_tester() : snapshot_attest_tester(std::vector{}, 0) {} /** @@ -75,11 +85,7 @@ class snapshot_attest_tester : public sysio_system_tester { setup_producer_accounts(bonded_producers); // Create snap provider accounts with resources - const std::vector snap_accounts = { - "snapprov1"_n, "snapprov2"_n, "snapprov3"_n, - "snapprov4"_n, "snapprov5"_n - }; - setup_producer_accounts(snap_accounts); + setup_producer_accounts(fixture_snapshot_providers()); produce_blocks(); @@ -325,6 +331,31 @@ class snapshot_attest_tester : public sysio_system_tester { "snap_provider", data, fixture_abi_yield_function()); } + /// RAM `account` currently uses. + int64_t ram_usage(account_name account) { + return control->get_resource_limits_manager().get_account_ram_usage(account); + } + + /// Cut `account` down to what `sysio.roa::newuser` grants: newaccount_ram and no CPU/NET. + void limit_to_new_user_resources(account_name account) { + constexpr int64_t no_weight = 0; + BOOST_REQUIRE_LE(ram_usage(account), static_cast(newaccount_ram)); + BOOST_REQUIRE_EQUAL(success(), push_action(config::system_account_name, "setacctram"_n, mvo() + ("account", account)("ram_bytes", static_cast(newaccount_ram)))); + BOOST_REQUIRE_EQUAL(success(), push_action(config::system_account_name, "setacctnet"_n, mvo() + ("account", account)("net_weight", no_weight))); + BOOST_REQUIRE_EQUAL(success(), push_action(config::system_account_name, "setacctcpu"_n, mvo() + ("account", account)("cpu_weight", no_weight))); + } + + /// Produce blocks until the head is a multiple of `block_spacing`, i.e. a scheduled height. + void align_head_to(uint32_t block_spacing) { + const uint32_t remainder = control->head().block_num() % block_spacing; + if (remainder != 0) { + produce_blocks(block_spacing - remainder); + } + } + /// Count pending snapshot tuple rows across the scheduled tally space. uint32_t snapshot_vote_count() { constexpr uint64_t maximum_vote_rows = 64; @@ -744,6 +775,31 @@ BOOST_AUTO_TEST_CASE(snapshot_attestation_cadence_helpers_enforce_boundaries) { BOOST_CHECK_EQUAL(2u, cadence::snapshot_attestation_grace_blocks(odd_spacing)); BOOST_CHECK_EQUAL(12'500u, cadence::snapshot_attestation_grace_blocks(cadence::default_block_spacing)); + + // The pending window opens at the first scheduled height, then holds exactly the most recent + // max_pending_vote_heights scheduled heights at every block. + constexpr uint32_t checked_periods = 3; + constexpr uint32_t window_blocks = cadence::max_pending_vote_heights * small_spacing; + BOOST_CHECK_EQUAL(0u, cadence::pending_vote_floor(window_blocks - 1, small_spacing)); + BOOST_CHECK_EQUAL(1u, cadence::pending_vote_floor(window_blocks, small_spacing)); + for (uint32_t current = window_blocks; current < checked_periods * window_blocks; ++current) { + const uint64_t floor = cadence::pending_vote_floor(current, small_spacing); + uint32_t open_heights = 0; + for (uint32_t height = small_spacing; height <= current; height += small_spacing) { + if (height >= floor) ++open_heights; + } + BOOST_CHECK_EQUAL(cadence::max_pending_vote_heights, open_heights); + } + // A cadence near the uint32 limit cannot wrap the floor past a valid height. + BOOST_CHECK_EQUAL(0u, cadence::pending_vote_floor(largest_spacing, largest_spacing)); + + // A vote landing at the end of the node-side verification grace still finds its height open. + for (const uint32_t spacing : {uint32_t{1}, small_spacing, odd_spacing, cadence::default_block_spacing}) { + const uint32_t height = checked_periods * spacing; + BOOST_CHECK_LE(cadence::pending_vote_floor(height + cadence::snapshot_attestation_grace_blocks(spacing), + spacing), + height); + } } // --------------------------------------------------------------------------- @@ -753,11 +809,13 @@ BOOST_AUTO_TEST_CASE(snapshot_attestation_cadence_helpers_enforce_boundaries) { BOOST_FIXTURE_TEST_CASE(votesnaphash_uses_updated_cadence, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); - const auto formerly_unscheduled = make_block_id(vote_block_num() - 1); + const uint32_t formerly_unscheduled_height = vote_block_num() - 1; + const auto formerly_unscheduled = make_block_id(formerly_unscheduled_height); const auto snapshot_hash = make_snap_hash(1); BOOST_REQUIRE_EQUAL(wasm_assert_msg("snapshot block is not a scheduled attestation height"), votesnaphash("snapprov1"_n, formerly_unscheduled, snapshot_hash)); - BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1, 1)); + // The height becomes its own cadence's first, so it is also inside the pending window. + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1, formerly_unscheduled_height)); BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, formerly_unscheduled, snapshot_hash)); } FC_LOG_AND_RETHROW() } @@ -1384,6 +1442,106 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_reopening_purged_historical_height, BOOST_REQUIRE_EQUAL(0u, snapshot_vote_count()); } FC_LOG_AND_RETHROW() } +/// Without quorum, only the last max_pending_vote_heights scheduled heights keep rows: each vote purges +/// the rows that fell below the window, and any vote below it, even an exact retry, is refused. +BOOST_FIXTURE_TEST_CASE(votesnaphash_bounds_pending_votes_without_quorum, snapshot_voting_tester) { try { + namespace cadence = sysiosystem::snapshot_attestation; + constexpr uint32_t window = cadence::max_pending_vote_heights; + // Every successful push produces a block, so one retry plus a round of votes must fit in a period. + constexpr uint32_t spacing = 10; + constexpr uint32_t rounds = window + 2; + const auto producers = fixture_producers(); + const auto providers = fixture_snapshot_providers(); + BOOST_REQUIRE_LT(providers.size() + 1, spacing); + for (size_t i = 0; i < providers.size(); ++i) { + BOOST_REQUIRE_EQUAL(success(), regsnapprov(producers[i], providers[i])); + } + // Each provider votes its own tuple, so none reaches K and every height stays pending. + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2, spacing)); + align_head_to(spacing); + + const auto tuple_hash = [&](uint32_t round, size_t provider) { + return make_snap_hash(round * static_cast(providers.size()) + static_cast(provider)); + }; + std::vector heights; + for (uint32_t round = 0; round < rounds; ++round) { + const uint32_t height = control->head().block_num(); + BOOST_REQUIRE(cadence::is_scheduled_block(height, spacing)); + heights.push_back(height); + + if (round >= window) { + // The closed height's rows still exist, but the window refuses them before the tally. + const uint32_t closed_round = round - window; + BOOST_REQUIRE_EQUAL(wasm_assert_msg("snapshot block is older than the pending vote window"), + votesnaphash(providers[0], make_block_id(heights[closed_round]), + tuple_hash(closed_round, 0))); + // The oldest open height still takes an idempotent retry, which purges the closed rows. + const uint32_t oldest_open_round = closed_round + 1; + BOOST_REQUIRE_EQUAL(success(), votesnaphash(providers[0], make_block_id(heights[oldest_open_round]), + tuple_hash(oldest_open_round, 0))); + } + for (size_t i = 0; i < providers.size(); ++i) { + BOOST_REQUIRE_EQUAL(success(), votesnaphash(providers[i], make_block_id(height), tuple_hash(round, i))); + } + BOOST_REQUIRE(getsnaphash(height).is_null()); + + const uint32_t open_heights = std::min(round + 1, window); + BOOST_REQUIRE_EQUAL(providers.size() * open_heights, snapshot_vote_count()); + BOOST_REQUIRE_LE(snapshot_vote_count(), max_snapshot_providers * window); + produce_blocks(height + spacing - control->head().block_num()); + } + + // Shrinking the cadence strands the old heights below the narrower window; the next vote purges them. + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2, 1)); + const uint32_t height = control->head().block_num(); + BOOST_REQUIRE_EQUAL(success(), votesnaphash(providers[0], make_block_id(height), tuple_hash(rounds, 0))); + BOOST_REQUIRE_EQUAL(1u, snapshot_vote_count()); +} FC_LOG_AND_RETHROW() } + +/// The window gates only the pending tally: a final record still answers late votes below it, and +/// rows it closes stay until an open vote purges them. +BOOST_FIXTURE_TEST_CASE(votesnaphash_pending_window_preserves_final_record_checks, snapshot_voting_tester) { try { + namespace cadence = sysiosystem::snapshot_attestation; + constexpr uint32_t spacing = 2; + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1, spacing)); + + const uint32_t final_height = control->head().block_num() / spacing * spacing; + const auto final_id = make_block_id(final_height); + const auto final_hash = make_snap_hash(120); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, final_id, final_hash)); + BOOST_REQUIRE(!getsnaphash(final_height).is_null()); + + // A later height that never reaches K. + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2, spacing)); + const uint32_t pending_height = control->head().block_num() / spacing * spacing; + BOOST_REQUIRE_GT(pending_height, final_height); + const auto pending_id = make_block_id(pending_height); + const auto pending_hash = make_snap_hash(121); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, pending_id, pending_hash)); + BOOST_REQUIRE_EQUAL(1u, snapshot_vote_count()); + + produce_blocks(cadence::max_pending_vote_heights * spacing); + BOOST_REQUIRE_GT(cadence::pending_vote_floor(control->head().block_num() + 1, spacing), pending_height); + + // A registered provider's matching late vote is still a no-op, and a mismatch still reports 9001. + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov2"_n, final_id, final_hash)); + BOOST_REQUIRE_EQUAL(wasm_assert_code(9001), votesnaphash("snapprov2"_n, final_id, make_snap_hash(122))); + + // The unfinished height is closed to new votes and to retries alike. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("snapshot block is older than the pending vote window"), + votesnaphash("snapprov2"_n, pending_id, pending_hash)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("snapshot block is older than the pending vote window"), + votesnaphash("snapprov1"_n, pending_id, pending_hash)); + BOOST_REQUIRE_EQUAL(1u, snapshot_vote_count()); + + // An open vote purges the closed row and leaves only its own. + const uint32_t open_height = control->head().block_num() / spacing * spacing; + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov2"_n, make_block_id(open_height), make_snap_hash(123))); + BOOST_REQUIRE_EQUAL(1u, snapshot_vote_count()); +} FC_LOG_AND_RETHROW() } + /// Registration churn cannot erase pending votes cast by other producers. BOOST_FIXTURE_TEST_CASE(votesnaphash_registration_churn_preserves_other_votes, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); @@ -1405,6 +1563,62 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_registration_churn_preserves_other_votes, s BOOST_REQUIRE(!getsnaphash(block_num).is_null()); } FC_LOG_AND_RETHROW() } +// --------------------------------------------------------------------------- +// RAM billing tests +// --------------------------------------------------------------------------- +/// Provider and vote rows bill sysio, so producers and snapshot accounts holding only what +/// `sysio.roa::newuser` grants can register, rotate and vote without their RAM usage moving. +BOOST_FIXTURE_TEST_CASE(snapshot_rows_bill_the_system_account, snapshot_voting_tester) { try { + const auto producers = fixture_producers(); + const auto providers = fixture_snapshot_providers(); + constexpr uint32_t voters = 3; + std::vector limited; + for (uint32_t i = 0; i < voters; ++i) { + limited.push_back(producers[i]); + limited.push_back(providers[i]); + } + // The rotation target too, so the rotated mapping cannot bill it either. + limited.push_back(providers[voters]); + for (const auto& account : limited) { + limit_to_new_user_resources(account); + } + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(voters)); + + std::map usage_before; + for (const auto& account : limited) { + usage_before[account] = ram_usage(account); + } + const auto require_unbilled = [&]() { + for (const auto& account : limited) { + BOOST_REQUIRE_EQUAL(usage_before[account], ram_usage(account)); + } + }; + const int64_t system_before = ram_usage(config::system_account_name); + + for (uint32_t i = 0; i < voters; ++i) { + BOOST_REQUIRE_EQUAL(success(), regsnapprov(producers[i], providers[i])); + } + require_unbilled(); + BOOST_REQUIRE_GT(ram_usage(config::system_account_name), system_before); + + const auto block_num = vote_block_num(); + const auto block_id = make_block_id(block_num); + const auto snapshot_hash = make_snap_hash(130); + // The first vote creates the pending row and the second grows it; neither voter pays. + BOOST_REQUIRE_EQUAL(success(), votesnaphash(providers[0], block_id, snapshot_hash)); + BOOST_REQUIRE_EQUAL(success(), votesnaphash(providers[1], block_id, snapshot_hash)); + BOOST_REQUIRE_EQUAL(1u, snapshot_vote_count()); + require_unbilled(); + + BOOST_REQUIRE_EQUAL(success(), votesnaphash(providers[2], block_id, snapshot_hash)); + BOOST_REQUIRE(!getsnaphash(block_num).is_null()); + require_unbilled(); + + // Rotation erases and re-creates the mapping, still on sysio. + BOOST_REQUIRE_EQUAL(success(), regsnapprov(producers[0], providers[voters])); + require_unbilled(); +} FC_LOG_AND_RETHROW() } + // --------------------------------------------------------------------------- // getsnaphash tests // --------------------------------------------------------------------------- diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index 2c567b6ec9..1e98eb3707 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -221,7 +221,8 @@ of free points. Snapshot service is optional. If you want it, register a snapshot provider account with `regsnapprov` and vote snapshot hashes with `votesnaphash`; `delsnapprov` retires the delegation -again and frees the registration slot it held. Only votes that reach quorum are +again and frees the registration slot it held. Those rows are billed to `sysio`, so neither account +needs RAM for them. Only votes that reach quorum are credited, so registering alone earns nothing. The credit is a rating of the CURRENT pay period and is stamped with it, so it expires at the period boundary rather than being consumed when you leave: attestations you earned this period still count if you park and come back inside it, and none of diff --git a/docs/snapshot-attestation.md b/docs/snapshot-attestation.md index 5596e6b5d9..111b35898c 100644 --- a/docs/snapshot-attestation.md +++ b/docs/snapshot-attestation.md @@ -229,6 +229,11 @@ pending rows. Scheduling remains based on block number, never elapsed time or bl - When a tuple reaches quorum, the contract writes a `snap_record` to `snaprecords` and purges pending votes through that block height. Only compact attested records are retained, keeping RAM use negligible over time. +- Provider, vote and record rows are billed to `sysio`. Only the last `max_pending_vote_heights` (2) + scheduled heights at or below the current block take votes, so the vote table stays bounded when + quorum never forms: an older height is rejected ("snapshot block is older than the pending vote + window") and the next accepted vote purges its rows. Two periods are at least four times the + verification grace, so a vote that lands in time for verifiers is never refused. - Once a newer height is attested, a purged unfinished historical height cannot be reopened. The latest retained `snaprecords` row acts as the monotonic finalized-height watermark. - After a block is attested, any later vote carrying a different block ID or hash for that height