diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index 86cd477..f96b0e4 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -52,5 +52,6 @@ add_subdirectory(eosio.system) add_subdirectory(eosio.token) add_subdirectory(eosio.wrap) add_subdirectory(eosio.fees) +add_subdirectory(eosio.bpay) add_subdirectory(test_contracts) diff --git a/contracts/eosio.bpay/CMakeLists.txt b/contracts/eosio.bpay/CMakeLists.txt new file mode 100644 index 0000000..09fe613 --- /dev/null +++ b/contracts/eosio.bpay/CMakeLists.txt @@ -0,0 +1,14 @@ +add_contract(eosio.bpay eosio.bpay ${CMAKE_CURRENT_SOURCE_DIR}/src/eosio.bpay.cpp) + +target_include_directories(eosio.bpay PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/../eosio.system/include + ${CMAKE_CURRENT_SOURCE_DIR}/../eosio.token/include) + +set_target_properties(eosio.bpay + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}") + +configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/ricardian/eosio.bpay.contracts.md.in ${CMAKE_CURRENT_BINARY_DIR}/ricardian/eosio.bpay.contracts.md @ONLY ) + +target_compile_options( eosio.bpay PUBLIC -R${CMAKE_CURRENT_SOURCE_DIR}/ricardian -R${CMAKE_CURRENT_BINARY_DIR}/ricardian ) diff --git a/contracts/eosio.bpay/include/eosio.bpay/eosio.bpay.hpp b/contracts/eosio.bpay/include/eosio.bpay/eosio.bpay.hpp new file mode 100644 index 0000000..042ebd3 --- /dev/null +++ b/contracts/eosio.bpay/include/eosio.bpay/eosio.bpay.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +using namespace std; + +namespace eosio { + /** + * The `eosio.bpay` contract handles system bpay distribution. + */ + class [[eosio::contract("eosio.bpay")]] bpay : public contract { + public: + using contract::contract; + + /** + * ## TABLE `rewards` + * + * @param owner - block producer owner account + * @param quantity - reward quantity in EOS + * + * ### example + * + * ```json + * [ + * { + * "owner": "alice", + * "quantity": "8.800 EOS" + * } + * ] + * ``` + */ + struct [[eosio::table("rewards")]] rewards_row { + name owner; + asset quantity; + + uint64_t primary_key() const { return owner.value; } + }; + typedef eosio::multi_index< "rewards"_n, rewards_row > rewards_table; + + /** + * Claim rewards for a block producer. + * + * @param owner - block producer owner account + */ + [[eosio::action]] + void claimrewards( const name owner); + + [[eosio::on_notify("eosio.token::transfer")]] + void on_transfer( const name from, const name to, const asset quantity, const string memo ); + + private: + }; +} /// namespace eosio diff --git a/contracts/eosio.bpay/ricardian/eosio.bpay.contracts.md.in b/contracts/eosio.bpay/ricardian/eosio.bpay.contracts.md.in new file mode 100644 index 0000000..85da666 --- /dev/null +++ b/contracts/eosio.bpay/ricardian/eosio.bpay.contracts.md.in @@ -0,0 +1,10 @@ +

claimrewards

