Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b8fd386
contracts: credit claimable balances instead of pushing transfers on …
heifner Aug 11, 2026
db8d48d
Merge remote-tracking branch 'origin/master' into fix/sec-150-claimab…
heifner Aug 11, 2026
ddc7a5c
Merge remote-tracking branch 'origin/master' into fix/sec-150-claimab…
heifner Aug 12, 2026
be11600
Merge remote-tracking branch 'origin/master' into fix/sec-150-claimab…
heifner Aug 12, 2026
c453c0c
contracts: record a retention stamp on payclaims and remitclaims
heifner Aug 12, 2026
76bc036
Merge remote-tracking branch 'origin/master' into fix/sec-150-claimab…
heifner Aug 13, 2026
6e17468
contracts: pay the T5 category buckets directly, not through payclaims
heifner Aug 13, 2026
c546b32
contracts: give expired wireclaims an epoch-driven sweep and enforce …
heifner Aug 13, 2026
8469473
contracts: close three gaps around the claimable-payout reserve
heifner Aug 13, 2026
264fe11
contracts: document the multi-contract upgrade order and pin the pre-…
heifner Aug 14, 2026
8aaa053
contracts: make the upgrade recipe executable and back the test's escrow
heifner Aug 14, 2026
69e34b4
docs: the root sysio account deploys natively; giftram cannot self-ta…
heifner Aug 14, 2026
cbf48d6
docs(emissions): EMISSIONS.md describes the pull payout, not a push
heifner Aug 14, 2026
562542b
docs(emissions): the credited rows carry no memo, and claimpay's is g…
heifner Aug 14, 2026
9948a13
docs: correct four comment claims the claimable payout invalidated
heifner Aug 14, 2026
369b194
contracts: correct the push-safety basis, the release-path failure, a…
heifner Aug 14, 2026
a0aceec
Merge remote-tracking branch 'origin/master' into fix/sec-150-claimab…
heifner Aug 14, 2026
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
1 change: 1 addition & 0 deletions contracts/sysio.epoch/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ if(BUILD_SYSTEM_CONTRACTS)
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.authex/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.chains/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.uwrit/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.reserv/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opp.common/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.token/include>
)
Expand Down
1 change: 1 addition & 0 deletions contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ namespace sysio {
static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n;
static constexpr name CHAINS_ACCOUNT = "sysio.chains"_n;
static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n;
static constexpr name RESERV_ACCOUNT = "sysio.reserv"_n;

/// Bounds on `epoch_duration_sec`. Floor is a typo-guard: well below this
/// value, `expected_rounds` in sysio.system::payepoch falls back to 1
Expand Down
43 changes: 43 additions & 0 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
// For uwrit::MAX_UWREQ_PRUNE_PER_EPOCH — the per-epoch budget advance hands
// to the inline `pruneuwreqs` sweep (the constant is owned by sysio.uwrit).
#include <sysio.uwrit/sysio.uwrit.hpp>
// For reserve::MAX_CLAIM_SWEEP_PER_EPOCH — the per-epoch budget advance hands
// to the inline `sweepclaims` retention sweep (the constant is owned by
// sysio.reserv).
#include <sysio.reserv/sysio.reserv.hpp>
// Canonical sysio.system emissions types + compute_epoch_emission. The
// [[sysio::contract("sysio.system")]] attribute on emission_config / t5_state
// pins them to sysio.system's ABI; no readonly mirror needed here.
Expand Down Expand Up @@ -129,6 +133,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 Expand Up @@ -376,6 +392,33 @@ void epoch::advance() {
std::make_tuple(uwrit::MAX_UWREQ_PRUNE_PER_EPOCH)
).send();

// Bounded retention sweep of expired `sysio.reserv::wireclaims`: erase rows
// whose one-year window closed and return their WIRE to the treasury.
//
// `sysio.reserv` also sweeps opportunistically when crediting a new claim,
// but that only fires while settlement traffic arrives. This inline call is
// what makes the deadline hold when swaps stop: without it nothing would
// revisit an aged-out row, leaving an unbounded system-funded table and the
// WIRE it reserves outstanding indefinitely. Budget-bounded and
// never-throwing, like the two sweeps above — a backlog drains across
// subsequent epochs.
//
// GUARDED on the account existing. Dispatching an inline action to an
// absent code account is a hard `action_validate_exception`, which inside
// `advance` is an epoch stall chain-wide — and `sysio.reserv` is not a
// precondition for advancing an epoch: a chain can advance before reserves
// are ever deployed (every emissions-only test fixture does exactly that).
// Nothing can have accrued a wireclaim in that state, so skipping the sweep
// is precisely correct rather than merely safe.
if (is_account(RESERV_ACCOUNT)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the reclamation path reachable when this epoch is balance-blocked. advance returns on a failed emissions gate at lines 349-361, before this call. If a pay epoch is BALANCE_INSUFFICIENT while expired wireclaims hold enough forfeited WIRE to cover the shortfall, every retry exits before transferring that WIRE back to sysio, so the gate remains stuck even though the contract already controls the needed funds. sweepclaims only accepts epoch or reserv authority, so an ordinary keeper cannot break the cycle and privileged intervention is required. Run the bounded maintenance reclaim before the economic gate, expose a safely capped permissionless trigger, or otherwise let gate-blocked retries execute it; add a regression where expired claims make a blocked epoch payable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8469473 — the sweep now runs BEFORE the emissions gate.

You are right, and it was self-inflicted: I had placed it with the other maintenance sweeps (chklocks, pruneuwreqs), which all sit after the gate. That is fine for them because none of them can change the gate's verdict. This one can — what it reclaims lands in the very balance the gate measures — so putting it in the same block created a cycle where a BALANCE_INSUFFICIENT epoch returns before reclaiming the forfeited WIRE that would cover its own shortfall, and every retry repeats it. Your point about authority closes the escape hatch: sweepclaims takes only epoch or reserv authority, so no ordinary keeper could break it either.

It is maintenance, not economics, so ahead of the gate is where it belongs. Safe there for three reasons I checked rather than assumed: it is budget-bounded, it never throws past its auth check, and it touches no epoch state — so a blocked epoch that sweeps and still cannot pay is exactly as blocked as before, minus some expired rows. Nothing about the gate's own inputs is disturbed except the balance, which is the point.

I did not add the permissionless trigger you offered as an alternative. Running it before the gate makes the reclaim unconditional on every advance attempt, including the retries of a blocked epoch, so the cycle cannot form in the first place — a keeper-callable escape valve would be a second path to the same outcome. Happy to add one if you would rather have a manual lever independent of the epoch tick.

Worth noting for the record: the guard on this inline is load-bearing and was found the hard way. The first version was unguarded and failed 124 cases with inline action's code account sysio.reserv does not exist — the emissions fixtures advance epochs without ever creating that account. Moving the call earlier does not change that; it is still wrapped in is_account(RESERV_ACCOUNT).

I have NOT added the regression you asked for — "expired claims make a blocked epoch payable". It needs a fixture holding a balance-blocked epoch and expired wireclaims whose forfeited total covers exactly the shortfall, which is a more elaborate setup than the two sweep tests; the reordering itself is covered only in the sense that the full suite still passes. Say the word and I will build it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, please add that regression. This placement is load-bearing cross-contract control flow, while the current reserv tests call sweepclaims directly and never exercise the real advance path. Since action.send() queues the inline action, the expected blocked-path behavior is: the first advance records the gate block and then executes the queued reclaim; the next chkcons retry observes the increased treasury balance and advances. A test that leaves an expired claim covering the shortfall and proves the epoch advances without a manual sweep or top-up would pin the actual guarantee and catch either moving this call back below the gate or losing the retry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 264fe11expired_wire_claims_unblock_a_balance_blocked_epoch, in contracts/tests/emissions_tests.cpp.

It pins the guarantee in exactly the two-attempt shape you described:

  • a reserve seeded from the treasury, one refundwire credit, then produce_block(fc::days(366)) with no further credit, so credit_wire_claim's opportunistic sweep can never fire — the epoch inline is the only thing that can collect the row;
  • the treasury drained to exactly period_emission - FORFEIT, i.e. short by precisely the forfeited claim;
  • first advanceblocklog[1] records EMISSIONS_BLOCK_REASON_BALANCE_INSUFFICIENT with attempted_emission == period_emission and t5state.last_epoch_index still 0, and the queued reclaim runs anyway: the claim row is gone and the treasury is exactly period_emission;
  • second advance — no manual sweepclaims, no top-up: the epoch advances to 1, blocklog[1] is pruned, last_epoch_index is 1.

I verified it is a real regression rather than assuming. Moving the inline back below the gate (where the other maintenance sweeps live) fails it at 0u == wire_claimable("lapseduser"_n) with [0 != 100000000000] — the blocked epoch returns without ever reclaiming, which is the cycle the placement exists to prevent. It would equally catch losing the retry, since the second advance is what asserts the epoch actually moves.

Reaching the epoch machinery from a reserv-aware fixture turned out not to need new scaffolding: deploy_reserv() already existed in the emissions fixture for the swap-fee fold-in test, and regreserve is legal there because the fixture is still inside the epoch-0 bootstrap window. The only addition is a wire_claimable() reader for sysio.reserv::wireclaims.

contracts_unit_test: 644 cases, *** No errors detected

action(
permission_level{get_self(), "owner"_n},
RESERV_ACCOUNT,
"sweepclaims"_n,
std::make_tuple(reserve::MAX_CLAIM_SWEEP_PER_EPOCH)
).send();
}

// Before incrementing: evaluate per-op delivery state for the EXPIRING
// epoch. The active group of the expiring epoch (`current_batch_op_group`
// BEFORE the increment) is the set of ops responsible for delivering
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Keep this helper documentation token-generic. sysio.opreg::claimremit instantiates these helpers with CORE_SYM (currently SYS,4), so defining balance, amount, and the return value as atomic WIRE units is already false for one consumer. Please describe them as atomic units of the symbol supplied to pay_out throughout this file; the registered-operator example near has_expiry should also avoid implying that historical remitclaims omit expiry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9948a13. You are right — sysio.opreg::claimremit instantiates these helpers with CORE_SYM (SYS,4), so a file-wide "atomic WIRE units" was already false for a live consumer, in the one file that is supposed to be token-agnostic.

The three sites are now generic:

  • the row contract's balance says atomic units of THAT contract's token (WIRE for sysio.system / sysio.reserv, CORE_SYM for sysio.opreg), and notes these helpers never name a symbol — pay_out's caller supplies it;
  • credit's @param amount is atomic units in the caller's token, pointing back at the row contract;
  • pay_out's @return is atomic units of the symbol passed in.

On the has_expiry example: it read as though bounded-set contracts (a producer schedule, a registered operator set) omit the field, which described payclaims and remitclaims as they were BEFORE the expiry stamp landed. All three tables carry expires_at_sec today. The note now says the false branch is the shape a future bounded-set contract may choose rather than a description of any current table, and separates the two questions that were blurred: whether a row is STAMPED (all three) versus whether anything ever ACTS on the stamp (only wireclaims; the rest is WIRE-339).

* * `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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Finish making this generic helper documentation token- and lifetime-generic. total_outstanding can sum CORE_SYM rows just as the helpers above do, so the backing is not necessarily WIRE. Also, this PR explicitly documents producer and registered-operator claim tables as bounded only concurrently and growing across historical churn, so these examples contradict their actual lifetime shape. Say caller token and require a genuinely lifetime-bounded table, or remove the currently unused helper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 369b194, rather than re-documented.

You gave two options and the second is the honest one, because both defects in that docblock point the same way. total_outstanding sums it->balance over any table, so "the WIRE backing unclaimed rows" is wrong for remitclaims exactly as you say — and its precondition, "intended for bounded claimable sets (a producer schedule, a registered operator set)", describes no table in this tree. This PR's own comments establish that the producer and registered-operator sets are bounded only CONCURRENTLY and grow with historical churn, which is why payclaims and remitclaims carry a retention stamp and why WIRE-339 exists. So the fix would have been to genericize the token AND replace the example with a table that does not exist.

It is also unused: nothing in contracts/ calls it, and payclaimtot superseded it before it ever had a consumer — a maintained counter precisely because the epoch gate reads the outstanding total on every advance and cannot afford an O(n) scan.

An uncalled helper, documented for a token it does not assume and a lifetime guarantee nothing satisfies, is worth less than its absence. If a genuinely lifetime-bounded claimable table ever appears, the four-line loop is easier to reintroduce correctly than to keep accurate in the meantime.

ABIs verified byte-identical for epoch / opreg / system / reserv after the removal.

/// 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
Loading