Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
12 changes: 12 additions & 0 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ emissions_gate_result check_emissions_ready(uint32_t epoch_duration_sec, uint32_
r.sysio_balance = acct_tbl.get(key).balance.amount;
}

// Reserve pay that payepoch has already credited but nobody has pulled yet. Those balances
// still sit in sysio's token balance while being fully owed, so counting them as available
// would let the gate authorize a period the treasury cannot actually cover -- the next
// claimpay would then fail on overdraw and strand earned pay. `sysio_balance` is reported to
// the blocklog as the SPENDABLE amount for exactly this reason: an operator reading
// BALANCE_INSUFFICIENT needs the shortfall against what can really be paid, not the gross.
sysiosystem::emissions::payclaimtot_t claim_tot_tbl(SYSTEM_ACCOUNT);
const int64_t claims_reserve = static_cast<int64_t>(
claim_tot_tbl.get_or_default(sysiosystem::emissions::pay_claim_total{}).outstanding);
r.sysio_balance -= claims_reserve;
if (r.sysio_balance < 0) r.sysio_balance = 0;

// emission_amount / period_emission is retained on the
// BALANCE_INSUFFICIENT path so the blocklog row reports the real
// shortfall (period total vs. available balance), not zero.
Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
208 changes: 208 additions & 0 deletions contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#pragma once
/**
* @file claimable.hpp
* @brief Pull-payment primitives for payouts that originate on never-throw paths.
*
* `sysio.token::transfer` calls `require_recipient(from)` and `require_recipient(to)`, and the
* chain executes notified receivers with no exception isolation (`apply_context::exec`). An
* assert inside a recipient's `on_notify("sysio.token::transfer")` handler therefore aborts the
* WHOLE transaction, including every parent inline action -- and a handler can equally burn CPU
* until the enclosing action blows its deadline.
*
* That makes a pushed transfer unusable on any never-throw path. `sysio.epoch::advance` and the
* `sysio.msgch::deliver -> evalcons -> dispatch` chain both pre-validate every `check()` they can
* reach so they cannot abort (`feedback_opp_handlers_never_throw.md`), but that discipline stops
* at the contract's own guards: once value is pushed to an account the protocol does not control,
* the counterparty decides whether the transaction commits. A single uncooperative recipient can
* stall epoch advancement chain-wide.
*
* The fix is to never push. A never-throw path credits a claimable balance and emits no transfer;
* the recipient later pulls it with an action carrying its own authority. A handler that aborts
* then blocks only its own claim.
*
* `sysio.dclaim` established this pattern (`onreward` credits `pending_claims`, `claim` pays out);
* these helpers generalize it so `sysio.system`, `sysio.reserv` and `sysio.opreg` share one
* audited implementation rather than three copies.
*
* ## Row contract
*
* Each contract declares its OWN `[[sysio::table]]`-attributed row and key, because the table name
* is baked into both the attribute and the `kv::table` template argument, and because a
* `[[sysio::table]]`-attributed struct cannot be shared into `sysio.system`'s translation unit
* without corrupting that contract's read-only-action return codegen (see the note on
* `sysio.reserv::rewards_bucket`). The helpers below are templated over the table instead, and
* require only that the row expose:
*
* * `uint64_t balance` -- required, the claimable amount in atomic WIRE units
* * `uint32_t expires_at_sec` -- optional; when present it is maintained by `credit` and
* makes the row eligible for `sweep_expired`
*/

#include <sysio/action.hpp>
#include <sysio/asset.hpp>
#include <sysio/check.hpp>
#include <sysio/name.hpp>

#include <sysio.opp.common/safe_ops.hpp>

#include <cstdint>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

