From d9f51de3c212b6f2a14926ac8c1dd62a86610d5e Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 28 Sep 2026 11:32:02 -0500 Subject: [PATCH 1/3] contracts: document audited designs and pin two in tests addpolicy stores time_block unchecked, so a value at or below the current block issues an unlocked grant that reducepolicy can take back at once. activateroa, node-owner registration and bootstrap tooling rely on that. Say so on addpolicy and in the ROA overview, which described every grant as time-committed, and correct reducepolicy's brief, which said it was callable only after time_block when the check admits the call at time_block too. network_gen is written only by the one-shot activateroa, so the policy actions accept only generation 0. Document that on addpolicy, expandpolicy and reducepolicy, and note on the policies table that a row does not record its funding generation, which a future rotation must add. A new test has all three actions reject generation 1 while the same calls at generation 0 go through. A new finalizer-key test deletes a producer's only key and registers it again: the key comes back under a new id, since key ids are never reused. A terminated operator can settle, re-register and collect its held blocks, so the unpaid_blocks comment no longer says otherwise, and the producer guide now says so and states that a slashed producer's held blocks are forfeited. votedispute now notes that ballots stay open past the deadline on purpose: the deadline only relaxes the tally, and an envelope dispute cannot lapse the way an underwriter challenge does. The header changes are comments only; no contract wasm or abi changes. --- .../include/sysio.chalg/sysio.chalg.hpp | 2 ++ contracts/sysio.roa/sysio.roa.hpp | 21 +++++++++--- .../include/sysio.system/sysio.system.hpp | 2 +- contracts/tests/sysio.finalizer_key_tests.cpp | 29 ++++++++++++++++ contracts/tests/sysio.roa_tests.cpp | 34 +++++++++++++++++++ docs/becoming-a-block-producer.md | 4 +++ docs/roa-overview.md | 9 ++--- 7 files changed, 92 insertions(+), 9 deletions(-) diff --git a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp index 55b9f43220..c01736707d 100644 --- a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp +++ b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp @@ -67,6 +67,8 @@ namespace sysio { /// `owner` must be a member of the dispute's snapshotted electorate (Tier-1 node owners at /// the time the dispute opened -- later registrations cannot join an in-flight dispute); /// `chosen_checksum` must be one of the dispute's candidate checksums. One vote per owner. + /// Ballots stay open past the deadline by design: the deadline only relaxes the tally, and an + /// envelope dispute has no non-resolution outcome, unlike an underwriter challenge, which lapses. [[sysio::action]] void votedispute(name owner, uint64_t dispute_id, checksum256 chosen_checksum); diff --git a/contracts/sysio.roa/sysio.roa.hpp b/contracts/sysio.roa/sysio.roa.hpp index 54502e4e2d..e136b52a4d 100644 --- a/contracts/sysio.roa/sysio.roa.hpp +++ b/contracts/sysio.roa/sysio.roa.hpp @@ -51,8 +51,12 @@ namespace sysio { * @param cpu_weight The amount of SYS allocated for CPU * @param ram_weight The amount of SYS allocated for RAM. * @param time_block A block number, the policy can't be reclaimed or reduced before this block. + * A value at or below the current block issues the grant unlocked (reducible + * immediately); extendpolicy adds or lengthens a lock. * @param network_gen Generation of issuer, in cases where you are a Node Owner in multiple, - * specifies which allocation of SYS to pull from. + * specifies which allocation of SYS to pull from. Must not exceed the + * current generation, which is always 0: only the one-shot activateroa + * sets it. */ [[sysio::action]] void addpolicy(const name& owner, const name& issuer, const asset& net_weight, const asset& cpu_weight, const asset& ram_weight, const uint32_t& time_block, const uint8_t& network_gen); @@ -68,7 +72,9 @@ namespace sysio { * @param cpu_weight The amount in SYS to increase CPU by. * @param ram_weight The amount in SYS to increase RAM by. * @param network_gen Generation of issuer, in cases where you are a Node Owner in multiple, - * specifies which allocation of SYS to pull from. + * specifies which allocation of SYS to pull from. Must not exceed the + * current generation, which is always 0: only the one-shot activateroa + * sets it. */ [[sysio::action]] void expandpolicy(const name& owner, const name& issuer, const asset& net_weight, const asset& cpu_weight, const asset& ram_weight, const uint8_t& network_gen); @@ -85,7 +91,7 @@ namespace sysio { /** * @brief Decrease the resource limits on an existing policy. Subtracts new weights from - * existing values. Only callable after policy's time_block. + * existing values. Only callable once the current block reaches the policy's time_block. * * Note: Will reclaim UPTO ram_weight worth of bytes, limited to the pool of unused bytes on 'owner's reslimit and upper bound by the policy ram_weight. * @@ -100,7 +106,9 @@ namespace sysio { * @param cpu_weight The non-negative amount in SYS to decrease CPU by. * @param ram_weight The non-negative amount in SYS to attempt decreasing RAM by, returning only * @param network_gen Generation of issuer, in cases where you are a Node Owner in multiple, - * specifies which allocation of SYS to adjust. + * specifies which allocation of SYS to adjust. Must not exceed the + * current generation, which is always 0: only the one-shot activateroa + * sets it. */ [[sysio::action]] void reducepolicy(const name& owner, const name& issuer, const asset& net_weight, const asset& cpu_weight, const asset& ram_weight, const uint8_t& network_gen); @@ -325,6 +333,11 @@ namespace sysio { SYSLIB_SERIALIZE(policy_key, (owner)) }; + /** + * A row does not record the network generation that funded it; expandpolicy and reducepolicy + * adjust whichever generation's budget the caller names. A future generation rotation must add + * that field before those two actions can be generation-safe. + */ struct [[sysio::table("policies")]] policies { name owner; // Account name this policy applies to. name issuer; // Account name of the Node Owner who issued this policy. diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index 869ed21153..8759ef4661 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -171,7 +171,7 @@ namespace sysiosystem { /// producer's slot did not deliver is simply never counted, so its pay stays in the treasury. /// The count SURVIVES a park, a demotion, or a lost key: a producer that is not schedulable /// at a payepoch is neither paid nor reset, and whatever it made is paid at the first - /// payepoch where it is schedulable again (never, for a slashed or terminated one). + /// payepoch where it is schedulable again (never, for a slashed one). uint32_t unpaid_blocks = 0; time_point last_claim_time; uint16_t location = 0; diff --git a/contracts/tests/sysio.finalizer_key_tests.cpp b/contracts/tests/sysio.finalizer_key_tests.cpp index de7fe29652..35b82b4b4a 100644 --- a/contracts/tests/sysio.finalizer_key_tests.cpp +++ b/contracts/tests/sysio.finalizer_key_tests.cpp @@ -489,6 +489,35 @@ BOOST_FIXTURE_TEST_CASE(delete_last_finalizer_key_test, finalizer_key_tester) tr } FC_LOG_AND_RETHROW() // delete_last_finalizer_key_test +// A deleted key can be registered again. The new registration gets a fresh id, because key ids are +// never reused, even once no row holds the old one. +BOOST_FIXTURE_TEST_CASE(reregister_deleted_finalizer_key_gets_new_id, finalizer_key_tester) try { + BOOST_REQUIRE_EQUAL( success(), regproducer(alice) ); + + BOOST_REQUIRE_EQUAL( success(), register_finalizer_key(alice, finalizer_key_1, pop_1) ); + const uint64_t first_id = get_finalizer_info(alice)["active_key_id"].as_uint64(); + + // Deleting alice's only key erases her finalizer row along with the key row. + BOOST_REQUIRE_EQUAL( success(), delete_finalizer_key(alice, finalizer_key_1) ); + BOOST_REQUIRE( get_finalizer_info(alice).is_null() ); + BOOST_REQUIRE( get_finalizer_key_info(first_id).is_null() ); + + BOOST_REQUIRE_EQUAL( success(), register_finalizer_key(alice, finalizer_key_1, pop_1) ); + + // The key is active again under a new id, and the old id stays vacant. + const auto alice_info = get_finalizer_info(alice); + const uint64_t second_id = alice_info["active_key_id"].as_uint64(); + BOOST_REQUIRE_GT( second_id, first_id ); + BOOST_REQUIRE_EQUAL( 1, alice_info["finalizer_key_count"].as_uint64() ); + BOOST_REQUIRE_EQUAL( finalizer_key_binary_1, alice_info["active_key_binary"].as_string() ); + + const auto key_info = get_finalizer_key_info(second_id); + BOOST_REQUIRE_EQUAL( "alice1111111", key_info["finalizer_name"].as_string() ); + BOOST_REQUIRE_EQUAL( finalizer_key_1, key_info["finalizer_key"].as_string() ); + BOOST_REQUIRE( get_finalizer_key_info(first_id).is_null() ); +} +FC_LOG_AND_RETHROW() // reregister_deleted_finalizer_key_gets_new_id + // After registering keys and waiting for update_ranked_producers, test key activation BOOST_FIXTURE_TEST_CASE(multi_activation_tests, finalizer_key_tester) try { auto producer_names = activate_producers_with_operators(); diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp index 632b06738b..9625b74c94 100644 --- a/contracts/tests/sysio.roa_tests.cpp +++ b/contracts/tests/sysio.roa_tests.cpp @@ -1502,6 +1502,40 @@ BOOST_FIXTURE_TEST_CASE( reducepolicy_wrong_symbol, sysio_roa_full_tester ) try sysio_assert_message_is("policy weights must be denominated in the core SYS symbol")); } FC_LOG_AND_RETHROW() +// ===== 1c. shared network_gen guard ===== + +// activateroa fixes the generation at 0 and no action advances it, so each policy action rejects +// generation 1 while the same call at the current generation goes through. +BOOST_FIXTURE_TEST_CASE( policy_actions_reject_future_network_gen, sysio_roa_full_tester ) try { + constexpr auto future_gen = NETWORK_GEN + 1; + constexpr auto invalid_gen = "Invalid network generation."; + const auto issuer = node_owners[2]; + const auto user = create_newuser(issuer); + produce_block(); + + BOOST_CHECK_EXCEPTION( + add_roa_policy(issuer, user, "2.0000 SYS", "2.0000 SYS", "2.0000 SYS", 0, future_gen), + sysio_assert_message_exception, sysio_assert_message_is(invalid_gen)); + add_roa_policy(issuer, user, "2.0000 SYS", "2.0000 SYS", "2.0000 SYS", 0, NETWORK_GEN); + + BOOST_CHECK_EXCEPTION( + expand_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", future_gen), + sysio_assert_message_exception, sysio_assert_message_is(invalid_gen)); + expand_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", NETWORK_GEN); + + BOOST_CHECK_EXCEPTION( + reduce_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", future_gen), + sysio_assert_message_exception, sysio_assert_message_is(invalid_gen)); + reduce_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", NETWORK_GEN); + produce_block(); + + // Only the current-generation calls moved the policy: 2 + 1 - 1. + const auto p = get_policy(user, issuer); + BOOST_TEST(p["net_weight"].as_string() == "2.0000 SYS"); + BOOST_TEST(p["cpu_weight"].as_string() == "2.0000 SYS"); + BOOST_TEST(p["ram_weight"].as_string() == "2.0000 SYS"); +} FC_LOG_AND_RETHROW() + // ===== 2. expandpolicy validation ===== // Expand non-existent policy should fail diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index ed89d5031a..e8f9f5b220 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -206,6 +206,10 @@ is paid at the first payout after you are payable again. Unregistering right aft re-registering before your next round costs you nothing provided you still meet the admission requirements; if governance raised a collateral minimum while you were parked, top up first. +A terminated producer that settles its collateral and re-registers is paid them too. Slashing is the +exception: a slashed producer can never be payable again, so any blocks it produced and was not yet +paid for are forfeited. + The one bound worth stating: a payout walks the ranking from the top and stops after a fixed number of rows, far below which no producer is paid anything anyway. Settling held blocks therefore requires you to be back within that reach, which is roughly twenty times the paid band — so in diff --git a/docs/roa-overview.md b/docs/roa-overview.md index 5dd9363aef..04ffda6655 100644 --- a/docs/roa-overview.md +++ b/docs/roa-overview.md @@ -289,7 +289,7 @@ A policy is a grant of resource weight from a node owner (the **issuer**) to an | `cpu_weight` | SYS-denominated weight granted for CPU | | `ram_weight` | SYS-denominated weight granted for RAM | | `bytes_per_unit` | Bytes per 0.0001 SYS, **frozen at the moment the policy was created** | -| `time_block` | Block height before which the policy cannot be reduced or reclaimed | +| `time_block` | Block height before which the policy cannot be reduced or reclaimed; at or below the current block, the grant is unlocked | RAM weight converts to bytes at `bytes_per_unit`. At the network's launch price of 104 bytes per 0.0001 SYS, **1 SYS of `ram_weight` ≈ 1.04 MB**. @@ -836,9 +836,10 @@ owners simultaneously, and any one of them can reduce their own policy — at or cleanly: see [Stacking policies](#stacking-policies-from-multiple-issuers) for what consumed RAM does to the reclaim. -**Grants are time-committed.** `reducepolicy` is blocked until `time_block`, and `extendpolicy` can -only push that block further out, never shorten it. An issuer commits to a term when they issue and -cannot reclaim before it elapses. +**Grants can be time-committed.** `reducepolicy` is blocked until `time_block`, and `extendpolicy` +can only push that block further out, never shorten it. An issuer that sets a future `time_block` +commits to that term and cannot reclaim before it elapses. A `time_block` at or below the current +block issues the grant unlocked, reducible immediately; `extendpolicy` can lock it later. **Caps are enforced on-chain** against the authoritative node-owner rows — 21 / 84 / 1,000 — counted in `regnodeowner` at registration time. From 1245b25eb1cd196650646b3da6066de92ff2323b Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Fri, 23 Oct 2026 14:03:19 -0400 Subject: [PATCH 2/3] contracts: a terminated producer must bond again before its held blocks are paid Termination credits the bond to the operator's claim, and regoperator starts a new UNKNOWN registration with no bond, so re-registering alone does not make a producer payable. The producer guide now says to post every required bond again, and to call regproducer if the row was parked; unpaid_blocks lists termination among what the count survives. A new test terminates a bonded producer, pulls the returned bond and registers again: the payouts before the new bond pay nothing and keep the count, and the payout after it pays every held block. Master is merged in. Change-Id: I98b4bf7e8764ff3745694ba3695b55e2fad864c2 --- .../include/sysio.system/sysio.system.hpp | 6 +- contracts/tests/emissions_tests.cpp | 66 ++++++++++++++++++- docs/becoming-a-block-producer.md | 5 +- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index 9282fb16e3..07f630c368 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -170,9 +170,9 @@ namespace sysiosystem { /// the period's per-block rate and zeroes the count of every producer it pays; a block the /// producer's slot did not deliver is simply never counted, so its pay stays in the treasury. /// At most one round per rotation is counted; see `last_paid_round`. - /// The count SURVIVES a park, a demotion, or a lost key: a producer that is not schedulable - /// at a payepoch is neither paid nor reset, and whatever was counted is paid at the first - /// payepoch where it is schedulable again (never, for a slashed one). + /// The count SURVIVES a park, a demotion, a lost key, or a termination: a producer that is not + /// schedulable at a payepoch is neither paid nor reset, and whatever was counted is paid at the + /// first payepoch where it is schedulable again (never, for a slashed one). uint32_t unpaid_blocks = 0; time_point last_claim_time; /// Schedule-order key: `update_ranked_producers` sorts a new set of scheduled producers by diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 825ff5ad12..08d2da1150 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -231,6 +231,8 @@ static constexpr uint32_t T_STANDBY_END_RANK = 28; static constexpr uint16_t T_STANDBY_BPS = 800; /// The fixture's default epoch (init_epoch_state) is 60s at one block per 500ms. static constexpr uint32_t T_EPOCH_SECS = 60; +/// Seconds in one T5 emission epoch (24 hours). +static constexpr uint32_t ONE_EPOCH = 86400u; // Helper: amount NOT transferred at payepoch when no producers / batch // members are paid. Equals producer_pool + batch_pool (compute share, both @@ -2487,9 +2489,6 @@ BOOST_AUTO_TEST_SUITE_END() // sysio_emissions_tests BOOST_AUTO_TEST_SUITE(t5_emissions_tests) -// Helper: number of seconds in one epoch (24 hours) -static constexpr uint32_t ONE_EPOCH = 86400u; - // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- @@ -6450,6 +6449,67 @@ BOOST_FIXTURE_TEST_CASE( pruned_operator_row_stays_demoted_through_rescore_sweep BOOST_REQUIRE(!is_scheduled(target)); } FC_LOG_AND_RETHROW() +// Termination keeps a producer's counted blocks but credits its bond to its claim, and a new +// registration starts UNKNOWN with no bond, so the blocks are paid only once it bonds again. +BOOST_FIXTURE_TEST_CASE( terminated_producer_is_paid_held_blocks_once_bonded_again, + producer_score_tester ) try { + create_t5_holding_accounts(); + const auto names = setup_collateralized_producers(3); + BOOST_REQUIRE_EQUAL( success(), set_producer_schedule(names) ); + produce_blocks(1); + wait_for_producer_schedule(); + produce_complete_cycles(3, 2); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + const auto target = names[0]; + BOOST_REQUIRE_EQUAL( success(), terminate_operator(target) ); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_TERMINATED", get_opreg_operator(target)["status"].as_string() ); + const uint32_t held = unpaid_blocks_of(target); + BOOST_REQUIRE_GT( held, 0u ); + + // A payout the producer is not payable at pays it nothing and keeps its count. + const auto require_unpaid_payout = [&] { + const uint32_t blocks = unpaid_blocks_of(target); + const asset before = get_wire_balance_paid(target); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + BOOST_REQUIRE_EQUAL( get_wire_balance_paid(target), before ); + BOOST_REQUIRE_GE( unpaid_blocks_of(target), blocks ); + }; + require_unpaid_payout(); + + // Pull the returned bond and register again: the new registration holds no bond. + const asset unclaimed = get_wire_balance(target); + BOOST_REQUIRE_EQUAL( success(), push_opreg_action(target, "claimremit"_n, + mvo()("account", target)("token_code", collateral_token)) ); + BOOST_REQUIRE_EQUAL( static_cast(base_min_bond), + get_wire_balance(target).get_amount() - unclaimed.get_amount() ); + BOOST_REQUIRE_EQUAL( success(), push_opreg_action(target, "regoperator"_n, mvo() + ("account", target)("type", OperatorType::OPERATOR_TYPE_PRODUCER)("is_bootstrapped", false)) ); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_UNKNOWN", get_opreg_operator(target)["status"].as_string() ); + produce_blocks(130); + require_unpaid_payout(); + + // Bond again: the next payout pays every held block at that period's rate. + BOOST_REQUIRE_EQUAL( success(), push_opreg_action(target, "deposit"_n, mvo() + ("account", target)("token_code", collateral_token)("amount", base_min_bond)) ); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_ACTIVE", get_opreg_operator(target)["status"].as_string() ); + produce_blocks(130); + + uint64_t produced = 0; + for (const auto& p : names) produced += unpaid_blocks_of(p); + const uint32_t blocks = unpaid_blocks_of(target); + BOOST_REQUIRE_GE( blocks, held ); + const asset before = get_wire_balance_paid(target); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + const int64_t got = get_wire_balance_paid(target).get_amount() - before.get_amount(); + const int64_t active_pool = test_active_pool(get_epoch_log(3)["compute_amount"].as()); + const uint64_t divisor = std::max(produced, test_nominal_slots(T_EPOCH_SECS)); + BOOST_REQUIRE_EQUAL( got, test_block_pay(active_pool, blocks, divisor) ); + BOOST_REQUIRE_GT( got, 0 ); +} FC_LOG_AND_RETHROW() + // --------------------------------------------------------------------------- // Position versus index slot // --------------------------------------------------------------------------- diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index 432962586d..587b936ebd 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -213,7 +213,10 @@ first payout after you are payable again. Unregistering right after producing an re-registering before your next round costs you nothing provided you still meet the admission requirements; if governance raised a collateral minimum while you were parked, top up first. -A terminated producer that settles its collateral and re-registers is paid them too. Slashing is the +Termination does not forfeit them either, but re-registering alone does not make you payable: +termination credits your whole bond to your `claimremit` claim, and `regoperator` starts a new +`UNKNOWN` registration with no bond. Post every required bond again, and call `regproducer` if your +producer row was parked; the held blocks are paid at the first payout after that. Slashing is the exception: a slashed producer can never be payable again, so any blocks it produced and was not yet paid for are forfeited. From 41fadc79383755e9e346d89e4610e97f76aacede Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Fri, 23 Oct 2026 17:11:37 -0400 Subject: [PATCH 3/3] contracts: a stale withdrawal holds back a rebonded producer until cancelled Termination leaves queued withdrawals in place, and the queue is per account, so a request from before termination reserves part of the new registration's bond and is paid out of it when it matures. The producer guide now says to cancel it with cancelwtdw before bonding again, and a new test rebonds a terminated producer while its request is queued: it stays UNKNOWN until the request is cancelled, and the next payout pays its held blocks. The held-pay tests now share a fixture, keep OperatorStatus typed, name their opreg actions and fields and their timing values, and read the paid epoch from epochstate. The network_gen and finalizer-key tests name their quantities and field keys and use the alice fixture. Master is merged in. Change-Id: Iacf1d505441a609cdc192b65da97f2ec62fd5c4b --- contracts/tests/emissions_tests.cpp | 232 ++++++++++++++---- contracts/tests/sysio.finalizer_key_tests.cpp | 21 +- contracts/tests/sysio.roa_tests.cpp | 30 ++- docs/becoming-a-block-producer.md | 10 +- 4 files changed, 227 insertions(+), 66 deletions(-) diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 6600e9ea05..06a12f09f8 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -61,6 +61,44 @@ static constexpr account_name EPOCH = "sysio.epoch"_n; static constexpr account_name CHALG = "sysio.chalg"_n; static constexpr account_name MSGCH = "sysio.msgch"_n; +/// sysio.opreg actions the producer collateral and held-pay tests push. +namespace opreg_action { +constexpr auto regoperator = "regoperator"_n; +constexpr auto deposit = "deposit"_n; +constexpr auto withdraw = "withdraw"_n; +constexpr auto cancelwtdw = "cancelwtdw"_n; +constexpr auto claimremit = "claimremit"_n; +} // namespace opreg_action + +/// sysio.opreg tables the held-pay tests read, with the ABI type of a decoded row. +namespace opreg_table { +constexpr auto opcounters = "opcounters"_n; +constexpr char opcounters_row[] = "op_counters"; +constexpr auto wtdwqueue = "wtdwqueue"_n; +} // namespace opreg_table + +/// sysio.opreg action and row fields the producer collateral and held-pay tests use. +namespace opreg_field { +constexpr char account[] = "account"; +constexpr char type[] = "type"; +constexpr char is_bootstrapped[] = "is_bootstrapped"; +constexpr char token_code[] = "token_code"; +constexpr char amount[] = "amount"; +constexpr char request_id[] = "request_id"; +constexpr char status[] = "status"; +constexpr char next_withdraw_id[] = "next_withdraw_id"; +} // namespace opreg_field + +/// sysio.epoch `epochstate` field the held-pay payout check reads. +namespace epoch_field { +constexpr char current_epoch_index[] = "current_epoch_index"; +} // namespace epoch_field + +/// sysio.system `epochlog` field the held-pay payout check reads. +namespace epochlog_field { +constexpr char compute_amount[] = "compute_amount"; +} // namespace epochlog_field + // Keep these in sync with contracts/sysio.system/src/emissions.cpp static constexpr uint32_t SECONDS_PER_MONTH = 30u * 24u * 60u * 60u; static constexpr uint32_t T1_DURATION = 12u * SECONDS_PER_MONTH; @@ -5626,8 +5664,14 @@ struct producer_score_tester : public producer_eligibility_tester { base_tester::push_action("sysio.liq"_n, "mint"_n, "sysio.synd"_n, mvo()("to", account)("token_code", token)("amount", amount)); } - return push_opreg_action(account, "deposit"_n, - mvo()("account", account)("token_code", token)("amount", amount)); + return deposit_collateral(account, amount, token); + } + + /// Bond `amount` of `token` the account already holds. + action_result deposit_collateral(account_name account, uint64_t amount, + std::string_view token = collateral_token) { + return push_opreg_action(account, opreg_action::deposit, mvo() + (opreg_field::account, account)(opreg_field::token_code, token)(opreg_field::amount, amount)); } /// Push `setscorecfg`. Defaults mirror the contract's own so a test names only the weight it @@ -5833,6 +5877,109 @@ struct producer_score_tester : public producer_eligibility_tester { } }; +/// Bonded producers on a published schedule, for tests that terminate one, bring it back and +/// check which payouts pay its held blocks. +struct terminated_producer_tester : public producer_score_tester { + + /// Producers on the published schedule. + static constexpr uint32_t producer_count = 3; + + /// Full rotations produced before the first payout, so every producer holds counted blocks. + static constexpr uint32_t warmup_rotations = 2; + + /// T5 starts this many seconds more than one T5 epoch ago, so the first advance pays. + static constexpr uint32_t t5_start_overdue_secs = 1; + + /// Blocks produced beyond one sysio.epoch of slots, so the next advance is past the boundary. + static constexpr uint32_t epoch_crossing_margin_blocks = 10; + + /// Bond the producers, publish their schedule, produce the warm-up rotations and start T5. + std::vector setup_paid_producers() { + create_t5_holding_accounts(); + auto names = setup_collateralized_producers(producer_count); + BOOST_REQUIRE_EQUAL( success(), set_producer_schedule(names) ); + produce_blocks(1); + wait_for_producer_schedule(); + produce_complete_cycles(producer_count, warmup_rotations); + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, + tpsec(head_secs() - ONE_EPOCH - t5_start_overdue_secs) ) ); + return names; + } + + /// Produce past the next sysio.epoch boundary, so the next advance may run. + void cross_epoch_boundary() { + produce_blocks(test_nominal_slots(T_EPOCH_SECS) + epoch_crossing_margin_blocks); + } + + OperatorStatus operator_status(account_name account) { + return get_opreg_operator(account)[opreg_field::status].as(); + } + + /// Register `account` as a producer operator under its own authority, as an operator would. + action_result regoperator(account_name account) { + return push_opreg_action(account, opreg_action::regoperator, mvo() + (opreg_field::account, account) + (opreg_field::type, OperatorType::OPERATOR_TYPE_PRODUCER) + (opreg_field::is_bootstrapped, false)); + } + + action_result withdraw(account_name account, uint64_t amount) { + return push_opreg_action(account, opreg_action::withdraw, mvo() + (opreg_field::account, account)(opreg_field::token_code, collateral_token)(opreg_field::amount, amount)); + } + + action_result cancelwtdw(account_name account, uint64_t request_id) { + return push_opreg_action(account, opreg_action::cancelwtdw, mvo() + (opreg_field::account, account)(opreg_field::request_id, request_id)); + } + + action_result claimremit(account_name account) { + return push_opreg_action(account, opreg_action::claimremit, mvo() + (opreg_field::account, account)(opreg_field::token_code, collateral_token)); + } + + /// The id `withdraw` gave the latest request: one below opreg's next free id. + uint64_t last_withdraw_request_id() { + const auto data = get_row_by_account(OPREG, OPREG, opreg_table::opcounters, opreg_table::opcounters); + BOOST_REQUIRE( !data.empty() ); + const auto counters = opreg_abi_ser.binary_to_variant(opreg_table::opcounters_row, data, + fixture_abi_yield_function()); + return counters[opreg_field::next_withdraw_id].as() - 1; + } + + bool withdraw_request_queued(uint64_t request_id) { + return !get_row_by_id(OPREG, OPREG, opreg_table::wtdwqueue, request_id).empty(); + } + + /// A payout `producer` is not payable at pays it nothing and keeps its count. + void assert_unpaid_payout(account_name producer) { + const uint32_t blocks = unpaid_blocks_of(producer); + const asset before = get_wire_balance_paid(producer); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + BOOST_REQUIRE_EQUAL( get_wire_balance_paid(producer), before ); + BOOST_REQUIRE_GE( unpaid_blocks_of(producer), blocks ); + } + + /// The next payout pays `producer` every block it holds, at least `held`, at that period's rate. + void assert_payout_pays_held_blocks(const std::vector& names, account_name producer, + uint32_t held) { + uint64_t produced = 0; + for (const auto& p : names) produced += unpaid_blocks_of(p); + const uint32_t blocks = unpaid_blocks_of(producer); + BOOST_REQUIRE_GE( blocks, held ); + const asset before = get_wire_balance_paid(producer); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + const int64_t got = get_wire_balance_paid(producer).get_amount() - before.get_amount(); + // The payout logs the epoch the advance moved to. + const auto paid_epoch = get_epoch_state_row()[epoch_field::current_epoch_index].as(); + const int64_t active_pool = + test_active_pool(get_epoch_log(paid_epoch)[epochlog_field::compute_amount].as()); + const uint64_t divisor = std::max(produced, test_nominal_slots(T_EPOCH_SECS)); + BOOST_REQUIRE_EQUAL( got, test_block_pay(active_pool, blocks, divisor) ); + BOOST_REQUIRE_GT( got, 0 ); + } +}; + BOOST_AUTO_TEST_SUITE(sysio_producer_score_tests) // ACTIVE is role-specific at schedule time too. Force a full rescore after replacing a settled @@ -6452,62 +6599,55 @@ BOOST_FIXTURE_TEST_CASE( pruned_operator_row_stays_demoted_through_rescore_sweep // Termination keeps a producer's counted blocks but credits its bond to its claim, and a new // registration starts UNKNOWN with no bond, so the blocks are paid only once it bonds again. BOOST_FIXTURE_TEST_CASE( terminated_producer_is_paid_held_blocks_once_bonded_again, - producer_score_tester ) try { - create_t5_holding_accounts(); - const auto names = setup_collateralized_producers(3); - BOOST_REQUIRE_EQUAL( success(), set_producer_schedule(names) ); - produce_blocks(1); - wait_for_producer_schedule(); - produce_complete_cycles(3, 2); - - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - + terminated_producer_tester ) try { + const auto names = setup_paid_producers(); const auto target = names[0]; BOOST_REQUIRE_EQUAL( success(), terminate_operator(target) ); - BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_TERMINATED", get_opreg_operator(target)["status"].as_string() ); + BOOST_REQUIRE( OperatorStatus::OPERATOR_STATUS_TERMINATED == operator_status(target) ); const uint32_t held = unpaid_blocks_of(target); BOOST_REQUIRE_GT( held, 0u ); - - // A payout the producer is not payable at pays it nothing and keeps its count. - const auto require_unpaid_payout = [&] { - const uint32_t blocks = unpaid_blocks_of(target); - const asset before = get_wire_balance_paid(target); - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - BOOST_REQUIRE_EQUAL( get_wire_balance_paid(target), before ); - BOOST_REQUIRE_GE( unpaid_blocks_of(target), blocks ); - }; - require_unpaid_payout(); + assert_unpaid_payout(target); // Pull the returned bond and register again: the new registration holds no bond. const asset unclaimed = get_wire_balance(target); - BOOST_REQUIRE_EQUAL( success(), push_opreg_action(target, "claimremit"_n, - mvo()("account", target)("token_code", collateral_token)) ); + BOOST_REQUIRE_EQUAL( success(), claimremit(target) ); BOOST_REQUIRE_EQUAL( static_cast(base_min_bond), get_wire_balance(target).get_amount() - unclaimed.get_amount() ); - BOOST_REQUIRE_EQUAL( success(), push_opreg_action(target, "regoperator"_n, mvo() - ("account", target)("type", OperatorType::OPERATOR_TYPE_PRODUCER)("is_bootstrapped", false)) ); - BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_UNKNOWN", get_opreg_operator(target)["status"].as_string() ); - produce_blocks(130); - require_unpaid_payout(); + BOOST_REQUIRE_EQUAL( success(), regoperator(target) ); + BOOST_REQUIRE( OperatorStatus::OPERATOR_STATUS_UNKNOWN == operator_status(target) ); + cross_epoch_boundary(); + assert_unpaid_payout(target); // Bond again: the next payout pays every held block at that period's rate. - BOOST_REQUIRE_EQUAL( success(), push_opreg_action(target, "deposit"_n, mvo() - ("account", target)("token_code", collateral_token)("amount", base_min_bond)) ); - BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_ACTIVE", get_opreg_operator(target)["status"].as_string() ); - produce_blocks(130); + BOOST_REQUIRE_EQUAL( success(), deposit_collateral(target, base_min_bond) ); + BOOST_REQUIRE( OperatorStatus::OPERATOR_STATUS_ACTIVE == operator_status(target) ); + cross_epoch_boundary(); + assert_payout_pays_held_blocks(names, target, held); +} FC_LOG_AND_RETHROW() + +// A withdrawal queued before termination outlives it, and the queue is per account: it reserves part +// of the new registration's bond until it is cancelled. +BOOST_FIXTURE_TEST_CASE( stale_withdrawal_holds_back_a_rebonded_producer_until_cancelled, + terminated_producer_tester ) try { + constexpr uint64_t stale_amount = base_min_bond / 2; // any part of the bond + const auto names = setup_paid_producers(); + const auto target = names[0]; + BOOST_REQUIRE_EQUAL( success(), withdraw(target, stale_amount) ); + const uint64_t stale_request = last_withdraw_request_id(); + BOOST_REQUIRE_EQUAL( success(), terminate_operator(target) ); + BOOST_REQUIRE( withdraw_request_queued(stale_request) ); + const uint32_t held = unpaid_blocks_of(target); - uint64_t produced = 0; - for (const auto& p : names) produced += unpaid_blocks_of(p); - const uint32_t blocks = unpaid_blocks_of(target); - BOOST_REQUIRE_GE( blocks, held ); - const asset before = get_wire_balance_paid(target); - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - const int64_t got = get_wire_balance_paid(target).get_amount() - before.get_amount(); - const int64_t active_pool = test_active_pool(get_epoch_log(3)["compute_amount"].as()); - const uint64_t divisor = std::max(produced, test_nominal_slots(T_EPOCH_SECS)); - BOOST_REQUIRE_EQUAL( got, test_block_pay(active_pool, blocks, divisor) ); - BOOST_REQUIRE_GT( got, 0 ); + // Register and post the whole required bond at once, while the request is still queued. + BOOST_REQUIRE_EQUAL( success(), regoperator(target) ); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(target, base_min_bond) ); + BOOST_REQUIRE( OperatorStatus::OPERATOR_STATUS_UNKNOWN == operator_status(target) ); + + // Cancelling it frees the bond, and the next payout pays every held block. + BOOST_REQUIRE_EQUAL( success(), cancelwtdw(target, stale_request) ); + BOOST_REQUIRE( !withdraw_request_queued(stale_request) ); + BOOST_REQUIRE( OperatorStatus::OPERATOR_STATUS_ACTIVE == operator_status(target) ); + assert_payout_pays_held_blocks(names, target, held); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- diff --git a/contracts/tests/sysio.finalizer_key_tests.cpp b/contracts/tests/sysio.finalizer_key_tests.cpp index db3b05425c..ba3b17ebf8 100644 --- a/contracts/tests/sysio.finalizer_key_tests.cpp +++ b/contracts/tests/sysio.finalizer_key_tests.cpp @@ -116,6 +116,15 @@ BOOST_AUTO_TEST_SUITE(sysio_system_finalizer_key_tests) const name alice = "alice1111111"_n; const name bob = "bob111111111"_n; +/// Fields of the `finalizers` and `finkeys` rows. +namespace finalizer_field { +constexpr char active_key_id[] = "active_key_id"; +constexpr char finalizer_key_count[] = "finalizer_key_count"; +constexpr char active_key_binary[] = "active_key_binary"; +constexpr char finalizer_name[] = "finalizer_name"; +constexpr char finalizer_key[] = "finalizer_key"; +} // namespace finalizer_field + const std::string finalizer_key_1 = "PUB_BLS_6j4Y3LfsRiBxY-DgvqrZNMCttHftBQPIWwDiN2CMhHWULjN1nGwM1O_nEEJefqwAG4X09n4Kdt4a1mfZ1ES1cLGjQo6uLLSloiVW4i9BUhMHU2nVujP1_U_9ihdI3egZ17N-iA"; const std::string finalizer_key_2 = "PUB_BLS_gtaOjOTa0NzDt8etBDqLoZKlfKTpTalcdfmbTJknLUJB2Fu4Cv-uoa8unF3bJ5kFewaCzf3tjYUyNE6CDSrwvYP5Nw47Y9oE9x4nqJWfJykMOoaI0kJz-GDrGN2nZdUAp5tWEg"; const std::string finalizer_key_3 = "PUB_BLS_CT8khvZYYZdObeIV9aTnd8fZ8bdaCL1UpSRyqNLZZM5sdrOSpOjDTAY2drTYGvQPVS21BhtD8acLJhqGyTfjqrWjyY5FTfqLdcligofSpa2lrG3FqKVNeUULR5QgcIMYga4vkQ"; @@ -531,7 +540,7 @@ BOOST_FIXTURE_TEST_CASE(reregister_deleted_finalizer_key_gets_new_id, finalizer_ BOOST_REQUIRE_EQUAL( success(), regproducer(alice) ); BOOST_REQUIRE_EQUAL( success(), register_finalizer_key(alice, finalizer_key_1, pop_1) ); - const uint64_t first_id = get_finalizer_info(alice)["active_key_id"].as_uint64(); + const uint64_t first_id = get_finalizer_info(alice)[finalizer_field::active_key_id].as_uint64(); // Deleting alice's only key erases her finalizer row along with the key row. BOOST_REQUIRE_EQUAL( success(), delete_finalizer_key(alice, finalizer_key_1) ); @@ -542,14 +551,14 @@ BOOST_FIXTURE_TEST_CASE(reregister_deleted_finalizer_key_gets_new_id, finalizer_ // The key is active again under a new id, and the old id stays vacant. const auto alice_info = get_finalizer_info(alice); - const uint64_t second_id = alice_info["active_key_id"].as_uint64(); + const uint64_t second_id = alice_info[finalizer_field::active_key_id].as_uint64(); BOOST_REQUIRE_GT( second_id, first_id ); - BOOST_REQUIRE_EQUAL( 1, alice_info["finalizer_key_count"].as_uint64() ); - BOOST_REQUIRE_EQUAL( finalizer_key_binary_1, alice_info["active_key_binary"].as_string() ); + BOOST_REQUIRE_EQUAL( 1, alice_info[finalizer_field::finalizer_key_count].as_uint64() ); + BOOST_REQUIRE_EQUAL( finalizer_key_binary_1, alice_info[finalizer_field::active_key_binary].as_string() ); const auto key_info = get_finalizer_key_info(second_id); - BOOST_REQUIRE_EQUAL( "alice1111111", key_info["finalizer_name"].as_string() ); - BOOST_REQUIRE_EQUAL( finalizer_key_1, key_info["finalizer_key"].as_string() ); + BOOST_REQUIRE_EQUAL( alice.to_string(), key_info[finalizer_field::finalizer_name].as_string() ); + BOOST_REQUIRE_EQUAL( finalizer_key_1, key_info[finalizer_field::finalizer_key].as_string() ); BOOST_REQUIRE( get_finalizer_key_info(first_id).is_null() ); } FC_LOG_AND_RETHROW() // reregister_deleted_finalizer_key_gets_new_id diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp index 1650dc23a8..e3baa73524 100644 --- a/contracts/tests/sysio.roa_tests.cpp +++ b/contracts/tests/sysio.roa_tests.cpp @@ -38,6 +38,13 @@ using sysio::chain::compute_table_id; constexpr account_name ROA = "sysio.roa"_n; constexpr uint64_t NETWORK_GEN = 0; +/// Weight fields of a `policies` row. +namespace policy_field { +constexpr char net_weight[] = "net_weight"; +constexpr char cpu_weight[] = "cpu_weight"; +constexpr char ram_weight[] = "ram_weight"; +} // namespace policy_field + /// 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. @@ -1509,31 +1516,34 @@ BOOST_FIXTURE_TEST_CASE( reducepolicy_wrong_symbol, sysio_roa_full_tester ) try BOOST_FIXTURE_TEST_CASE( policy_actions_reject_future_network_gen, sysio_roa_full_tester ) try { constexpr auto future_gen = NETWORK_GEN + 1; constexpr auto invalid_gen = "Invalid network generation."; + constexpr auto granted = "2.0000 SYS"; // per resource, by addpolicy + constexpr auto step = "1.0000 SYS"; // per resource, added by expandpolicy and taken back by reducepolicy + constexpr uint32_t unlocked_time_block = 0; // lets reducepolicy run at once const auto issuer = node_owners[2]; const auto user = create_newuser(issuer); produce_block(); BOOST_CHECK_EXCEPTION( - add_roa_policy(issuer, user, "2.0000 SYS", "2.0000 SYS", "2.0000 SYS", 0, future_gen), + add_roa_policy(issuer, user, granted, granted, granted, unlocked_time_block, future_gen), sysio_assert_message_exception, sysio_assert_message_is(invalid_gen)); - add_roa_policy(issuer, user, "2.0000 SYS", "2.0000 SYS", "2.0000 SYS", 0, NETWORK_GEN); + add_roa_policy(issuer, user, granted, granted, granted, unlocked_time_block, NETWORK_GEN); BOOST_CHECK_EXCEPTION( - expand_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", future_gen), + expand_roa_policy(issuer, user, step, step, step, future_gen), sysio_assert_message_exception, sysio_assert_message_is(invalid_gen)); - expand_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", NETWORK_GEN); + expand_roa_policy(issuer, user, step, step, step, NETWORK_GEN); BOOST_CHECK_EXCEPTION( - reduce_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", future_gen), + reduce_roa_policy(issuer, user, step, step, step, future_gen), sysio_assert_message_exception, sysio_assert_message_is(invalid_gen)); - reduce_roa_policy(issuer, user, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", NETWORK_GEN); + reduce_roa_policy(issuer, user, step, step, step, NETWORK_GEN); produce_block(); - // Only the current-generation calls moved the policy: 2 + 1 - 1. + // Only the current-generation calls moved the policy, and their expand and reduce cancel out. const auto p = get_policy(user, issuer); - BOOST_TEST(p["net_weight"].as_string() == "2.0000 SYS"); - BOOST_TEST(p["cpu_weight"].as_string() == "2.0000 SYS"); - BOOST_TEST(p["ram_weight"].as_string() == "2.0000 SYS"); + for (const char* field : {policy_field::net_weight, policy_field::cpu_weight, policy_field::ram_weight}) { + BOOST_TEST(p[field].as_string() == granted); + } } FC_LOG_AND_RETHROW() // ===== 2. expandpolicy validation ===== diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index 587b936ebd..af41eda19c 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -215,10 +215,12 @@ requirements; if governance raised a collateral minimum while you were parked, t Termination does not forfeit them either, but re-registering alone does not make you payable: termination credits your whole bond to your `claimremit` claim, and `regoperator` starts a new -`UNKNOWN` registration with no bond. Post every required bond again, and call `regproducer` if your -producer row was parked; the held blocks are paid at the first payout after that. Slashing is the -exception: a slashed producer can never be payable again, so any blocks it produced and was not yet -paid for are forfeited. +`UNKNOWN` registration with no bond. Cancel any withdrawal still queued from before termination with +`cancelwtdw`: the queue is per account, so it would reserve part of the new bond and be paid out of +it when it matures. Then post every required bond again, and call `regproducer` if your producer row +was parked; the held blocks are paid at the first payout after that. Slashing is the exception: a +slashed producer can never be payable again, so any blocks it produced and was not yet paid for are +forfeited. The one bound worth stating: a payout walks the ranking from the top and stops after a fixed number of rows, far below which no producer is paid anything anyway. Settling held blocks therefore