diff --git a/src/cryptonote_core/service_node_list.cpp b/src/cryptonote_core/service_node_list.cpp index 25a2f4fa9..b53db1b89 100644 --- a/src/cryptonote_core/service_node_list.cpp +++ b/src/cryptonote_core/service_node_list.cpp @@ -876,7 +876,7 @@ namespace service_nodes } } - bool service_node_list::state_t::process_key_image_unlock_tx(cryptonote::network_type nettype, uint64_t block_height, const cryptonote::transaction &tx) + bool service_node_list::state_t::process_key_image_unlock_tx(cryptonote::network_type nettype, cryptonote::hf hf_version, uint64_t block_height, const cryptonote::transaction &tx) { crypto::public_key snode_key; if (!cryptonote::get_service_node_pubkey_from_tx_extra(tx.extra, snode_key)) @@ -913,6 +913,18 @@ namespace service_nodes }); if (cit != contributor.locked_contributions.end()) { + if (hf_version >= hf::hf19) + { + if (cit->amount < service_nodes::SMALL_CONTRIBUTOR_THRESHOLD && (block_height - node_info.registration_height) < service_nodes::SMALL_CONTRIBUTOR_UNLOCK_TIMER) + { + LOG_PRINT_L1("Unlock TX: small contributor trying to unlock node before " + << std::to_string(service_nodes::SMALL_CONTRIBUTOR_UNLOCK_TIMER) + << " blocks have passed, rejected on height: " + << block_height << " for tx: " + << get_transaction_hash(tx)); + return false; + } + } // NOTE(oxen): This should be checked in blockchain check_tx_inputs already if (crypto::check_signature(service_nodes::generate_request_stake_unlock_hash(unlock.nonce), cit->key_image_pub_key, unlock.signature)) @@ -2166,7 +2178,7 @@ namespace service_nodes } else if (tx.type == cryptonote::txtype::key_image_unlock) { - process_key_image_unlock_tx(nettype, block_height, tx); + process_key_image_unlock_tx(nettype, hf_version, block_height, tx); } } diff --git a/src/cryptonote_core/service_node_list.h b/src/cryptonote_core/service_node_list.h index 2b213c419..03782da58 100644 --- a/src/cryptonote_core/service_node_list.h +++ b/src/cryptonote_core/service_node_list.h @@ -725,7 +725,7 @@ namespace service_nodes const cryptonote::block &block, const cryptonote::transaction& tx, const service_node_keys *my_keys); - bool process_key_image_unlock_tx(cryptonote::network_type nettype, uint64_t block_height, const cryptonote::transaction &tx); + bool process_key_image_unlock_tx(cryptonote::network_type nettype, cryptonote::hf hf_version, uint64_t block_height, const cryptonote::transaction &tx); payout get_block_leader() const; payout get_block_producer(uint8_t pulse_round) const; }; diff --git a/src/cryptonote_core/service_node_rules.h b/src/cryptonote_core/service_node_rules.h index ed43d9243..ad5e96389 100644 --- a/src/cryptonote_core/service_node_rules.h +++ b/src/cryptonote_core/service_node_rules.h @@ -245,6 +245,10 @@ namespace service_nodes { // (for pre-HF19 registrations). inline constexpr uint64_t MINIMUM_OPERATOR_PORTION = cryptonote::old::STAKING_PORTIONS / oxen::MAX_CONTRIBUTORS_V1; + // Small Stake prevented from unlocking stake until a certain number of blocks have passed + constexpr uint64_t SMALL_CONTRIBUTOR_UNLOCK_TIMER = cryptonote::BLOCKS_PER_DAY * 30; + constexpr uint64_t SMALL_CONTRIBUTOR_THRESHOLD = 3749; + static_assert(cryptonote::old::STAKING_PORTIONS != UINT64_MAX, "UINT64_MAX is used as the invalid value for failing to calculate the min_node_contribution"); // return: UINT64_MAX if (num_contributions > the max number of contributions), otherwise the amount in oxen atomic units uint64_t get_min_node_contribution (cryptonote::hf version, uint64_t staking_requirement, uint64_t total_reserved, size_t num_contributions); diff --git a/src/wallet/wallet2.cpp b/src/wallet/wallet2.cpp index caf0f4023..7cef46e89 100644 --- a/src/wallet/wallet2.cpp +++ b/src/wallet/wallet2.cpp @@ -8589,6 +8589,19 @@ wallet2::request_stake_unlock_result wallet2::can_request_stake_unlock(const cry return result; } + if (contribution.amount < service_nodes::SMALL_CONTRIBUTOR_THRESHOLD && (curr_height - node_info.registration_height) < service_nodes::SMALL_CONTRIBUTOR_UNLOCK_TIMER) + { + result.msg.append("You are requesting to unlock a stake of: "); + result.msg.append(cryptonote::print_money(contribution.amount)); + result.msg.append(" Oxen which is a small contributor stake.\nSmall contributors need to wait "); + result.msg.append(std::to_string(service_nodes::SMALL_CONTRIBUTOR_UNLOCK_TIMER)); + result.msg.append(" blocks before being allowed to unlock."); + result.msg.append("You will need to wait: "); + result.msg.append(std::to_string(service_nodes::SMALL_CONTRIBUTOR_UNLOCK_TIMER - (curr_height - node_info.registration_height))); + result.msg.append(" more blocks."); + return result; + } + result.msg.append("You are requesting to unlock a stake of: "); result.msg.append(cryptonote::print_money(contribution.amount)); result.msg.append(" Oxen from the service node network.\nThis will schedule the service node: "); diff --git a/tests/core_tests/chaingen.cpp b/tests/core_tests/chaingen.cpp index a7d232594..e3d1955c3 100644 --- a/tests/core_tests/chaingen.cpp +++ b/tests/core_tests/chaingen.cpp @@ -53,6 +53,7 @@ #include "cryptonote_basic/cryptonote_basic_impl.h" #include "cryptonote_basic/cryptonote_format_utils.h" #include "cryptonote_basic/miner.h" +#include "cryptonote_basic/tx_extra.h" #include "cryptonote_core/uptime_proof.h" #include "oxen_economy.h" #include "ringct/rctSigs.h" @@ -434,6 +435,13 @@ cryptonote::transaction oxen_chain_generator::create_and_add_staking_tx(const cr return result; } +cryptonote::transaction oxen_chain_generator::create_and_add_unlock_stake_tx(const crypto::public_key& pub_key, const cryptonote::account_base& src, const cryptonote::transaction& staking_tx, bool kept_by_block) +{ + cryptonote::transaction result = create_unlock_stake_tx(pub_key, staking_tx, src); + add_tx(result, true /*can_be_added_to_blockchain*/, "" /*fail_msg*/, kept_by_block); + return result; +} + oxen_blockchain_entry &oxen_chain_generator::create_and_add_next_block(const std::vector& txs, cryptonote::checkpoint_t const *checkpoint, bool can_be_added_to_blockchain, std::string const &fail_msg) { oxen_blockchain_entry entry = create_next_block(txs, checkpoint); @@ -542,6 +550,49 @@ cryptonote::transaction oxen_chain_generator::create_staking_tx(const crypto::pu return result; } +cryptonote::transaction oxen_chain_generator::create_unlock_stake_tx(const crypto::public_key& pub_key, const cryptonote::transaction& staking_tx, const cryptonote::account_base& src) const +{ + cryptonote::transaction result = {}; + cryptonote::tx_extra_tx_key_image_unlock unlock = {}; + unlock.nonce = cryptonote::tx_extra_tx_key_image_unlock::FAKE_NONCE; + + crypto::secret_key tx_secret_key{}; + crypto::public_key tx_public_key{}; + cryptonote::tx_extra_tx_key_image_proofs key_image_proofs; + if (!cryptonote::get_field_from_tx_extra(staking_tx.extra, key_image_proofs)) + throw("TX: Didn't have key image proofs in the tx_extra, rejected for tx"); + + if (!cryptonote::get_tx_secret_key_from_tx_extra(staking_tx.extra, tx_secret_key)) + throw("TX: Failed to get tx secret key from contribution"); + crypto::secret_key_to_public_key(tx_secret_key, tx_public_key); + + crypto::key_derivation recv_derivation{}; + crypto::generate_key_derivation(tx_public_key, src.get_keys().m_view_secret_key, recv_derivation); + crypto::secret_key output_secret_key{}; + crypto::public_key output_public_key{}; + for (size_t i = 0; i <= 1; i++) + { + crypto::derive_secret_key(recv_derivation, i, src.get_keys().m_spend_secret_key, output_secret_key); + crypto::secret_key_to_public_key(output_secret_key, output_public_key); + + crypto::generate_signature(cryptonote::tx_extra_tx_key_image_unlock::HASH, output_public_key, output_secret_key, unlock.signature); + crypto::generate_key_image(output_public_key, output_secret_key, unlock.key_image); + + if (unlock.key_image == key_image_proofs.proofs[0].key_image) + { + cryptonote::add_service_node_pubkey_to_tx_extra(result.extra, pub_key); + cryptonote::add_tx_key_image_unlock_to_tx_extra(result.extra, unlock); + } + } + + uint64_t new_height = get_block_height(top().block) + 1; + const auto new_hf_version = get_hf_version_at(new_height); + + result.type = cryptonote::txtype::key_image_unlock; + result.version = cryptonote::transaction::get_max_version_for_hf(new_hf_version); + return result; +} + cryptonote::transaction oxen_chain_generator::create_state_change_tx(service_nodes::new_state state, const crypto::public_key &pub_key, uint16_t reasons_all, uint16_t reasons_any, uint64_t height, const std::vector& voters, uint64_t fee) const { if (height == UINT64_MAX) diff --git a/tests/core_tests/chaingen.h b/tests/core_tests/chaingen.h index a9c770a38..9a457855d 100644 --- a/tests/core_tests/chaingen.h +++ b/tests/core_tests/chaingen.h @@ -1347,6 +1347,7 @@ public: cryptonote::tx_destination_entry change_addr{ change_amount, m_from.get_keys().m_account_address, false /*is_subaddr*/ }; bool result = cryptonote::construct_tx( m_from.get_keys(), sources, destinations, change_addr, m_extra, m_tx, m_unlock_time, m_tx_params); + return result; } }; @@ -1460,6 +1461,7 @@ struct oxen_chain_generator cryptonote::transaction create_and_add_state_change_tx(service_nodes::new_state state, const crypto::public_key& pub_key, uint16_t reasons_all, uint16_t reasons_any, uint64_t height = -1, const std::vector& voters = {}, uint64_t fee = 0, bool kept_by_block = false); cryptonote::transaction create_and_add_registration_tx(const cryptonote::account_base& src, const cryptonote::keypair& sn_keys = cryptonote::keypair{hw::get_device("default")}, bool kept_by_block = false); cryptonote::transaction create_and_add_staking_tx (const crypto::public_key &pub_key, const cryptonote::account_base &src, uint64_t amount, bool kept_by_block = false); + cryptonote::transaction create_and_add_unlock_stake_tx (const crypto::public_key& pub_key, const cryptonote::account_base& src, const cryptonote::transaction& staking_tx, bool kept_by_block = false); oxen_blockchain_entry &create_and_add_next_block (const std::vector& txs = {}, cryptonote::checkpoint_t const *checkpoint = nullptr, bool can_be_added_to_blockchain = true, std::string const &fail_msg = {}); // Same as create_and_add_tx, but also adds 95kB of junk into tx_extra to bloat up the tx size. cryptonote::transaction create_and_add_big_tx(const cryptonote::account_base& src, const cryptonote::account_public_address& dest, uint64_t amount, uint64_t junk_size = 95000, uint64_t fee = TESTS_DEFAULT_FEE, bool kept_by_block = false); @@ -1472,6 +1474,7 @@ struct oxen_chain_generator uint64_t fee = cryptonote::STAKING_FEE_BASIS, const std::vector& contributors = {}) const; cryptonote::transaction create_staking_tx (const crypto::public_key& pub_key, const cryptonote::account_base &src, uint64_t amount) const; + cryptonote::transaction create_unlock_stake_tx (const crypto::public_key& pub_key, const cryptonote::transaction& staking_tx, const cryptonote::account_base &src) const; cryptonote::transaction create_state_change_tx(service_nodes::new_state state, const crypto::public_key& pub_key, uint16_t reasons_all, uint16_t reasons_any, uint64_t height = -1, const std::vector& voters = {}, uint64_t fee = 0) const; cryptonote::checkpoint_t create_service_node_checkpoint(uint64_t block_height, size_t num_votes) const; diff --git a/tests/core_tests/chaingen_main.cpp b/tests/core_tests/chaingen_main.cpp index 112d6a852..e3b9590f6 100644 --- a/tests/core_tests/chaingen_main.cpp +++ b/tests/core_tests/chaingen_main.cpp @@ -136,6 +136,8 @@ int main(int argc, char* argv[]) GENERATE_AND_PLAY(oxen_service_nodes_insufficient_contribution); GENERATE_AND_PLAY(oxen_service_nodes_insufficient_contribution_HF18); GENERATE_AND_PLAY(oxen_service_nodes_sufficient_contribution_HF19); + GENERATE_AND_PLAY(oxen_service_nodes_small_contribution_early_withdrawal); + GENERATE_AND_PLAY(oxen_service_nodes_large_contribution_early_withdrawal); GENERATE_AND_PLAY(oxen_service_nodes_insufficient_operator_contribution_HF19); GENERATE_AND_PLAY(oxen_service_nodes_test_rollback); GENERATE_AND_PLAY(oxen_service_nodes_test_swarms_basic); diff --git a/tests/core_tests/oxen_tests.cpp b/tests/core_tests/oxen_tests.cpp index e846a7eb5..c59286289 100644 --- a/tests/core_tests/oxen_tests.cpp +++ b/tests/core_tests/oxen_tests.cpp @@ -3094,6 +3094,112 @@ bool oxen_service_nodes_sufficient_contribution_HF19::generate(std::vector &events) +{ + auto hard_forks = oxen_generate_hard_fork_table(); + oxen_chain_generator gen(events, hard_forks); + + gen.add_blocks_until_version(hard_forks.back().version); + gen.add_mined_money_unlock_blocks(); + + const auto alice = gen.add_account(); + const auto tx0 = gen.create_and_add_tx(gen.first_miner_, alice.get_keys().m_account_address, MK_COINS(101)); + gen.create_and_add_next_block({tx0}); + gen.add_transfer_unlock_blocks(); + + uint64_t operator_portions = cryptonote::old::STAKING_PORTIONS / oxen::MAX_CONTRIBUTORS_HF19 * (oxen::MAX_CONTRIBUTORS_HF19 - 1); + uint64_t staking_requirement = service_nodes::get_staking_requirement(cryptonote::network_type::FAKECHAIN, hard_forks.back().height); + uint64_t operator_amount = staking_requirement / oxen::MAX_CONTRIBUTORS_HF19 * (oxen::MAX_CONTRIBUTORS_HF19 - 1); + uint64_t single_contributed_amount = staking_requirement - operator_amount + 1; + cryptonote::keypair sn_keys{hw::get_device("default")}; + cryptonote::transaction register_tx = gen.create_registration_tx(gen.first_miner_, sn_keys, operator_portions); + gen.add_tx(register_tx); + gen.create_and_add_next_block({register_tx}); + + assert(single_contributed_amount != 0); + cryptonote::transaction stake = gen.create_and_add_staking_tx(sn_keys.pub, alice, single_contributed_amount); + gen.create_and_add_next_block({stake}); + + oxen_register_callback(events, "test_sufficient_stake_does_get_accepted", [sn_keys, staking_requirement](cryptonote::core &c, size_t ev_index) + { + DEFINE_TESTS_ERROR_CONTEXT("test_sufficient_stake_does_get_accepted"); + const auto sn_list = c.get_service_node_list_state({sn_keys.pub}); + CHECK_TEST_CONDITION(sn_list.size() == 1); + CHECK_TEST_CONDITION(sn_list[0].info->contributors.size() == 2); + + service_nodes::service_node_pubkey_info const &pubkey_info = sn_list[0]; + CHECK_EQ(pubkey_info.info->total_contributed, staking_requirement); + return true; + }); + + cryptonote::transaction unstake = gen.create_unlock_stake_tx(sn_keys.pub, stake, alice); + gen.create_and_add_next_block({unstake}, nullptr, false, "Small contributor should not be able to withdraw early"); + + oxen_register_callback(events, "test_unlock_does_not_get_accepted", [sn_keys](cryptonote::core &c, size_t ev_index) + { + DEFINE_TESTS_ERROR_CONTEXT("test_unlock_does_not_get_accepted"); + const auto sn_list = c.get_service_node_list_state({sn_keys.pub}); + CHECK_TEST_CONDITION(sn_list.size() == 1); + CHECK_TEST_CONDITION(sn_list[0].info->requested_unlock_height == 0); + return true; + }); + + return true; +} + +bool oxen_service_nodes_large_contribution_early_withdrawal::generate(std::vector &events) +{ + auto hard_forks = oxen_generate_hard_fork_table(); + oxen_chain_generator gen(events, hard_forks); + + gen.add_blocks_until_version(hard_forks.back().version); + gen.add_mined_money_unlock_blocks(); + + const auto alice = gen.add_account(); + const auto tx0 = gen.create_and_add_tx(gen.first_miner_, alice.get_keys().m_account_address, MK_COINS(101)); + gen.create_and_add_next_block({tx0}); + gen.add_transfer_unlock_blocks(); + + uint64_t operator_portions = cryptonote::old::STAKING_PORTIONS / oxen::MAX_CONTRIBUTORS_HF19 * (oxen::MAX_CONTRIBUTORS_HF19 - 4); + uint64_t staking_requirement = service_nodes::get_staking_requirement(cryptonote::network_type::FAKECHAIN, hard_forks.back().height); + uint64_t operator_amount = staking_requirement / oxen::MAX_CONTRIBUTORS_HF19 * (oxen::MAX_CONTRIBUTORS_HF19- 4); + uint64_t single_contributed_amount = staking_requirement - operator_amount + 1; + cryptonote::keypair sn_keys{hw::get_device("default")}; + cryptonote::transaction register_tx = gen.create_registration_tx(gen.first_miner_, sn_keys, operator_portions); + gen.add_tx(register_tx); + gen.create_and_add_next_block({register_tx}); + + assert(single_contributed_amount != 0); + cryptonote::transaction stake = gen.create_and_add_staking_tx(sn_keys.pub, alice, single_contributed_amount); + gen.create_and_add_next_block({stake}); + + oxen_register_callback(events, "test_sufficient_stake_does_get_accepted", [sn_keys, staking_requirement](cryptonote::core &c, size_t ev_index) + { + DEFINE_TESTS_ERROR_CONTEXT("test_sufficient_stake_does_get_accepted"); + const auto sn_list = c.get_service_node_list_state({sn_keys.pub}); + CHECK_TEST_CONDITION(sn_list.size() == 1); + CHECK_TEST_CONDITION(sn_list[0].info->contributors.size() == 2); + + service_nodes::service_node_pubkey_info const &pubkey_info = sn_list[0]; + CHECK_EQ(pubkey_info.info->total_contributed, staking_requirement); + return true; + }); + + cryptonote::transaction unstake = gen.create_and_add_unlock_stake_tx(sn_keys.pub, alice, stake); + gen.create_and_add_next_block({unstake}); + + oxen_register_callback(events, "test_unlock_does_get_accepted", [sn_keys](cryptonote::core &c, size_t ev_index) + { + DEFINE_TESTS_ERROR_CONTEXT("test_unlock_does_get_accepted"); + const auto sn_list = c.get_service_node_list_state({sn_keys.pub}); + CHECK_TEST_CONDITION(sn_list.size() == 1); + CHECK_TEST_CONDITION(sn_list[0].info->requested_unlock_height > 0); + return true; + }); + + return true; +} + bool oxen_service_nodes_insufficient_operator_contribution_HF19::generate(std::vector &events) { auto hard_forks = oxen_generate_hard_fork_table(cryptonote::hf::hf19_reward_batching); diff --git a/tests/core_tests/oxen_tests.h b/tests/core_tests/oxen_tests.h index 8f6c282d2..c62db29eb 100644 --- a/tests/core_tests/oxen_tests.h +++ b/tests/core_tests/oxen_tests.h @@ -79,6 +79,8 @@ struct oxen_service_nodes_gen_nodes struct oxen_service_nodes_insufficient_contribution : public test_chain_unit_base { bool generate(std::vector& events); }; struct oxen_service_nodes_insufficient_contribution_HF18 : public test_chain_unit_base { bool generate(std::vector& events); }; struct oxen_service_nodes_sufficient_contribution_HF19 : public test_chain_unit_base { bool generate(std::vector& events); }; +struct oxen_service_nodes_small_contribution_early_withdrawal : public test_chain_unit_base { bool generate(std::vector& events); }; +struct oxen_service_nodes_large_contribution_early_withdrawal : public test_chain_unit_base { bool generate(std::vector& events); }; struct oxen_service_nodes_insufficient_operator_contribution_HF19 : public test_chain_unit_base { bool generate(std::vector& events); }; struct oxen_service_nodes_test_rollback : public test_chain_unit_base { bool generate(std::vector& events); }; struct oxen_service_nodes_test_swarms_basic : public test_chain_unit_base { bool generate(std::vector& events); };