Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
55 changes: 55 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 @@ -330,6 +346,45 @@ void epoch::advance() {
// wall clock for the current epoch effectively extends until the gate
// eventually passes on a subsequent chkcons retry.
const uint32_t target_epoch = state.current_epoch_index + 1;

// Bounded retention sweep of expired `sysio.reserv::wireclaims`: erase rows whose one-year
// window closed and return their WIRE to the treasury.
//
// Runs BEFORE the emissions gate, deliberately. This is maintenance, not economics, and the
// WIRE it reclaims lands in the very balance the gate measures -- so ordering it after the gate
// creates a deadlock: an epoch blocked as BALANCE_INSUFFICIENT returns below without ever
// reclaiming forfeited WIRE that could cover the shortfall, and every retry takes the same
// path. The contract would be sitting on the funds needed to unblock itself. `sweepclaims`
// takes only epoch or reserv authority, so no ordinary keeper could break that cycle either.
//
// Safe to run ahead of the gate because it is bounded, never-throwing past its auth check, and
// touches no epoch state -- a blocked epoch that sweeps and still cannot pay is exactly as
// blocked as before, minus some expired rows.
//
// `sysio.reserv` also sweeps opportunistically when crediting a new claim, but that only fires
// while settlement traffic arrives; this call is what makes the deadline hold when swaps stop.
//
// GUARDED on the account existing. Dispatching an inline action to an absent code account is a
// hard `action_validate_exception`, which inside `advance` is a chain-wide epoch stall.
// `sysio.reserv` is not a precondition for advancing an epoch -- a chain can advance before
// reserves are ever deployed -- and nothing can have accrued a wireclaim in that state, so
// skipping is precisely correct rather than merely defensive.
//
// That guard covers an ABSENT account, not a STALE one: an old `sysio.reserv` build has the
// account and not the action, and the CDT dispatcher asserts on an action it does not
// implement -- so deploying `sysio.epoch` ahead of `sysio.reserv` aborts every advance. The
// deploy is therefore one atomic `sysio.msig` transaction, or `sysio.reserv` first. Both this
// edge and the `sysio.epoch`-before-`sysio.system` one the emissions gate creates are written
// up in `docs/contract-upgrade-order.md`.
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] Define a safe multi-contract rollout order. This call and the new pay-claim reader create two unsafe mixed-version states. If sysio.epoch is upgraded while sysio.reserv is still old, is_account passes but the old CDT dispatcher asserts on the unknown sweepclaims action, so every advance aborts. If sysio.system is upgraded before epoch, new payepoch starts retaining WIRE in payclaims while the old gate still counts that backing as spendable, so a later pay period can double-commit it and leave claims underfunded. Deploy sysio.reserv then sysio.epoch then sysio.system, or batch/feature-gate the upgrades atomically; document and exercise that transition so release tooling cannot choose an unsafe order.

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 264fe11 — the order is documented, and the write-up is new in this PR: docs/contract-upgrade-order.md.

Both hazards you name are real, and I verified the mechanism rather than taking it on faith: the CDT-generated dispatcher ends every contract's apply with default: if (r != "sysio"_n.value) sysio_assert_code(false, 1);, so an inline action a stale callee does not implement asserts — and inside advance that is a chain-wide epoch stall, exactly as you describe.

Two things came out of writing it up, and both change the shape of the answer.

The hazard is systemic, not specific to sweepclaims. advance inlines into six contracts — sysio.reserv (sweepclaims), sysio.uwrit (chklocks, pruneuwreqs, drainfwq), sysio.opreg (recorddel, termcheck, flushwtdw), sysio.chalg (slashop), sysio.msgch (queueout, buildenv), sysio (accrueepoch, payepoch) — plus everything those inline transitively. pruneuwreqs (SEC-129) carried the identical exposure when it landed. A guard on this one call fixes one edge of N; a deploy procedure fixes the class.

sysio is the exception in that dispatcher, and it is the worse one. The if (r != "sysio"_n.value) means an action sysio.system does not implement is silently IGNORED rather than asserted. So the sysio.epoch-new / sysio.system-old direction does not halt — it drops the effect. An accrueepoch that never runs looks exactly like a chain that is working. Table reads have no dispatcher at all, so a reader meeting a stale writer sees the field default; that direction is always silent.

The doc therefore leads with your second remedy rather than an ordering rule: ship a release's system contracts in ONE sysio.msig transaction — every setcode/setabi commits or fails together, so there is no mixed-version window to order. The staged fallback is sysio.reservsysio.epochsysio.system with your two edges as the reasons, sysio.opreg/sysio.uwrit free to land anywhere (they gain no inlined action and no other contract reads their new tables), and the general rule for future changes: a contract gaining an advance-inlined action deploys BEFORE sysio.epoch; a contract whose new state the gate must reserve deploys AFTER it. The downgrade direction is written down too, since it inherits both rules reversed.

Every intermediate state of that order is safe, and I checked the one that looked risky rather than asserting it: a new sysio.epoch under an old sysio.system reads a payclaimtot that has no row yet — that read is contract-side KV, so it never consults the stale ABI, and get_or_default yields a zero reserve, which is the correct answer while nothing is credited.

What I did not do is add a structural guard, and that was a decision rather than an oversight. The cheap version — gate the inline on sysio.reserv's wireclaims being non-empty, since only the new build writes that table — would make epoch-before-reserv safe automatically in any order. I left it out for three reasons: it closes 1 of the N edges above while the procedure closes all of them; it needs a layout-compatible mirror of wire_claim inside sysio.epoch (the same duplication rewards_bucket already carries, a drift surface with its own pinning test); and pre-release, every cluster is bootstrapped fresh from one build, so the mixed-version state is a launch-time concern rather than a live one. Say the word and I will add it.

On "so release tooling cannot choose an unsafe order": there is no in-tree upgrade tool to constrain — the harness bootstraps fresh clusters and deploys the whole set in one pass — so the procedure is the artifact. And a genuine mixed-version test would need a stale wasm vendored into the tree, an artifact nobody can rebuild from source, which is exactly what this repo does not commit. The nearest in-tree evidence is the guard already there and the 124 cases that failed the first time the inline went out unguarded.

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

const auto gate = check_emissions_ready(cfg.epoch_duration_sec, target_epoch);
if (!gate.ready) {
// OPP silent-return diagnostic: the epoch silently does NOT advance when the
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
Loading
Loading