+ +--- +spec_version: "0.2.0" +title: Claim Rewards +summary: '{{nowrap owner}} claims block production rewards' +icon: @ICON_BASE_URL@/@MULTISIG_ICON_URI@ +--- + +{{owner}} claims block production rewards accumulated through network fees. diff --git a/contracts/eosio.bpay/src/eosio.bpay.cpp b/contracts/eosio.bpay/src/eosio.bpay.cpp new file mode 100644 index 0000000..fee02ce --- /dev/null +++ b/contracts/eosio.bpay/src/eosio.bpay.cpp @@ -0,0 +1,73 @@ +#include + +namespace eosio { + +void bpay::claimrewards( const name owner ) { + require_auth( owner ); + + rewards_table _rewards( get_self(), get_self().value ); + + const auto& row = _rewards.get( owner.value, "no rewards to claim" ); + + eosio::token::transfer_action transfer( "eosio.token"_n, { get_self(), "active"_n }); + transfer.send( get_self(), owner, row.quantity, "producer block pay" ); + + _rewards.erase(row); +} + +void bpay::on_transfer( const name from, const name to, const asset quantity, const string memo ) { + if (from == get_self() || to != get_self()) { + return; + } + + // ignore eosio system incoming transfers (caused by bpay income transfers eosio => eosio.bpay => producer) + if ( from == "eosio"_n) return; + + symbol system_symbol = eosiosystem::system_contract::get_core_symbol(); + + check( quantity.symbol == system_symbol, "only core token allowed" ); + + rewards_table _rewards( get_self(), get_self().value ); + eosiosystem::producers_table _producers( "eosio"_n, "eosio"_n.value ); + + eosiosystem::global_state_singleton _global("eosio"_n, "eosio"_n.value); + check( _global.exists(), "global state does not exist"); + uint16_t producer_count = _global.get().last_producer_schedule_size; + + asset reward = quantity / producer_count; + + // get producer with the most votes + // using `by_votes` secondary index + auto idx = _producers.get_index<"prototalvote"_n>(); + auto prod = idx.begin(); + + // get top n producers by vote, excluding inactive + std::vector top_producers; + while (true) { + if (prod == idx.end()) break; + if (prod->is_active == false) continue; + + top_producers.push_back(prod->owner); + + if (top_producers.size() == producer_count) break; + + prod++; + } + + // distribute rewards to top producers + for (auto producer : top_producers) { + auto row = _rewards.find( producer.value ); + if (row == _rewards.end()) { + _rewards.emplace( get_self(), [&](auto& row) { + row.owner = producer; + row.quantity = reward; + }); + } else { + _rewards.modify(row, get_self(), [&](auto& row) { + row.quantity += reward; + }); + } + } +} + +} /// namespace eosio diff --git a/tests/contracts.hpp.in b/tests/contracts.hpp.in index 7280e79..cce660d 100644 --- a/tests/contracts.hpp.in +++ b/tests/contracts.hpp.in @@ -15,6 +15,8 @@ struct contracts { static std::vector wrap_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/eosio.wrap/eosio.wrap.abi"); } static std::vector bios_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/eosio.bios/eosio.bios.wasm"); } static std::vector bios_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/eosio.bios/eosio.bios.abi"); } + static std::vector bpay_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/eosio.bpay/eosio.bpay.wasm"); } + static std::vector bpay_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/eosio.bpay/eosio.bpay.abi"); } struct util { static std::vector reject_all_wasm() { return read_wasm("${CMAKE_CURRENT_SOURCE_DIR}/test_contracts/reject_all.wasm"); } diff --git a/tests/eosio.bpay_tests.cpp b/tests/eosio.bpay_tests.cpp new file mode 100644 index 0000000..19beb4f --- /dev/null +++ b/tests/eosio.bpay_tests.cpp @@ -0,0 +1,101 @@ +#include "eosio.system_tester.hpp" + +using namespace eosio_system; + +BOOST_AUTO_TEST_SUITE(eosio_bpay_tests); + +account_name voter = "alice1111111"_n; +account_name standby = "bp.standby"_n; +account_name inactive = "bp.inactive"_n; +account_name fees = "eosio.fees"_n; +account_name bpay = "eosio.bpay"_n; + +BOOST_FIXTURE_TEST_CASE( bpay_test, eosio_system_tester ) try { + + + // Transferring some tokens to the fees account + // since tokens from eosio will not be directly accepted as contributions to + // the bpay contract + transfer( config::system_account_name, fees, core_sym::from_string("100000.0000"), config::system_account_name ); + + + // Setting up the producers, standby and inactive producers, and voting them in + setup_producer_accounts({standby, inactive}); + auto producer_names = active_and_vote_producers(); + + BOOST_REQUIRE_EQUAL( success(), regproducer(standby) ); + BOOST_REQUIRE_EQUAL( success(), regproducer(inactive) ); + vector top_producers_and_inactive = {inactive}; + top_producers_and_inactive.insert( top_producers_and_inactive.end(), producer_names.begin(), producer_names.begin()+21 ); + + BOOST_REQUIRE_EQUAL( success(), vote( voter, top_producers_and_inactive ) ); + produce_blocks( 250 ); + + + BOOST_REQUIRE_EQUAL( 0, get_producer_info( standby )["unpaid_blocks"].as() ); + BOOST_REQUIRE_EQUAL( get_producer_info( producer_names[0] )["unpaid_blocks"].as() > 0, true ); + + // TODO: Check nothing happened here, no rewards since it comes from system account + + asset rewards_sent = core_sym::from_string("1000.0000"); + transfer( fees, bpay, rewards_sent, fees); + + // rewards / 21 + asset balance_per_producer = core_sym::from_string("47.6190"); + + auto rewards = get_bpay_rewards(producer_names[0]); + + // bp.inactive is still active, so should be included in the rewards + BOOST_REQUIRE_EQUAL( get_bpay_rewards(inactive)["quantity"].as(), balance_per_producer ); + // Random sample + BOOST_REQUIRE_EQUAL( get_bpay_rewards(producer_names[11])["quantity"].as(), balance_per_producer ); + + + // Deactivating a producer + BOOST_REQUIRE_EQUAL( success(), push_action(config::system_account_name, "rmvproducer"_n, mvo()("producer", inactive) ) ); + BOOST_REQUIRE_EQUAL( false, get_producer_info( inactive )["is_active"].as() ); + + transfer( fees, bpay, rewards_sent, fees); + BOOST_REQUIRE_EQUAL( get_bpay_rewards(inactive)["quantity"].as(), balance_per_producer ); + BOOST_REQUIRE_EQUAL( get_bpay_rewards(producer_names[11])["quantity"].as(), core_sym::from_string("95.2380") ); + + // BP should be able to claim their rewards + { + auto prod = producer_names[11]; + BOOST_REQUIRE_EQUAL( core_sym::from_string("0.0000"), get_balance( prod ) ); + BOOST_REQUIRE_EQUAL( success(), bpay_claimrewards( prod ) ); + BOOST_REQUIRE_EQUAL( core_sym::from_string("95.2380"), get_balance( prod ) ); + BOOST_REQUIRE_EQUAL( true, get_bpay_rewards(prod).is_null() ); + + // should still have rewards for another producer + BOOST_REQUIRE_EQUAL( get_bpay_rewards(producer_names[10])["quantity"].as(), core_sym::from_string("95.2380") ); + } + + // Should be able to claim rewards from a producer that is no longer active + { + BOOST_REQUIRE_EQUAL( core_sym::from_string("0.0000"), get_balance( inactive ) ); + BOOST_REQUIRE_EQUAL( success(), bpay_claimrewards( inactive ) ); + BOOST_REQUIRE_EQUAL( core_sym::from_string("47.6190"), get_balance( inactive ) ); + BOOST_REQUIRE_EQUAL( true, get_bpay_rewards(inactive).is_null() ); + } + + // Should not have rewards for a producer that was never active + { + BOOST_REQUIRE_EQUAL( true, get_bpay_rewards(standby).is_null() ); + BOOST_REQUIRE_EQUAL( core_sym::from_string("0.0000"), get_balance( standby ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("no rewards to claim"), bpay_claimrewards( standby ) ); + BOOST_REQUIRE_EQUAL( core_sym::from_string("0.0000"), get_balance( standby ) ); + } + + // Tokens transferred from the eosio account should be ignored + { + transfer( config::system_account_name, bpay, rewards_sent, config::system_account_name ); + BOOST_REQUIRE_EQUAL( get_bpay_rewards(producer_names[10])["quantity"].as(), core_sym::from_string("95.2380") ); + } + + + +} FC_LOG_AND_RETHROW() + + +BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file diff --git a/tests/eosio.system_schedules_tests.cpp b/tests/eosio.system_schedules_tests.cpp index 17fd69e..05746db 100644 --- a/tests/eosio.system_schedules_tests.cpp +++ b/tests/eosio.system_schedules_tests.cpp @@ -1,5 +1,4 @@ #include - #include "eosio.system_tester.hpp" using namespace eosio_system; diff --git a/tests/eosio.system_tester.hpp b/tests/eosio.system_tester.hpp index 8efa57e..65142e6 100644 --- a/tests/eosio.system_tester.hpp +++ b/tests/eosio.system_tester.hpp @@ -36,8 +36,8 @@ class eosio_system_tester : public TESTER { produce_blocks( 100 ); + set_code( "eosio.token"_n, contracts::token_wasm()); - set_code( "eosio.fees"_n, contracts::fees_wasm()); set_abi( "eosio.token"_n, contracts::token_abi().data() ); { const auto& accnt = control->db().get( "eosio.token"_n ); @@ -45,6 +45,17 @@ class eosio_system_tester : public TESTER { BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, abi), true); token_abi_ser.set_abi(abi, abi_serializer::create_yield_function(abi_serializer_max_time)); } + + set_code( "eosio.fees"_n, contracts::fees_wasm()); + + set_code( "eosio.bpay"_n, contracts::bpay_wasm()); + set_abi( "eosio.bpay"_n, contracts::bpay_abi().data() ); + { + const auto& accnt = control->db().get( "eosio.bpay"_n ); + abi_def abi; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, abi), true); + bpay_abi_ser.set_abi(abi, abi_serializer::create_yield_function(abi_serializer_max_time)); + } } void create_core_token( symbol core_symbol = symbol{CORE_SYM} ) { @@ -1508,8 +1519,25 @@ class eosio_system_tester : public TESTER { return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "schedules_info", data, abi_serializer::create_yield_function(abi_serializer_max_time) ); } + + + action_result bpay_claimrewards( const account_name owner ) { + action act; + act.account = "eosio.bpay"_n; + act.name = "claimrewards"_n; + act.data = abi_ser.variant_to_binary( bpay_abi_ser.get_action_type("claimrewards"_n), mvo()("owner", owner), abi_serializer::create_yield_function(abi_serializer_max_time) ); + + return base_tester::push_action( std::move(act), owner.to_uint64_t() ); + } + + fc::variant get_bpay_rewards( account_name producer ) { + vector data = get_row_by_account( "eosio.bpay"_n, "eosio.bpay"_n, "rewards"_n, producer ); + return data.empty() ? fc::variant() : bpay_abi_ser.binary_to_variant( "rewards_row", data, abi_serializer::create_yield_function(abi_serializer_max_time) ); + } + abi_serializer abi_ser; abi_serializer token_abi_ser; + abi_serializer bpay_abi_ser; }; inline fc::mutable_variant_object voter( account_name acct ) {