Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions plugins/batch_operator_plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down
30 changes: 21 additions & 9 deletions plugins/batch_operator_plugin/src/underwriter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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<request_id_t, const request*> by_id;
for (const request& r : in.requests) by_id.emplace(r.id, &r);
Expand Down Expand Up @@ -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:
Expand Down
122 changes: 114 additions & 8 deletions plugins/batch_operator_plugin/test/test_underwriter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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<plan_inputs> 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<funding_case> 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)};
Expand Down Expand Up @@ -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<uint64_t>{1});
}
Expand All @@ -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);
}

Expand Down
Loading