diff --git a/plugins/chain_api_plugin/chain.swagger.yaml b/plugins/chain_api_plugin/chain.swagger.yaml index 06a02946f8..02f1f30825 100644 --- a/plugins/chain_api_plugin/chain.swagger.yaml +++ b/plugins/chain_api_plugin/chain.swagger.yaml @@ -871,6 +871,14 @@ paths: description: List of authorizing keys items: $ref: "https://docs.eosnetwork.com/openapi/v2.0/PublicKey.yaml" + limit: + type: integer + maximum: 1000 + description: Maximum number of account permission authorities to return per page, capped by the server at 1000. Supplying this field enables pagination. When omitted, the server preserves legacy behavior by attempting to return the complete result and failing if the request deadline expires. A value of zero returns no accounts and sets more to indicate whether any match exists. + cursor: + type: string + default: "" + description: Opaque continuation cursor returned as next_cursor by a previous paginated request. Limit is required and the accounts and keys inputs must be repeated unchanged when continuing a query. responses: "200": description: OK @@ -881,6 +889,8 @@ paths: description: Result containing a list of accounts which are authorized, in whole or part, by the provided accounts and keys required: - accounts + - more + - next_cursor properties: accounts: type: array @@ -909,6 +919,12 @@ paths: threshold: type: "integer" description: the sum of weights that must be met or exceeded to satisfy the permission + more: + type: boolean + description: True when a paginated request has additional matching account permission authorities beyond the current page. + next_cursor: + type: string + description: Opaque cursor to pass as cursor with a limit in the next request when more is true. Empty for an unpaginated or complete response and for limit zero, which does not advance the query. /get_transaction_status: post: description: Attempts to get current blockchain state and, if available, transaction information given the transaction id. For query to work, the transaction finality status feature must be enabled by configuring the chain plugin with the config option '--transaction-finality-status-max-storage-size-gb' in nodeos. diff --git a/plugins/chain_plugin/account_query_db.cpp b/plugins/chain_plugin/account_query_db.cpp index a4b26b3e22..1c6f78eb3d 100644 --- a/plugins/chain_plugin/account_query_db.cpp +++ b/plugins/chain_plugin/account_query_db.cpp @@ -4,6 +4,9 @@ #include #include +#include +#include + #include #include #include @@ -13,6 +16,7 @@ #include #include +#include #include using namespace eosio; @@ -20,6 +24,22 @@ using namespace eosio::chain::literals; using namespace boost::multi_index; using namespace boost::bimaps; +namespace eosio::chain_apis { + struct account_query_cursor { + static constexpr uint8_t current_version = 1; + + uint8_t version = current_version; + std::variant input; + std::optional last_authorizing_account; + chain::weight_type weight = 0; + chain::name owner; + chain::name permission; + }; +} + +FC_REFLECT( eosio::chain_apis::account_query_cursor, + (version)(input)(last_authorizing_account)(weight)(owner)(permission) ) + namespace { /** * Structure to hold indirect reference to a `property_object` via {owner,name} as well as a non-standard @@ -81,13 +101,16 @@ namespace { struct weighted { T value; chain::weight_type weight; + chain::name owner; + chain::name permission; static weighted lower_bound_for( const T& value ) { - return {value, std::numeric_limits::min()}; + return {value, std::numeric_limits::min(), {}, {}}; } static weighted upper_bound_for( const T& value ) { - return {value, std::numeric_limits::max()}; + const auto max_name = chain::name{std::numeric_limits::max()}; + return {value, std::numeric_limits::max(), max_name, max_name}; } }; @@ -99,6 +122,22 @@ namespace { return {}; } } + + std::string encode_cursor( const eosio::chain_apis::account_query_cursor& cursor ) { + const auto packed = fc::raw::pack(cursor); + return fc::base64url_encode(packed.data(), packed.size()); + } + + eosio::chain_apis::account_query_cursor decode_cursor( const std::string& encoded ) { + try { + auto cursor = fc::raw::unpack(fc::base64url_decode(encoded)); + EOS_ASSERT(cursor.version == eosio::chain_apis::account_query_cursor::current_version, + eosio::chain::invalid_http_request, "Unsupported cursor version"); + return cursor; + } catch (...) { + EOS_THROW(eosio::chain::invalid_http_request, "Invalid cursor"); + } + } } namespace std { @@ -118,7 +157,8 @@ namespace std { template struct less> { bool operator()( const weighted& lhs, const weighted& rhs ) const { - return std::tie(lhs.value, lhs.weight) < std::tie(rhs.value, rhs.weight); + return std::tie(lhs.value, lhs.weight, lhs.owner, lhs.permission) < + std::tie(rhs.value, rhs.weight, rhs.owner, rhs.permission); } }; @@ -171,12 +211,12 @@ namespace eosio::chain_apis { void add_to_bimaps( const permission_info& pi, const chain::permission_object& po ) { // For each account, add this permission info's non-owning reference to the bimap for accounts for (const auto& a : po.auth.accounts) { - name_bimap.insert(name_bimap_t::value_type {{a.permission, a.weight}, pi}); + name_bimap.insert(name_bimap_t::value_type {{a.permission, a.weight, pi.owner, pi.name}, pi}); } // for each key, add this permission info's non-owning reference to the bimap for keys for (const auto& k: po.auth.keys) { - key_bimap.insert(key_bimap_t::value_type {{k.key.to_public_key(), k.weight}, pi}); + key_bimap.insert(key_bimap_t::value_type {{k.key.to_public_key(), k.weight, pi.owner, pi.name}, pi}); } } @@ -419,21 +459,78 @@ namespace eosio::chain_apis { } account_query_db::get_accounts_by_authorizers_result - get_accounts_by_authorizers( const account_query_db::get_accounts_by_authorizers_params& args) const { + get_accounts_by_authorizers( const account_query_db::get_accounts_by_authorizers_params& args, + const fc::time_point& deadline) const { std::shared_lock read_lock(rw_mutex); using result_t = account_query_db::get_accounts_by_authorizers_result; result_t result; + EOS_ASSERT(args.cursor.empty() || args.limit.has_value(), chain::invalid_http_request, + "A limit is required when continuing with a cursor"); + // An omitted limit preserves the legacy complete-or-timeout behavior. An explicit limit opts into + // cursor pagination and is clamped to the server's maximum page size. + const bool paginated = args.limit.has_value(); + const auto limit = args.limit + ? static_cast(std::min(*args.limit, account_query_db::max_page_results)) + : std::numeric_limits::max(); // deduplicate inputs - auto account_set = std::set(args.accounts.begin(), args.accounts.end()); - const auto key_set = std::set(args.keys.begin(), args.keys.end()); + std::set account_set; + for (const auto& account : args.accounts) { + FC_CHECK_DEADLINE(deadline); + account_set.emplace(account); + } + + std::set key_set; + for (const auto& key : args.keys) { + FC_CHECK_DEADLINE(deadline); + key_set.emplace(key); + } + + std::optional cursor; + if (!args.cursor.empty()) { + cursor = decode_cursor(args.cursor); + } + + auto account_itr = account_set.begin(); + auto key_itr = key_set.begin(); + bool resume_account = false; + bool resume_key = false; + + if (cursor) { + if (std::holds_alternative(cursor->input)) { + const auto& input = std::get(cursor->input); + account_itr = account_set.find(input); + EOS_ASSERT(account_itr != account_set.end(), chain::invalid_http_request, + "Cursor account is not present in the request"); + EOS_ASSERT(cursor->last_authorizing_account.has_value(), chain::invalid_http_request, + "Invalid account cursor"); + const auto& last_authorizer = *cursor->last_authorizing_account; + EOS_ASSERT(last_authorizer.actor == input.actor && + (input.permission.empty() || last_authorizer.permission == input.permission), + chain::invalid_http_request, "Invalid account cursor"); + resume_account = true; + } else { + const auto& input = std::get(cursor->input); + key_itr = key_set.find(input); + EOS_ASSERT(key_itr != key_set.end(), chain::invalid_http_request, + "Cursor key is not present in the request"); + account_itr = account_set.end(); + resume_key = true; + } + } + + std::string last_cursor; /** * Add a range of results */ - auto push_results = [&result](const auto& begin, const auto& end) { - for (auto itr = begin; itr != end; ++itr) { + auto push_results = [&result, &last_cursor, paginated, limit, &deadline](const auto& begin, const auto& end, + const auto& input) { + FC_CHECK_DEADLINE(deadline); + auto itr = begin; + for (; itr != end && result.accounts.size() < limit; ++itr) { + FC_CHECK_DEADLINE(deadline); const auto& pi = itr->second.get(); const auto& authorizer = itr->first.value; auto weight = itr->first.weight; @@ -446,33 +543,74 @@ namespace eosio::chain_apis { weight, pi.threshold }); + + if (paginated) { + account_query_cursor next; + next.input = input; + if constexpr (std::is_same_v, chain::permission_level>) { + next.last_authorizing_account = authorizer; + } + next.weight = weight; + next.owner = pi.owner; + next.permission = pi.name; + last_cursor = encode_cursor(next); + } + } + if (itr != end) { + result.more = true; + result.next_cursor = last_cursor; + return false; } + return true; }; - - for (const auto& a: account_set) { + for (; account_itr != account_set.end(); ++account_itr) { + FC_CHECK_DEADLINE(deadline); + const auto& a = *account_itr; if (a.permission.empty()) { // empty permission is a wildcard // construct a range between the lower bound of the given account and the lower bound of the // next possible account name - const auto begin = name_bimap.left.lower_bound(weighted::lower_bound_for({a.actor, a.permission})); + auto begin = name_bimap.left.lower_bound(weighted::lower_bound_for({a.actor, a.permission})); const auto next_account_name = chain::name(a.actor.to_uint64_t() + 1); const auto end = name_bimap.left.lower_bound(weighted::lower_bound_for({next_account_name, a.permission})); - push_results(begin, end); + if (resume_account) { + const auto& last_authorizer = *cursor->last_authorizing_account; + begin = name_bimap.left.upper_bound(weighted{ + last_authorizer, cursor->weight, cursor->owner, cursor->permission}); + resume_account = false; + } + if (!push_results(begin, end, a)) + return result; } else { // construct a range of all possible weights for an account/permission pair const auto p = chain::permission_level{a.actor, a.permission}; - const auto begin = name_bimap.left.lower_bound(weighted::lower_bound_for(p)); + auto begin = name_bimap.left.lower_bound(weighted::lower_bound_for(p)); const auto end = name_bimap.left.upper_bound(weighted::upper_bound_for(p)); - push_results(begin, end); + if (resume_account) { + const auto& last_authorizer = *cursor->last_authorizing_account; + begin = name_bimap.left.upper_bound(weighted{ + last_authorizer, cursor->weight, cursor->owner, cursor->permission}); + resume_account = false; + } + if (!push_results(begin, end, a)) + return result; } } - for (const auto& k: key_set) { + for (; key_itr != key_set.end(); ++key_itr) { + FC_CHECK_DEADLINE(deadline); + const auto& k = *key_itr; // construct a range of all possible weights for a key - const auto begin = key_bimap.left.lower_bound(weighted::lower_bound_for(k)); + auto begin = key_bimap.left.lower_bound(weighted::lower_bound_for(k)); const auto end = key_bimap.left.upper_bound(weighted::upper_bound_for(k)); - push_results(begin, end); + if (resume_key) { + begin = key_bimap.left.upper_bound(weighted{ + k, cursor->weight, cursor->owner, cursor->permission}); + resume_key = false; + } + if (!push_results(begin, end, k)) + return result; } return result; @@ -528,8 +666,9 @@ namespace eosio::chain_apis { } FC_LOG_AND_DROP(("ACCOUNT DB commit_block ERROR")); } - account_query_db::get_accounts_by_authorizers_result account_query_db::get_accounts_by_authorizers( const account_query_db::get_accounts_by_authorizers_params& args) const { - return _impl->get_accounts_by_authorizers(args); + account_query_db::get_accounts_by_authorizers_result account_query_db::get_accounts_by_authorizers( + const account_query_db::get_accounts_by_authorizers_params& args, const fc::time_point& deadline) const { + return _impl->get_accounts_by_authorizers(args, deadline); } } diff --git a/plugins/chain_plugin/chain_plugin.cpp b/plugins/chain_plugin/chain_plugin.cpp index b2b713acb2..17469c0014 100644 --- a/plugins/chain_plugin/chain_plugin.cpp +++ b/plugins/chain_plugin/chain_plugin.cpp @@ -2636,10 +2636,10 @@ read_only::get_transaction_id_result read_only::get_transaction_id( const read_o account_query_db::get_accounts_by_authorizers_result -read_only::get_accounts_by_authorizers( const account_query_db::get_accounts_by_authorizers_params& args, const fc::time_point& ) const +read_only::get_accounts_by_authorizers( const account_query_db::get_accounts_by_authorizers_params& args, const fc::time_point& deadline ) const { EOS_ASSERT(aqdb.has_value(), plugin_config_exception, "Account Queries being accessed when not enabled"); - return aqdb->get_accounts_by_authorizers(args); + return aqdb->get_accounts_by_authorizers(args, deadline); } namespace detail { diff --git a/plugins/chain_plugin/include/eosio/chain_plugin/account_query_db.hpp b/plugins/chain_plugin/include/eosio/chain_plugin/account_query_db.hpp index 2b360e28db..1586637f4d 100644 --- a/plugins/chain_plugin/include/eosio/chain_plugin/account_query_db.hpp +++ b/plugins/chain_plugin/include/eosio/chain_plugin/account_query_db.hpp @@ -10,6 +10,7 @@ namespace eosio::chain_apis { */ class account_query_db { public: + static constexpr uint32_t max_page_results = 1000; /** * Instantiate a new account query DB from the given chain controller @@ -54,6 +55,8 @@ namespace eosio::chain_apis { std::vector accounts; std::vector keys; + std::optional limit; + std::string cursor; }; /** @@ -70,6 +73,8 @@ namespace eosio::chain_apis { }; std::vector accounts; + bool more = false; + std::string next_cursor; }; /** * Given a set of account names and public keys, find all account permission authorities that are, in part or whole, @@ -78,7 +83,9 @@ namespace eosio::chain_apis { * @param args * @return */ - get_accounts_by_authorizers_result get_accounts_by_authorizers( const get_accounts_by_authorizers_params& args) const; + get_accounts_by_authorizers_result get_accounts_by_authorizers( + const get_accounts_by_authorizers_params& args, + const fc::time_point& deadline = fc::time_point::maximum()) const; private: std::unique_ptr _impl; @@ -130,6 +137,6 @@ namespace fc { } } -FC_REFLECT( eosio::chain_apis::account_query_db::get_accounts_by_authorizers_params, (accounts)(keys)) +FC_REFLECT( eosio::chain_apis::account_query_db::get_accounts_by_authorizers_params, (accounts)(keys)(limit)(cursor)) FC_REFLECT( eosio::chain_apis::account_query_db::get_accounts_by_authorizers_result::account_result, (account_name)(permission_name)(authorizing_account)(authorizing_key)(weight)(threshold)) -FC_REFLECT( eosio::chain_apis::account_query_db::get_accounts_by_authorizers_result, (accounts)) +FC_REFLECT( eosio::chain_apis::account_query_db::get_accounts_by_authorizers_result, (accounts)(more)(next_cursor)) diff --git a/plugins/chain_plugin/test/test_account_query_db.cpp b/plugins/chain_plugin/test/test_account_query_db.cpp index e8dddfb0a2..bbc933e0f5 100644 --- a/plugins/chain_plugin/test/test_account_query_db.cpp +++ b/plugins/chain_plugin/test/test_account_query_db.cpp @@ -92,6 +92,97 @@ BOOST_FIXTURE_TEST_CASE(updateauth_test, validating_tester) { try { } FC_LOG_AND_RETHROW() } +BOOST_FIXTURE_TEST_CASE(get_accounts_by_authorizers_limit_test, validating_tester) { try { + + auto aq_db = account_query_db(*control); + + auto c = control->accepted_block().connect([&](const block_signal_params& t) { + const auto& [ block, id ] = t; + aq_db.commit_block( block ); + }); + + produce_blocks(10); + + const auto tester_account = "tester"_n; + const auto shared_key = get_public_key(tester_account, "shared"); + create_account(tester_account); + + // Insert out of lexical order so the test also verifies deterministic tie-breaking. + for (const auto permission : {"third"_n, "first"_n, "second"_n}) { + const auto trace_ptr = push_action(config::system_account_name, updateauth::get_name(), tester_account, + fc::mutable_variant_object() + ("account", tester_account) + ("permission", permission) + ("parent", "active") + ("auth", authority(shared_key, 1))); + aq_db.cache_transaction_trace(trace_ptr); + produce_block(); + } + + params pars; + pars.keys.emplace_back(shared_key); + + const auto legacy_results = aq_db.get_accounts_by_authorizers(pars); + BOOST_TEST_REQUIRE(legacy_results.accounts.size() == 3u); + BOOST_TEST_REQUIRE(legacy_results.more == false); + BOOST_TEST_REQUIRE(legacy_results.next_cursor.empty()); + + pars.limit = 2; + + const auto limited_results = aq_db.get_accounts_by_authorizers(pars); + BOOST_TEST_REQUIRE(limited_results.accounts.size() == 2u); + BOOST_TEST_REQUIRE(limited_results.more == true); + BOOST_TEST_REQUIRE(!limited_results.next_cursor.empty()); + BOOST_TEST_REQUIRE(limited_results.accounts[0].permission_name == "first"_n); + BOOST_TEST_REQUIRE(limited_results.accounts[1].permission_name == "second"_n); + + pars.cursor = limited_results.next_cursor; + const auto continued_results = aq_db.get_accounts_by_authorizers(pars); + BOOST_TEST_REQUIRE(continued_results.accounts.size() == 1u); + BOOST_TEST_REQUIRE(continued_results.accounts[0].permission_name == "third"_n); + BOOST_TEST_REQUIRE(continued_results.more == false); + BOOST_TEST_REQUIRE(continued_results.next_cursor.empty()); + + pars.limit.reset(); + BOOST_CHECK_THROW(aq_db.get_accounts_by_authorizers(pars), invalid_http_request); + + pars.cursor.clear(); + pars.limit = 0; + const auto existence_results = aq_db.get_accounts_by_authorizers(pars); + BOOST_TEST_REQUIRE(existence_results.accounts.empty()); + BOOST_TEST_REQUIRE(existence_results.more == true); + BOOST_TEST_REQUIRE(existence_results.next_cursor.empty()); + + pars.limit = 3; + const auto complete_results = aq_db.get_accounts_by_authorizers(pars); + BOOST_TEST_REQUIRE(complete_results.accounts.size() == 3u); + BOOST_TEST_REQUIRE(complete_results.more == false); + BOOST_TEST_REQUIRE(complete_results.next_cursor.empty()); + BOOST_TEST_REQUIRE(complete_results.accounts[0].permission_name == "first"_n); + BOOST_TEST_REQUIRE(complete_results.accounts[1].permission_name == "second"_n); + BOOST_TEST_REQUIRE(complete_results.accounts[2].permission_name == "third"_n); + + // Updating a tied row removes and reinserts it, but must not change the query order. + const auto trace_ptr = push_action(config::system_account_name, updateauth::get_name(), tester_account, + fc::mutable_variant_object() + ("account", tester_account) + ("permission", "first"_n) + ("parent", "active") + ("auth", authority(shared_key, 1))); + aq_db.cache_transaction_trace(trace_ptr); + produce_block(); + + const auto reordered_results = aq_db.get_accounts_by_authorizers(pars); + BOOST_TEST_REQUIRE(reordered_results.accounts.size() == 3u); + BOOST_TEST_REQUIRE(reordered_results.accounts[0].permission_name == "first"_n); + BOOST_TEST_REQUIRE(reordered_results.accounts[1].permission_name == "second"_n); + BOOST_TEST_REQUIRE(reordered_results.accounts[2].permission_name == "third"_n); + + pars.limit.reset(); + BOOST_CHECK_THROW(aq_db.get_accounts_by_authorizers(pars, fc::time_point::min()), fc::timeout_exception); + +} FC_LOG_AND_RETHROW() } + BOOST_FIXTURE_TEST_CASE(updateauth_test_multi_threaded, validating_tester) { try { // instantiate an account_query_db @@ -286,4 +377,3 @@ BOOST_AUTO_TEST_CASE(fork_test) { try { } FC_LOG_AND_RETHROW() } BOOST_AUTO_TEST_SUITE_END() -