diff --git a/plugins/batch_operator_plugin/README.md b/plugins/batch_operator_plugin/README.md index 4dcf725dc4..fbd063fd2f 100644 --- a/plugins/batch_operator_plugin/README.md +++ b/plugins/batch_operator_plugin/README.md @@ -141,9 +141,12 @@ requests `sysio.synd` issues for syndication envelopes. Each poll it: Whether the statement is true is checked against the outpost alone; amounts, states and windows still come from the depot's tables. 2. **Bonds** each verified OPEN request `sysio.synd` issued under the `oppenvelope` - schema, oldest first and for its whole remainder (`sysio.bond::accept`), within - the token's exposure cap (bonds not yet claimed count) and the account's - `sysio.liq` balance. + schema, oldest first (`sysio.bond::accept`). Each contribution is the minimum of + the uncovered remainder, the token's remaining exposure allowance (bonds not + yet claimed count), and the account's `sysio.liq` balance, rounded down to a + 0.01-token increment. Multiple underwriters can contribute to the same request; + for example, three accounts with 2 LIQSOL of available capacity each can fully + bond a 6 LIQSOL request. 3. **Cranks** `sysio.synd::crank`, which releases what bonding allows and issues the next request: every poll while an envelope request is in play or has an outcome `sysio.synd` has not acknowledged, otherwise every 5 minutes. @@ -163,7 +166,7 @@ those lines is left to log tooling: | The outpost holds no final record for the statement's epoch | The request waits | | The outpost recorded another digest for that epoch | Never bonded; an error log names both digests | | The node cannot serve a chain (no RPC client, wrong chain id) | That chain's requests wait; the others go on | -| No cap, over the cap, or too little balance | The request waits | +| No cap, or remaining exposure allowance or balance below one 0.01-token increment | The request waits | | The `sysio.andon` cord is pulled | Nothing is bonded, approved or claimed until it clears | | A request is challenged (HELD) | Reported, whether or not we bonded it: `sysio` rules it | | A bond of ours is ruled INVALID while the node runs, even one since claimed or pruned | Bonding stops until a restart; approvals and claims go on. A forfeited bond's principal is not returned | diff --git a/plugins/batch_operator_plugin/src/underwriter.hpp b/plugins/batch_operator_plugin/src/underwriter.hpp index 35e4de50a2..5ebc7daa3f 100644 --- a/plugins/batch_operator_plugin/src/underwriter.hpp +++ b/plugins/batch_operator_plugin/src/underwriter.hpp @@ -33,6 +33,8 @@ namespace sysio::batch_operator_detail::underwriter { /// `sysio.bond` identifiers the underwriter touches. namespace bond { + /// `sysio.bond` accepts contributions in 0.01-token steps (`BOND_INCREMENT_DECIMALS = 2`). + constexpr int64_t increments_per_token = 100; constexpr auto account = "sysio.bond"; constexpr auto table_requests = "requests"; constexpr auto table_bonds = "bonds"; @@ -364,8 +366,8 @@ enum class wait_reason : uint8_t { UNVERIFIED, ///< the outpost holds no final record of an envelope for that epoch CONTRADICTED, ///< the outpost recorded another digest for that epoch: the statement is false NO_CAP, ///< no exposure cap is configured for the token - OVER_CAP, ///< bonding it would exceed the token's exposure cap - LOW_BALANCE ///< the underwriter does not hold enough of the token + OVER_CAP, ///< the token's remaining exposure allowance cannot fund one bond increment + LOW_BALANCE ///< the underwriter cannot fund one bond increment from its token balance }; /// Bond `amount` on request `request_id`. @@ -413,8 +415,9 @@ struct plan_inputs { /// Decide one pass. Only envelope requests are underwritten, and only when the outpost's own record of the envelope /// it emitted for the statement's epoch carries the statement's digest. OPEN requests are taken oldest first, so a -/// pair's envelopes are bonded in the order `sysio.synd` releases them; each bonds its whole remainder or waits. A -/// bond counts against its token's cap until `claim` returns it, unless it was forfeited. +/// pair's envelopes are bonded in the order `sysio.synd` releases them. Each contribution is limited by the uncovered +/// remainder, available exposure and wallet balance, rounded down to a 0.01-token increment so other underwriters can +/// complete the coverage. A bond counts against its token's cap until `claim` returns it, unless it was forfeited. inline plan plan_actions(const plan_inputs& in) { std::map by_id; for (const request& r : in.requests) by_id.emplace(r.id, &r); @@ -455,12 +458,21 @@ inline plan plan_actions(const plan_inputs& in) { const auto cap = in.caps.find(r->token_code); if (cap == in.caps.end()) { wait(wait_reason::NO_CAP); break; } chain::asset& used = outstanding.try_emplace(r->token_code, 0, remainder.get_symbol()).first->second; - if (used + remainder > cap->second) { wait(wait_reason::OVER_CAP); break; } + const int64_t increment = remainder.get_symbol().precision() / bond::increments_per_token; + FC_ASSERT(increment > 0); + if (used >= cap->second) { wait(wait_reason::OVER_CAP); break; } + const chain::asset available = cap->second - used; + if (available.get_amount() < increment) { wait(wait_reason::OVER_CAP); break; } const auto balance = balances.find(r->token_code); - if (balance == balances.end() || balance->second < remainder) { wait(wait_reason::LOW_BALANCE); break; } - out.accepts.push_back({r->id, remainder}); - used = used + remainder; - balance->second = balance->second - remainder; + if (balance == balances.end() || balance->second.get_amount() < increment) { + wait(wait_reason::LOW_BALANCE); break; + } + const int64_t affordable = std::min({remainder.get_amount(), available.get_amount(), + balance->second.get_amount()}); + const chain::asset contribution(affordable - affordable % increment, remainder.get_symbol()); + out.accepts.push_back({r->id, contribution}); + used = used + contribution; + balance->second = balance->second - contribution; break; } case request_state::BONDED: diff --git a/plugins/batch_operator_plugin/test/test_underwriter.cpp b/plugins/batch_operator_plugin/test/test_underwriter.cpp index 4fec9797e5..a10a8b7144 100644 --- a/plugins/batch_operator_plugin/test/test_underwriter.cpp +++ b/plugins/batch_operator_plugin/test/test_underwriter.cpp @@ -22,8 +22,9 @@ const fc::slug_name eth{"ETH"}; const fc::slug_name sol{"SOL"}; const fc::slug_name liqeth{"LIQETH"}; const fc::slug_name liqsol{"LIQSOL"}; -const sysio::chain::symbol liqeth_symbol{9, "LIQETH"}; -const sysio::chain::symbol liqsol_symbol{9, "LIQSOL"}; +/// Cent precision makes each base unit a valid 0.01 bond increment. Production-precision cases below use nine decimals. +const sysio::chain::symbol liqeth_symbol{2, "LIQETH"}; +const sysio::chain::symbol liqsol_symbol{2, "LIQSOL"}; const token_symbols symbols{{liqeth, liqeth_symbol}, {liqsol, liqsol_symbol}}; /// `units` base units of `token`. @@ -333,6 +334,103 @@ BOOST_AUTO_TEST_CASE(a_statement_the_outpost_recorded_is_bonded_for_its_remainde BOOST_CHECK(out.waiting.empty()); } +BOOST_AUTO_TEST_CASE(five_underwriters_with_two_liqsol_each_can_cover_six) { + const sysio::chain::symbol sym{9, "LIQSOL"}; + constexpr int64_t unit = 1'000'000'000; + auto r = synd_request(1, sol, 1, liqsol, 0); + r.covered = sysio::chain::asset(6 * unit, sym); + r.bonded = sysio::chain::asset(0, sym); + std::vector providers(5); + for (auto& in : providers) { + in.requests = {r}; + in.caps = {{liqsol, sysio::chain::asset(2 * unit, sym)}}; + in.balances = in.caps; + outpost_emitted_all(in); + } + + for (size_t i = 0; i < providers.size(); ++i) { + auto& in = providers[i]; + in.requests = {r}; + const auto out = plan_actions(in); + if (i >= 3) { + BOOST_CHECK(out.accepts.empty()); + continue; + } + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + const auto& contribution = out.accepts.front(); + BOOST_CHECK_EQUAL(r.id, contribution.request_id); + BOOST_CHECK_EQUAL(sysio::chain::asset(2 * unit, sym), contribution.amount); + r.bonded += contribution.amount; + if (r.bonded == r.covered) r.state = request_state::BONDED; + + // A later pass must count this provider's partial bond against its cap. + in.bonds = {{r.id, {.request_id = r.id, .amount = contribution.amount}}}; + in.balances.at(liqsol) -= contribution.amount; + in.requests = {r}; + BOOST_CHECK(plan_actions(in).accepts.empty()); + } + BOOST_CHECK_EQUAL(r.covered, r.bonded); + BOOST_CHECK(r.state == request_state::BONDED); +} + +BOOST_AUTO_TEST_CASE(partial_contributions_round_down_to_the_contract_increment) { + const sysio::chain::symbol sym{9, "LIQSOL"}; + constexpr int64_t unit = 1'000'000'000; + constexpr int64_t cent = unit / 100; + /// Available funds and the contribution, or reason to wait, expected from one pass. + struct funding_case { + int64_t cap; + int64_t balance; + int64_t expected; + wait_reason reason = wait_reason::OVER_CAP; + }; + const std::vector cases{ + {2 * unit + cent - 1, 6 * unit, 2 * unit}, + {6 * unit, 2 * unit + cent - 1, 2 * unit}, + {6 * unit, cent, cent}, + {cent - 1, 6 * unit, 0, wait_reason::OVER_CAP}, + {6 * unit, cent - 1, 0, wait_reason::LOW_BALANCE}, + {0, 6 * unit, 0, wait_reason::OVER_CAP}, + {6 * unit, 0, 0, wait_reason::LOW_BALANCE}, + }; + for (const auto& c : cases) { + BOOST_TEST_CONTEXT("cap=" << c.cap << " balance=" << c.balance) { + auto r = synd_request(1, sol, 1, liqsol, 0); + r.covered = sysio::chain::asset(6 * unit, sym); + r.bonded = sysio::chain::asset(0, sym); + plan_inputs in; + in.requests = {r}; + in.caps = {{liqsol, sysio::chain::asset(c.cap, sym)}}; + in.balances = {{liqsol, sysio::chain::asset(c.balance, sym)}}; + outpost_emitted_all(in); + const auto out = plan_actions(in); + if (c.expected == 0) { + BOOST_CHECK(out.accepts.empty()); + BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK(c.reason == out.waiting.front().reason); + } else { + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_CHECK_EQUAL(sysio::chain::asset(c.expected, sym), out.accepts.front().amount); + BOOST_CHECK(out.waiting.empty()); + } + } + } +} + +BOOST_AUTO_TEST_CASE(a_cap_lowered_below_existing_exposure_waits) { + auto in = eth_inputs(); + auto existing = synd_request(1, eth, 1, liqeth, 100); + existing.state = request_state::APPROVED; + in.requests = {existing, synd_request(2, eth, 2, liqeth, 10)}; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(100)}}}; + in.caps.at(liqeth) = liqeth_units(90); + outpost_emitted_all(in); + const auto out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK(wait_reason::OVER_CAP == out.waiting.front().reason); +} + BOOST_AUTO_TEST_CASE(a_statement_without_an_outpost_record_waits) { auto in = eth_inputs(); in.requests = {synd_request(1, eth, 11, liqeth, 10), synd_request(2, sol, 1, liqeth, 10)}; @@ -381,13 +479,17 @@ BOOST_AUTO_TEST_CASE(the_cap_counts_unsettled_bonds_and_this_pass_oldest_first) in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(30)}}, {2, {.request_id = 2, .amount = liqeth_units(50), .paid = true}}}; // Listed newest first: the plan still takes request 3 before request 4. - in.requests = {approved, settled, synd_request(4, eth, 4, liqeth, 40), synd_request(3, eth, 3, liqeth, 60)}; + in.requests = {approved, settled, synd_request(5, eth, 5, liqeth, 10), synd_request(4, eth, 4, liqeth, 40), + synd_request(3, eth, 3, liqeth, 60)}; outpost_emitted_all(in); const auto out = plan_actions(in); - BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_REQUIRE_EQUAL(2u, out.accepts.size()); BOOST_CHECK_EQUAL(3u, out.accepts[0].request_id); // 30 + 60 = 90 fits under 100 + BOOST_CHECK_EQUAL(liqeth_units(60), out.accepts[0].amount); + BOOST_CHECK_EQUAL(4u, out.accepts[1].request_id); // only the remaining 10 of capacity is contributed + BOOST_CHECK_EQUAL(liqeth_units(10), out.accepts[1].amount); BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); - BOOST_CHECK_EQUAL(4u, out.waiting[0].request_id); // 90 + 40 does not + BOOST_CHECK_EQUAL(5u, out.waiting[0].request_id); // the cap is now exhausted BOOST_CHECK(wait_reason::OVER_CAP == out.waiting[0].reason); BOOST_CHECK(out.claims == std::vector{1}); } @@ -402,17 +504,21 @@ BOOST_AUTO_TEST_CASE(no_cap_or_too_little_balance_waits) { in = eth_inputs(); in.balances = {{liqeth, liqeth_units(50)}}; - in.requests = {synd_request(1, eth, 1, liqeth, 40), synd_request(2, eth, 2, liqeth, 40)}; + in.requests = {synd_request(1, eth, 1, liqeth, 40), synd_request(2, eth, 2, liqeth, 40), + synd_request(3, eth, 3, liqeth, 10)}; outpost_emitted_all(in); out = plan_actions(in); - BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_REQUIRE_EQUAL(2u, out.accepts.size()); + BOOST_CHECK_EQUAL(liqeth_units(40), out.accepts[0].amount); + BOOST_CHECK_EQUAL(liqeth_units(10), out.accepts[1].amount); BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK_EQUAL(3u, out.waiting[0].request_id); BOOST_CHECK(wait_reason::LOW_BALANCE == out.waiting[0].reason); in.balances.clear(); // no balance of the token at all out = plan_actions(in); BOOST_CHECK(out.accepts.empty()); - BOOST_REQUIRE_EQUAL(2u, out.waiting.size()); + BOOST_REQUIRE_EQUAL(3u, out.waiting.size()); BOOST_CHECK(wait_reason::LOW_BALANCE == out.waiting[0].reason); }