From 6ff3e3caf3426a519b89a47377c2ed2b6c5786b9 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Fri, 6 Nov 2026 16:08:35 -0500 Subject: [PATCH] Let batch operator roles pay for their own pushes `--batch-operator-explicit-payer` and `--batch-underwriter-explicit-payer`, off by default, make the relay or the underwriter declare `{account, sysio.payer}` ahead of its permission on every action it pushes, so the account pays from its own CPU and NET instead of the action's contract. The role's one signature satisfies both authorizations. An account without CPU or NET would fail every push it pays for, so the node stops at startup in that case, like the other signer checks. There is one option per role because the relay and the underwriter may run as different accounts. Change-Id: I4fb3bf7bda43c2dcc68450b1f32ab10ea212a6af --- plugins/batch_operator_plugin/README.md | 19 ++- .../src/batch_operator_plugin.cpp | 96 +++++++++--- .../batch_operator_plugin/src/role_config.hpp | 25 ++- .../test/test_batch_operator_plugin.cpp | 8 + .../test/test_explicit_payer.cpp | 147 ++++++++++++++++++ .../test/test_role_config.cpp | 26 ++++ 6 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 plugins/batch_operator_plugin/test/test_explicit_payer.cpp diff --git a/plugins/batch_operator_plugin/README.md b/plugins/batch_operator_plugin/README.md index a95120e1d0..b83a214fb2 100644 --- a/plugins/batch_operator_plugin/README.md +++ b/plugins/batch_operator_plugin/README.md @@ -76,6 +76,8 @@ permission then need not be linked for `sysio.kicker::kick`. | `--batch-underwriter-max-exposure` | none | Most the underwriter may have bonded at once in one shadow token, as an asset (`100.000000000 LIQETH`). Repeat per token; a token without one is not underwritten | | `--batch-kick-crank` | true | Push `sysio.kicker::kick` for every kicker pool that could pay. Off, the relay's permission need not authorize `kick` | | `--batch-kick-interval-ms` | 60000 | First wait before this operator retries a `sysio.kicker::kick` that did not pay (ms); doubles per unpaid attempt, up to an hour. Must be 1 to 3600000; startup fails otherwise | +| `--batch-operator-explicit-payer` | false | The relay's account pays for its pushes instead of each action's contract (see [Who pays](#who-pays)) | +| `--batch-underwriter-explicit-payer` | false | The underwriter's account pays for its pushes instead of each action's contract (see [Who pays](#who-pays)) | There is no separate enable flag: the relay runs when `--batch-operator-account` is configured, the way `producer_plugin` keys off `--producer-name`, and the @@ -103,6 +105,20 @@ still satisfies its authorization; after `updateauth` rotates it, the role stops pushing (the underwriter) or logs (the relay) until the node is restarted with the new key. +### Who pays + +By default each pushed action's contract pays for its CPU and NET. With +`--batch-operator-explicit-payer` (the relay) or `--batch-underwriter-explicit-payer` +(the underwriter), the role's own account pays instead: each action declares +`{account, sysio.payer}` ahead of the role's permission, and the role's one signature +covers both. The options are per role because the roles may run as different accounts. + +An account that pays needs CPU and NET of its own, from an ROA policy; a role whose +account has none stops the node at startup. Size the policy for a day of pushes. The +account pays for each action's whole execution, inline actions included (a `chkcons` +that advances the epoch runs `sysio.epoch::advance`), and other nodes bill its failed +pushes against the same limits. + ### Outpost wiring Nothing about an outpost is declared per node. @@ -171,7 +187,8 @@ those lines is left to log tooling: ### Underwriter setup On the depot, the underwriter account holds the shadow LIQ it bonds, RAM for its -bond rows, and CPU for a few pushes per poll. With a dedicated permission, link it to +bond rows, and, with `--batch-underwriter-explicit-payer`, CPU and NET for a few pushes +per poll. With a dedicated permission, link it to exactly the actions it pushes, so the node's key can move the inventory nowhere else: `sysio.bond::accept`, `approve`, `claim`, `prune` and `sysio.synd::crank`, `pruneenv`. The WIRE its bonds earn is withdrawn with `sysio.bond::claimwire`, which diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index 8e6d9a61d1..5652afb039 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -71,6 +71,9 @@ namespace { /// `--batch-kick-crank` default: on, like every other depot crank, which /// runs whenever its contract is deployed. constexpr bool KICK_CRANK_ENABLED = true; + /// `--batch-operator-explicit-payer` / `--batch-underwriter-explicit-payer` default: off, so each pushed action's + /// contract pays for it. + constexpr bool EXPLICIT_PAYER_ENABLED = false; /// Minimum private cron-service thread count even when 0 outposts are /// discovered at startup — keeps `epoch_tick` viable so a cold-sync node @@ -256,11 +259,12 @@ namespace { fc::slug_name chain_code{}; ///< the outpost whose token it shadows }; - /// The authorization a role declares on its depot actions and the signature provider whose key alone satisfies - /// it, resolved once the node is synced (`impl::resolve_signer`). + /// The authorization a role declares on its depot actions, the signature provider whose key alone satisfies it, + /// and who pays for the actions, resolved once the node is synced (`impl::resolve_signer`). struct signer { - chain::permission_level auth; - fc::crypto::signature_provider_ptr provider; + chain::permission_level auth; + fc::crypto::signature_provider_ptr provider; + batch_operator_detail::action_payer payer = batch_operator_detail::action_payer::contract; }; /// The descriptor of `chain_code` in `outposts`, or nullptr when it is not there. @@ -280,6 +284,14 @@ namespace { return meta != nullptr && meta->code_hash != chain::digest_type(); } + /// Whether `account` exists and has CPU and NET of its own to pay with. Call from a read window. + bool can_pay_for_itself(const chain::controller& controller, chain::name account) { + if (controller.find_account_metadata(account) == nullptr) return false; + int64_t ram_bytes = 0, net_weight = 0, cpu_weight = 0; + controller.get_resource_limits_manager().get_account_limits(account, ram_bytes, net_weight, cpu_weight); + return batch_operator_detail::has_own_resources(net_weight, cpu_weight); + } + /// "request ( epoch )", or "request " when its statement does not decode. std::string describe(const uw::request& r) { const std::optional s = uw::decode_statement(r.statement); @@ -309,6 +321,9 @@ struct batch_operator_plugin::impl { bool underwriter_enabled = false; /// `batch-underwriter-max-exposure`, by shadow symbol code: the most the underwriter may have bonded at once. std::map underwriter_caps; + /// Who pays for each role's pushes (`batch-operator-explicit-payer`, `batch-underwriter-explicit-payer`). + batch_operator_detail::action_payer relay_payer = batch_operator_detail::action_payer::contract; + batch_operator_detail::action_payer underwriter_payer = batch_operator_detail::action_payer::contract; /// The relay's and the underwriter's signers, resolved by `resolve_signers` once the node is synced. std::optional relay_signer; @@ -1345,11 +1360,20 @@ struct batch_operator_plugin::impl { /// The signer of `role`, declaring `level`: the one operator-configured WIRE signature provider whose key alone /// satisfies `level` on chain. Nullopt, logged, when there is none, when there is more than one (the choice would - /// fall to provider order), when its signatures do not recover to its key, or when `level` may not declare one - /// of `actions`. Call on the main thread, after the sync gate. + /// fall to provider order), when its signatures do not recover to its key, when `level` may not declare one + /// of `actions`, or when `payer` is the account and it has no CPU or NET. Call on the main thread, after the + /// sync gate. std::optional resolve_signer(std::string_view role, const chain::permission_level& level, - std::span actions) { + std::span actions, + batch_operator_detail::action_payer payer) { const chain::controller& chain = chain_plug->chain(); + // An account without CPU or NET of its own would fail every push it pays for. + const bool can_pay = + payer == batch_operator_detail::action_payer::contract || can_pay_for_itself(chain, level.actor); + if (!can_pay) { + elog("batch_operator: {} pays for its pushes, but {} has no CPU or NET of its own: give it an ROA policy " + "or turn its explicit payer off", role, level.actor.to_string()); + } const std::vector unlinked = batch_operator_detail::unlinked_actions( actions, [&](const pushed_action& a) { return permission_satisfies_link(chain, level, chain::name(a.contract), chain::name(a.action)); @@ -1372,8 +1396,8 @@ struct batch_operator_plugin::impl { "{}@{}, found {}", role, level.actor.to_string(), level.permission.to_string(), choice.matches); return std::nullopt; } - if (!signs_for_its_key(role, **choice.chosen) || !linked) return std::nullopt; - return signer{.auth = level, .provider = *choice.chosen}; + if (!signs_for_its_key(role, **choice.chosen) || !linked || !can_pay) return std::nullopt; + return signer{.auth = level, .provider = *choice.chosen, .payer = payer}; } /// Whether `provider`'s signature over a probe digest recovers to its own key, which is how the chain checks a @@ -1399,11 +1423,13 @@ struct batch_operator_plugin::impl { // A disabled crank's action is not required: its link cannot stop the node. const std::vector relay_actions = batch_operator_detail::required_actions( RELAY_ACTIONS, std::array{batch_operator_detail::optional_action{KICK_ACTION, kick_crank_enabled}}); - relay_signer = resolve_signer("the relay", {operator_account, chain::config::active_name}, relay_actions); + relay_signer = resolve_signer("the relay", {operator_account, chain::config::active_name}, relay_actions, + relay_payer); resolved = relay_signer.has_value() && resolved; } if (underwriter_enabled) { - underwriter_signer = resolve_signer("the underwriter", underwriter_auth, UNDERWRITER_ACTIONS); + underwriter_signer = + resolve_signer("the underwriter", underwriter_auth, UNDERWRITER_ACTIONS, underwriter_payer); resolved = underwriter_signer.has_value() && resolved; } return resolved; @@ -1852,10 +1878,11 @@ struct batch_operator_plugin::impl { chain::digest_type digest; }; - /// `contract::action_name` declaring `auth`, unsigned, against the current head. Reads chain state (the ABI, the - /// head): call on the main thread or in a read window. Nullopt, logged, when it cannot be built. + /// `contract::action_name` declaring `authorization`, unsigned, against the current head. Reads chain state (the + /// ABI, the head): call on the main thread or in a read window. Nullopt, logged, when it cannot be built. std::optional build_unsigned(const std::string& contract, const std::string& action_name, - const chain::permission_level& auth, const fc::variant_object& data) { + const std::vector& authorization, + const fc::variant_object& data) { const chain::controller& chain = chain_plug->chain(); try { auto resolver = make_resolver(chain, delivery_timeout, throw_on_yield::no); @@ -1868,8 +1895,8 @@ struct batch_operator_plugin::impl { chain::bytes action_data = abis_opt->variant_to_binary( action_type, fc::variant(data), chain::abi_serializer::create_yield_function(delivery_timeout)); unsigned_trx out; - out.trx.actions.emplace_back(std::vector{auth}, chain::name(contract), - chain::name(action_name), std::move(action_data)); + out.trx.actions.emplace_back(authorization, chain::name(contract), chain::name(action_name), + std::move(action_data)); out.trx.set_reference_block(chain.head().id()); out.trx.expiration = fc::time_point_sec(chain.head().block_time() + PUSH_EXPIRATION); out.digest = out.trx.sig_digest(chain.get_chain_id(), out.trx.context_free_data); @@ -1914,8 +1941,9 @@ struct batch_operator_plugin::impl { } } - /// Build, sign and submit a depot action, declaring `by.auth` and signed by `by.provider`. True when the node - /// accepted the transaction within `delivery_timeout`; a refusal is logged by the push callback. + /// Build, sign and submit a depot action, declaring `by.auth` (after its `sysio.payer` marker when `by.payer` is + /// the account) and signed by `by.provider`. True when the node accepted the transaction within + /// `delivery_timeout`; a refusal is logged by the push callback. /// /// Called from cron threads, which may not read chain state: the build runs in a read window and the submission /// on the main thread, while the signature, possibly a remote signer, stays here. The waits do not cancel @@ -1927,22 +1955,25 @@ struct batch_operator_plugin::impl { if (shutting_down) return false; const fc::time_point deadline = fc::time_point::now() + delivery_timeout; const bool on_main = std::this_thread::get_id() == app().executor().get_main_thread_id(); + std::vector authorization = + batch_operator_detail::declared_authorization(by.auth, by.payer); std::optional trx; if (on_main) { - trx = build_unsigned(contract, action_name, by.auth, data); + trx = build_unsigned(contract, action_name, authorization, data); } else { std::shared_ptr>> built = std::make_shared>>(); std::future> built_future = built->get_future(); // Shutdown is checked again when the task runs, since chain state may be going away by then. app().executor().post(appbase::priority::medium, appbase::exec_queue::read_only, - [this, built, contract, action_name, auth = by.auth, data = fc::variant_object(data)] { + [this, built, contract, action_name, authorization = std::move(authorization), + data = fc::variant_object(data)] { if (shutting_down) { built->set_value(std::nullopt); return; } - built->set_value(build_unsigned(contract, action_name, auth, data)); + built->set_value(build_unsigned(contract, action_name, authorization, data)); }); if (!ready_by(built_future, deadline)) { if (shutting_down) wlog("batch_operator: push {}::{} abandoned on shutdown", contract, action_name); @@ -2131,6 +2162,15 @@ void batch_operator_plugin::set_program_options(options_description& cli, opts("batch-underwriter-max-exposure", bpo::value>()->composing(), "Most the underwriter may have bonded at once in one shadow token, as an asset such as " "100.000000000 LIQETH. Repeat for each token; requests in a token without one are not bonded."); + // One option per role: the two roles may run as different accounts, and each account needs its own CPU and NET + // before it can pay. + opts("batch-operator-explicit-payer", bpo::value()->default_value(EXPLICIT_PAYER_ENABLED), + "The relay's account pays for its pushes from its own CPU and NET, through a sysio.payer authorization, " + "instead of each action's contract. The account needs CPU and NET of its own; the node stops otherwise."); + opts("batch-underwriter-explicit-payer", bpo::value()->default_value(EXPLICIT_PAYER_ENABLED), + "The underwriter's account pays for its pushes from its own CPU and NET, through a sysio.payer " + "authorization, instead of each action's contract. The account needs CPU and NET of its own; the node " + "stops otherwise."); opts("batch-kick-crank", bpo::value()->default_value(KICK_CRANK_ENABLED), "Push sysio.kicker::kick for every kicker pool that could pay"); opts("batch-kick-interval-ms", bpo::value()->default_value(whole_milliseconds(KICK_INTERVAL)), @@ -2164,6 +2204,20 @@ void batch_operator_plugin::plugin_initialize(const variables_map& options) { if (!_impl->underwriter_enabled && !_impl->underwriter_caps.empty()) { wlog("batch_operator_plugin: batch-underwriter-max-exposure has no effect without batch-underwriter-account"); } + const auto payer_of = [](bool explicit_payer) { + return explicit_payer ? batch_operator_detail::action_payer::account + : batch_operator_detail::action_payer::contract; + }; + const bool relay_explicit_payer = options["batch-operator-explicit-payer"].as(); + const bool underwriter_explicit_payer = options["batch-underwriter-explicit-payer"].as(); + _impl->relay_payer = payer_of(relay_explicit_payer); + _impl->underwriter_payer = payer_of(underwriter_explicit_payer); + if (relay_explicit_payer && !_impl->enabled) { + wlog("batch_operator_plugin: batch-operator-explicit-payer has no effect without batch-operator-account"); + } + if (underwriter_explicit_payer && !_impl->underwriter_enabled) { + wlog("batch_operator_plugin: batch-underwriter-explicit-payer has no effect without batch-underwriter-account"); + } _impl->kick_crank_enabled = options["batch-kick-crank"].as(); const uint32_t kick_interval_ms = options["batch-kick-interval-ms"].as(); FC_ASSERT(batch_operator_detail::kick_interval_valid(kick_interval_ms), diff --git a/plugins/batch_operator_plugin/src/role_config.hpp b/plugins/batch_operator_plugin/src/role_config.hpp index 4bf27c66da..93a4a057dc 100644 --- a/plugins/batch_operator_plugin/src/role_config.hpp +++ b/plugins/batch_operator_plugin/src/role_config.hpp @@ -1,8 +1,8 @@ #pragma once /** * @file role_config.hpp - * @brief Parsing of the batch-operator plugin's role options and the choice of the key a role signs with. Pure - * functions, so the plugin's tests drive them without a node. + * @brief Parsing of the batch-operator plugin's role options, the choice of the key a role signs with, and the + * authorization it declares. Pure functions, so the plugin's tests drive them without a node. */ #include @@ -45,6 +45,27 @@ inline std::map parse_exposure_caps(const std: return caps; } +/// Who pays the CPU and NET of the actions a role pushes. +enum class action_payer { + contract, ///< each action's contract, the chain's default + account, ///< the role's own account, declared as the explicit payer +}; + +/// The authorization a role declares on each action it pushes: `level`, preceded by the `sysio.payer` marker of +/// its account when the account pays. The chain requires the marker first and paired with a real permission of the +/// same actor, so `level`'s signature is all the transaction needs. +inline std::vector declared_authorization(const chain::permission_level& level, + action_payer payer) { + if (payer == action_payer::contract) return {level}; + return {{level.actor, chain::config::sysio_payer_name}, level}; +} + +/// Whether an account with these resource weights has CPU and NET to pay with: zero gives it none, negative is +/// unlimited. +inline bool has_own_resources(int64_t net_weight, int64_t cpu_weight) { + return net_weight != 0 && cpu_weight != 0; +} + /// A depot action a role pushes. struct pushed_action { const char* contract; diff --git a/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp b/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp index 4e46848e3c..2aba5bfb82 100644 --- a/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp @@ -29,6 +29,9 @@ constexpr uint32_t expected_error_log_count = 1; /// Default delivery budget in milliseconds. constexpr auto delivery_timeout_option = "batch-delivery-timeout-ms"; constexpr uint32_t expected_delivery_timeout_ms = 60'000; +/// Whether each role's account pays for its own pushes. +constexpr auto relay_explicit_payer_option = "batch-operator-explicit-payer"; +constexpr auto underwriter_explicit_payer_option = "batch-underwriter-explicit-payer"; namespace callback_lifetime_action { constexpr auto contract = "sysio.system"; @@ -62,6 +65,8 @@ BOOST_AUTO_TEST_CASE(plugin_options_are_registered) try { BOOST_CHECK(option_names.count("batch-yield-tick-interval-ms") > 0); BOOST_CHECK(option_names.count("batch-kick-crank") > 0); BOOST_CHECK(option_names.count("batch-kick-interval-ms") > 0); + BOOST_CHECK(option_names.count(relay_explicit_payer_option) > 0); + BOOST_CHECK(option_names.count(underwriter_explicit_payer_option) > 0); // Configuring batch-operator-account is what enables the relay. A separate // enable flag would let an operator set the account and still relay nothing, // which is indistinguishable from a healthy node until the group misses an epoch. @@ -92,6 +97,9 @@ BOOST_AUTO_TEST_CASE(default_options_are_correct) try { // pool no faster than the tickyield crank ticks one. BOOST_CHECK(vm["batch-kick-crank"].as()); BOOST_CHECK_EQUAL(vm["batch-kick-interval-ms"].as(), 60000u); + // Each pushed action's contract pays unless a role's account is set up to pay for itself. + BOOST_CHECK(!vm[relay_explicit_payer_option].as()); + BOOST_CHECK(!vm[underwriter_explicit_payer_option].as()); // No default account: an unconfigured node leaves the relay off. BOOST_CHECK_EQUAL(vm.count("batch-operator-account"), 0u); } FC_LOG_AND_RETHROW(); diff --git a/plugins/batch_operator_plugin/test/test_explicit_payer.cpp b/plugins/batch_operator_plugin/test/test_explicit_payer.cpp new file mode 100644 index 0000000000..35031662ce --- /dev/null +++ b/plugins/batch_operator_plugin/test/test_explicit_payer.cpp @@ -0,0 +1,147 @@ +#include "../src/role_config.hpp" + +#include + +#include +#include +#include + +#include + +using namespace sysio::chain; +using namespace sysio::testing; +using sysio::batch_operator_detail::action_payer; +using sysio::batch_operator_detail::declared_authorization; +using sysio::batch_operator_detail::has_own_resources; + +namespace { + +/// A chain whose operator account starts as `sysio.system::newaccount` leaves one, with no CPU or NET. It pushes +/// `sysio::reqauth`, which, like the depot actions the plugin pushes, is a `sysio.*` contract's action that needs +/// only the role's authorization. +struct explicit_payer_fixture : tester { + static constexpr account_name operator_account = "batchop"_n; + static constexpr permission_name underwrite = "underwrite"_n; + static constexpr action_name setalimits = "setalimits"_n; + static constexpr action_name reqauth = "reqauth"_n; + static constexpr int64_t unlimited = -1; + static constexpr int64_t no_weight = 0; + /// The ROA policy an operator holds before turning the option on. + static constexpr auto policy_weight = "10.0000 SYS"; + static constexpr auto policy_ram = "1.0000 SYS"; + + /// NET and CPU billed to an account, read within one pending block. + struct billed { + uint64_t net = 0; + uint64_t cpu = 0; + }; + + const permission_level active{operator_account, config::active_name}; + + explicit_payer_fixture() + : tester(setup_policy::full) { + produce_block(); + create_account(operator_account, config::system_account_name, false, false, false, false); + push_action(config::system_account_name, setalimits, config::system_account_name, + fc::mutable_variant_object()("account", operator_account)("ram_bytes", unlimited)( + "net_weight", no_weight)("cpu_weight", no_weight)); + produce_block(); + } + + /// Give the operator CPU and NET through an ROA policy. + void add_policy() { + add_roa_policy(NODE_DADDY, operator_account, policy_weight, policy_weight, policy_ram, 0, 0); + produce_block(); + } + + /// Push `sysio::reqauth` for the operator declaring `level` as `payer` says, signed only by `key`. + transaction_trace_ptr push_as(const permission_level& level, action_payer payer, const private_key_type& key) { + return push_reqauth(operator_account, declared_authorization(level, payer), {key}); + } + + /// Whether the operator has CPU and NET of its own, by its limits on chain. + bool can_pay() const { + int64_t ram_bytes = 0, net_weight = 0, cpu_weight = 0; + control->get_resource_limits_manager().get_account_limits(operator_account, ram_bytes, net_weight, cpu_weight); + return has_own_resources(net_weight, cpu_weight); + } + + billed billed_to(account_name account) const { + const auto& usage = + control->db().get(account); + return {.net = usage.net_usage.value_ex, .cpu = usage.cpu_usage.value_ex}; + } +}; + +} // namespace + +BOOST_AUTO_TEST_SUITE(explicit_payer_tests) + +/// Off, the contract pays, so an operator without CPU or NET still pushes and is billed nothing. +BOOST_FIXTURE_TEST_CASE(the_contract_pays_by_default, explicit_payer_fixture) try { + BOOST_REQUIRE(!can_pay()); + const billed operator_before = billed_to(operator_account); + const billed contract_before = billed_to(config::system_account_name); + + const transaction_trace_ptr trace = + push_as(active, action_payer::contract, get_private_key(operator_account, "active")); + BOOST_REQUIRE(!trace->action_traces.empty()); + BOOST_CHECK_EQUAL(config::system_account_name, trace->action_traces.front().act.payer()); + + const billed operator_after = billed_to(operator_account); + const billed contract_after = billed_to(config::system_account_name); + BOOST_CHECK_EQUAL(operator_before.net, operator_after.net); + BOOST_CHECK_EQUAL(operator_before.cpu, operator_after.cpu); + BOOST_CHECK_GT(contract_after.net, contract_before.net); + BOOST_CHECK_GT(contract_after.cpu, contract_before.cpu); +} FC_LOG_AND_RETHROW() + +/// An operator without CPU or NET cannot pay, which is why the plugin refuses to start that way. +BOOST_FIXTURE_TEST_CASE(an_operator_without_resources_cannot_pay, explicit_payer_fixture) try { + BOOST_REQUIRE(!can_pay()); + BOOST_CHECK_THROW(push_as(active, action_payer::account, get_private_key(operator_account, "active")), + resource_exhausted_exception); +} FC_LOG_AND_RETHROW() + +/// With an ROA policy, the explicitly paid push passes authorization with only the active key's signature, and the +/// operator pays instead of the contract. +BOOST_FIXTURE_TEST_CASE(an_operator_with_a_policy_pays_for_its_pushes, explicit_payer_fixture) try { + add_policy(); + BOOST_REQUIRE(can_pay()); + const billed operator_before = billed_to(operator_account); + const billed contract_before = billed_to(config::system_account_name); + + const transaction_trace_ptr trace = + push_as(active, action_payer::account, get_private_key(operator_account, "active")); + BOOST_REQUIRE(!trace->action_traces.empty()); + BOOST_CHECK_EQUAL(operator_account, trace->action_traces.front().act.payer()); + + const billed operator_after = billed_to(operator_account); + const billed contract_after = billed_to(config::system_account_name); + BOOST_CHECK_GT(operator_after.net, operator_before.net); + BOOST_CHECK_GT(operator_after.cpu, operator_before.cpu); + BOOST_CHECK_EQUAL(contract_before.net, contract_after.net); + BOOST_CHECK_EQUAL(contract_before.cpu, contract_after.cpu); +} FC_LOG_AND_RETHROW() + +/// The underwriter declares a dedicated permission linked to its actions: the marker pairs with it, and that +/// permission's key alone signs. +BOOST_FIXTURE_TEST_CASE(a_linked_permission_pays_without_the_active_key, explicit_payer_fixture) try { + add_policy(); + set_authority(operator_account, underwrite, authority(get_public_key(operator_account, underwrite.to_string())), + config::active_name); + link_authority(operator_account, config::system_account_name, underwrite, reqauth); + produce_block(); + const billed operator_before = billed_to(operator_account); + const billed contract_before = billed_to(config::system_account_name); + + push_as({operator_account, underwrite}, action_payer::account, + get_private_key(operator_account, underwrite.to_string())); + + const billed operator_after = billed_to(operator_account); + const billed contract_after = billed_to(config::system_account_name); + BOOST_CHECK_GT(operator_after.cpu, operator_before.cpu); + BOOST_CHECK_EQUAL(contract_before.cpu, contract_after.cpu); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/batch_operator_plugin/test/test_role_config.cpp b/plugins/batch_operator_plugin/test/test_role_config.cpp index 919ae00b55..58ddb4615b 100644 --- a/plugins/batch_operator_plugin/test/test_role_config.cpp +++ b/plugins/batch_operator_plugin/test/test_role_config.cpp @@ -86,4 +86,30 @@ BOOST_AUTO_TEST_CASE(a_disabled_crank_action_is_not_required_of_the_permission) BOOST_CHECK_EQUAL(std::string("kick"), unlinked.front().action); } +/// Off, a role declares only its permission and the contract pays. On, its account's `sysio.payer` marker comes +/// first, paired with the same permission. +BOOST_AUTO_TEST_CASE(a_role_declares_the_payer_marker_only_when_its_account_pays) { + const auto level = parse_permission_level("uw.acct@underwrite"); + + const auto contract_pays = declared_authorization(level, action_payer::contract); + BOOST_REQUIRE_EQUAL(1u, contract_pays.size()); + BOOST_CHECK(level == contract_pays[0]); + + const auto account_pays = declared_authorization(level, action_payer::account); + BOOST_REQUIRE_EQUAL(2u, account_pays.size()); + BOOST_CHECK(account_pays[0] == (sysio::chain::permission_level{level.actor, sysio::chain::config::sysio_payer_name})); + BOOST_CHECK(level == account_pays[1]); +} + +/// An account pays only with both CPU and NET; a negative weight is unlimited. +BOOST_AUTO_TEST_CASE(an_account_has_its_own_resources_only_with_cpu_and_net) { + constexpr int64_t none = 0, some = 1, unlimited = -1; + BOOST_CHECK(has_own_resources(some, some)); + BOOST_CHECK(has_own_resources(unlimited, unlimited)); + BOOST_CHECK(has_own_resources(some, unlimited)); + BOOST_CHECK(!has_own_resources(none, some)); + BOOST_CHECK(!has_own_resources(some, none)); + BOOST_CHECK(!has_own_resources(none, none)); +} + BOOST_AUTO_TEST_SUITE_END()