namespace sysio::opp::claimable {

/// Compile-time detection of the optional `expires_at_sec` member on a claimable row. Contracts
/// whose claimable set is bounded (a producer schedule, a registered operator set) omit the field
/// and opt out of expiry entirely; contracts crediting an unbounded, caller-influenced set of
/// accounts carry it so abandoned dust rows cannot accumulate system-paid RAM forever.
template<class Row, class = void>
struct has_expiry : std::false_type {};

template<class Row>
struct has_expiry<Row, std::void_t<decltype(std::declval<Row&>().expires_at_sec)>> : std::true_type {};

template<class Row>
inline constexpr bool has_expiry_v = has_expiry<Row>::value;

/// Saturating credit, capped at `safe::depot_amount_max` (2^62-1) rather than `UINT64_MAX`.
///
/// The cap is deliberately the `sysio::asset` magnitude limit, not the integer limit: `pay_out`
/// carries the stored balance out as an `asset`, and `asset`'s constructor `check()`-aborts above
/// `max_amount`. Saturating at the integer limit here would merely move the abort from credit time
/// (on a never-throw path) to claim time, stranding the balance permanently. Capping at the asset
/// limit keeps the row payable end to end. The cap is unreachable for any real payout.
inline uint64_t add_capped(uint64_t balance, uint64_t amount) {
constexpr uint64_t cap = static_cast<uint64_t>(safe::depot_amount_max);
if (balance >= cap) return cap;
const uint64_t room = cap - balance;
return amount >= room ? cap : balance + amount;
}

/// Credit `amount` to a claimable row, creating it when absent and accumulating when present.
///
/// Never throws: a zero amount is a silent no-op and the credit saturates rather than aborting, so
/// this is safe to call from `sysio.epoch::advance` and from OPP inbound dispatch handlers.
///
/// @param tbl the contract's claimable kv table.
/// @param payer RAM payer for a newly created row.
/// @param key primary key for the recipient.
/// @param fresh prototype row used when the key is absent; the caller pre-fills the identifying
/// fields (`account`, ...) and this function sets `balance` (and `expires_at_sec`).
/// @param amount atomic WIRE units to credit.
/// @param expires_at_sec absolute expiry stamp, ignored unless the row carries the field. Passing
/// the refreshed expiry on every credit means an account with ongoing activity
/// never expires mid-stream.
template<class Table, class Key, class Row>
void credit(Table& tbl, sysio::name payer, const Key& key, Row fresh, uint64_t amount,
uint32_t expires_at_sec = 0) {
if (amount == 0) return;

fresh.balance = add_capped(0, amount);
if constexpr (has_expiry_v<Row>) {
fresh.expires_at_sec = expires_at_sec;
}

tbl.upsert(payer, key, fresh, [&](Row& r) {
r.balance = add_capped(r.balance, amount);
if constexpr (has_expiry_v<Row>) {
r.expires_at_sec = expires_at_sec;
}
});
}

/// Drain a claimable row and emit the single `sysio.token::transfer` that pays it out.
///
/// This is the ONLY place a claimable balance becomes a transfer, and it is reached only from an
/// action carrying the claimant's own authority. A recipient whose notify handler aborts therefore
/// blocks nothing but its own claim.
///
/// The row is erased BEFORE the transfer is queued. The transfer notifies `to`, whose handler may
/// re-enter the claim action; erasing first means the re-entry observes no row and cannot double
/// spend. (Same ordering rationale as the credit-before-transfer guard in `sysio.opreg::deposit`.)
///
/// Unlike `credit`, this DOES `check()`-throw when there is nothing to claim -- correct here,
/// because the throw reaches only the claimant who asked for it.
///
/// @return the amount paid out, in atomic WIRE units.
template<class Table, class Key>
uint64_t pay_out(Table& tbl, const Key& key, sysio::name self, sysio::name token_account,
sysio::name to, const sysio::symbol& sym, const std::string& memo,
const char* nothing_to_claim_msg) {
auto it = tbl.find(key);
sysio::check(it != tbl.end(), nothing_to_claim_msg);

const uint64_t amount = it->balance;
sysio::check(amount > 0, nothing_to_claim_msg);

tbl.erase(key);

sysio::action(
sysio::permission_level{self, "active"_n},
token_account, "transfer"_n,
std::make_tuple(self, to, sysio::asset(static_cast<int64_t>(amount), sym), memo)
).send();

return amount;
}

/// Total of every outstanding claimable balance, saturating.
///
/// Callers that gate spending against a live token balance MUST subtract this: the WIRE backing
/// unclaimed rows is already owed, and spending it would leave a later `pay_out` unpayable. O(n)
/// over the table, so it is intended for bounded claimable sets (a producer schedule, a registered
/// operator set), not for the unbounded ones.
template<class Table>
uint64_t total_outstanding(const Table& tbl) {
uint64_t total = 0;
for (auto it = tbl.begin(); it != tbl.end(); ++it) {
total = safe::add_sat_u64(total, it->balance);
}
return total;
}

/// Bounded sweep of rows past their expiry, returning the reclaimed total.
///
/// Iterates the caller's expiry-ordered secondary index so the oldest rows are visited first and
/// the scan can stop at the first live row -- a bounded scan over the PRIMARY (account-ordered)
/// index would repeatedly re-walk the same low-key live rows and might never reach an expired one.
///
/// Expired keys are collected first and erased afterwards, rather than erasing through the
/// secondary iterator mid-walk: mutating a kv secondary index while iterating it is the same
/// foot-gun `sysio.system::payepoch` avoids with its `to_reset` snapshot.
///
/// Never throws, so it is safe to call from the credit path as an on-write retention contract (the
/// shape `sysio.opreg::prune_dellog` uses).
///
/// @param tbl the contract's claimable kv table.
/// @param by_expiry secondary index ordered by `expires_at_sec`.
/// @param to_key maps a row to its primary key.
/// @param now_sec current wall-clock seconds.
/// @param max_rows hard bound on rows erased in one call, keeping the caller inside its CPU
/// deadline.
template<class Table, class Index, class ToKey>
uint64_t sweep_expired(Table& tbl, Index& by_expiry, ToKey&& to_key, uint32_t now_sec,
uint32_t max_rows) {
using Key = std::decay_t<decltype(to_key(*by_expiry.begin()))>;

std::vector<Key> doomed;
uint64_t reclaimed = 0;

for (auto it = by_expiry.begin(); it != by_expiry.end() && doomed.size() < max_rows; ++it) {
// A zero stamp means "never expires"; such rows sort first, so skip rather than stop.
if (it->expires_at_sec == 0) continue;
// Index is expiry-ordered: the first live row means every later row is live too.
if (it->expires_at_sec > now_sec) break;
reclaimed = safe::add_sat_u64(reclaimed, it->balance);
doomed.push_back(to_key(*it));
}

for (const auto& k : doomed) {
tbl.erase(k);
}

return reclaimed;
}

} // namespace sysio::opp::claimable
74 changes: 74 additions & 0 deletions contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,19 @@ namespace sysio {
[[sysio::action]]
void terminate(name account, std::string reason);

/// Auth = the claiming operator. Pull CORE_SYM collateral credited by a WIRE-chain remit
/// (withdraw flush, deferred lock release, or termination payout) in a single transfer.
///
/// The remit paths credit rather than transfer because they are reachable from
/// `sysio.epoch::advance`, which must never abort; see `remitclaims`. This is the only place
/// such a balance becomes a transfer, and it carries the operator's own authority, so a
/// hostile transfer-notify handler blocks nothing but this caller's own claim.
///
/// Independent of the operator row: `prune` may have erased a settled TERMINATED operator,
/// and the collateral remains claimable regardless.
[[sysio::action]]
void claimremit(name account);

/// Prune terminated operator rows, plus delivery-log rows that have aged
/// out of the rolling termination window. Permissionless.
///
Expand Down Expand Up @@ -524,6 +537,67 @@ namespace sysio {
sysio::const_mem_fun<delivery_log_entry, uint128_t, &delivery_log_entry::by_account_ts>>
>;

/// Claimable CORE_SYM collateral owed to an operator by a WIRE-chain remit: a withdraw
/// flush (`flushwtdw`), a deferred lock release on a TERMINATED operator, or the
/// termination payout itself.
///
/// All three are reachable from `sysio.epoch::advance` (flush by epoch, termination via
/// `termcheck`), which must never abort. A pushed `sysio.token::transfer` notifies the
/// operator, so an operator carrying a hostile notify handler could abort `advance` and halt
/// epoch advancement chain-wide -- and in the termination case would be blocking its own
/// removal, so the retry never converges. Crediting here and paying out from `claimremit`
/// puts the transfer under the operator's own authority instead.
///
/// No expiry: this is returned collateral, held until claimed.
///
/// The operator set is registered and bounded only CONCURRENTLY, not across this table's
/// lifetime — a terminated operator that never calls `claimremit` leaves a row billed to the
/// sysio RAM pool even after `prune` removes its operator record, so storage grows with
/// historical operators rather than with the live set. Accepted here for the same reason as
/// `sysio.system::payclaims`: forfeiting returned collateral is an economic decision, not a
/// RAM one. (Contrast `sysio.reserv::wireclaims`, whose recipient set is unbounded AND
/// caller-influenced, and which pays for its sweep with forfeiture.)
///
/// The row therefore carries `expires_at_sec` and an expiry-ordered index NOW, even though
/// nothing reads them yet. Landing the schema is free before launch and expensive after it;
/// wiring the sweep is the part that needs a decision, so the two are deliberately
/// separated. `credit` maintains the stamp, so WIRE-339 inherits real age data rather than a
/// table that starts counting from the day the field is added.
///
/// NOTHING EXPIRES TODAY: no `claimable::sweep_expired` call is wired against this table.
/// `REMIT_CLAIM_WINDOW_SEC` is the provisional window the stamp is computed from, not a
/// commitment — whether returned collateral should be forfeited at all is open in WIRE-339.
struct remitclaim_key {
uint64_t account;
SYSLIB_SERIALIZE(remitclaim_key, (account))
};

/// Provisional retention window used only to compute `remit_claim::expires_at_sec`. Mirrors
/// `sysio.reserv::WIRE_CLAIM_WINDOW_SEC` so the claimable tables age on one scale; revisited
/// when (and if) a sweep is wired.
static constexpr uint32_t REMIT_CLAIM_WINDOW_SEC = 365 * 24 * 60 * 60;

struct [[sysio::table("remitclaims")]] remit_claim {
sysio::name account;
uint64_t balance = 0; // atomic CORE_SYM units owed, not yet claimed
uint32_t expires_at_sec = 0; // recorded by `credit`; read by nothing yet (WIRE-339)

/// Expiry-major composite so the secondary index orders by expiry and a future retention
/// sweep can stop at the first live row. The account tail only breaks ties, keeping the
/// key unique when many rows share an expiry second. (Same shape as
/// `sysio.reserv::wire_claim`.)
uint128_t by_expiry() const {
return (static_cast<uint128_t>(expires_at_sec) << 64) | account.value;
}

SYSLIB_SERIALIZE(remit_claim, (account)(balance)(expires_at_sec))
};

using remitclaims_t = sysio::kv::table<"remitclaims"_n, remitclaim_key, remit_claim,
sysio::kv::index<"byexpiry"_n,
sysio::const_mem_fun<remit_claim, uint128_t, &remit_claim::by_expiry>>
>;

/// Singleton holding the next-issued `request_id` / `log_id`. Keeps
/// the auto-increment monotonic across action calls.
struct [[sysio::table("opcounters")]] op_counters {
Expand Down
Loading