From 024a862ea03453b6d32fe2e30acb8ef7480b82d0 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 10 Nov 2026 21:51:19 -0600 Subject: [PATCH] Bill snapshot provider and vote rows to sysio regsnapprov billed the snapprovs row to the producer and votesnaphash billed each snapvotes row to the snapshot account. An account created through sysio.roa holds only newaccount_ram, so its regsnapprov failed 64 bytes short. Both tables now bill sysio, like the rest of the producer path. Account RAM was the only bound on pending votes when quorum stalls, so votesnaphash now accepts votes only for the two most recent scheduled heights and purges pending rows below that window, at most 60 per call. The table holds at most 60 rows. A vote or retry below the window fails with "snapshot block is older than the pending vote window"; late votes on a finalized record and the disagreement error are unchanged. No ABI change. Change-Id: I7b25ceb014982f934e8678a6e3c17650e3576ebf --- .../include/sysio.system/snapshot_attest.hpp | 11 +- .../snapshot_attest_constants.hpp | 14 ++ .../sysio.system/src/snapshot_attest.cpp | 44 +++- contracts/sysio.system/sysio.system.wasm | Bin 225054 -> 225779 bytes contracts/tests/emissions_tests.cpp | 5 - .../tests/sysio.snapshot_attest_tests.cpp | 228 +++++++++++++++++- docs/becoming-a-block-producer.md | 3 +- docs/snapshot-attestation.md | 5 + 8 files changed, 286 insertions(+), 24 deletions(-) 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 87d9f2a0bad0955ca6ff100225b2d8bcb253245b..574b40da62779743b99faeedd2039cb1fc1cd888 100755 GIT binary patch delta 6784 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 RcwD`=OcOp(%SS89{{jKhnScNQ delta 6189 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 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