diff --git a/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp b/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp index e24fe68bad..af96e12269 100644 --- a/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp +++ b/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -41,11 +40,10 @@ namespace sysio { * duplicates (`external_epoch_ref <= last`) are rejected at ingest, so no * per-reward history is retained (roll-up + data-leak safe). * - * Claimable lifespan: every credited / staged balance carries an - * `expires_at_sec`. `flushexpired` prunes anything past it; the WIRE stays - * in the `sysio.dclaim` account balance — i.e. it reverts into the staking - * capital fund for redistribution. The window is configurable - * (`setclmwindow`), defaulting to 180 days. + * Credited and imported balances never expire, including balances waiting + * for AuthX linking. They remain owed until claimed; there is no expiry + * cleanup or forfeiture to the capital fund. Unclaimed rows retain RAM + * indefinitely under this policy. * * No cooldown/withdrawal machinery for v1 (no withdrawal flow in this * wave). When withdrawals come online post-launch, the cooldown-queue + @@ -66,32 +64,14 @@ namespace sysio { /// WIRE token symbol with the system-wide nine-decimal precision. static constexpr symbol WIRE_SYM = opp::wire::asset_symbol; - // Default claimable-reward lifespan: 180 days, in seconds. Configurable - // per deployment via `setclmwindow`. - static constexpr uint32_t DEFAULT_CLAIM_WINDOW_SEC = 180u * 24u * 60u * 60u; - - // Upper bound on the configurable claim window. Expiry is computed as the - // uint32 sum `now_sec() + claim_window_sec`; an unbounded window overflows - // uint32 and wraps the expiry into the past, so freshly credited claims are - // pruned by `flushexpired` the moment they are written. 10 years is far past - // any intended reward lifespan while keeping `now + window` clear of the - // uint32 range for decades. - static constexpr uint32_t MAX_CLAIM_WINDOW_SEC = 10u * 365u * 24u * 60u * 60u; - // ----------------------------------------------------------------------- // Actions // ----------------------------------------------------------------------- - /// Initialize the config singleton (idempotent). The claimable-reward - /// window defaults to `DEFAULT_CLAIM_WINDOW_SEC`. + /// Initialize the bootstrap import config singleton (idempotent). [[sysio::action]] void setconfig(); - /// Set the claimable-reward window (seconds). Unclaimed balances older - /// than this revert to the capital fund on `flushexpired`. Auth=self. - [[sysio::action]] - void setclmwindow(uint32_t window_sec); - /// User-callable: drain the caller's `pending_claims` row via an inline /// transfer of WIRE from `sysio.dclaim` to `wire_account`. Erases the row. /// Reverts if no row exists or the balance is zero. @@ -100,13 +80,9 @@ namespace sysio { /// Internal: sweep an `unmapped_tokens` entry into `pending_claims` when /// the staker / purchaser completes AuthX linking. Called inline by - /// `sysio.authex` after a successful link. An already-expired unmapped - /// row is forfeited instead of being re-stamped with a fresh window. A - /// live row retains its absolute expiry when it creates a pending row; - /// when it joins an existing account aggregate, the later effective - /// deadline governs the aggregate so newer rewards cannot expire early. - /// Its WIRE stays in the DClaim capital fund if expired. No-op if nothing - /// matches. Auth=sysio.authex. + /// `sysio.authex` after a successful link. The parked balance joins the + /// account's pending balance regardless of age. No-op if nothing matches. + /// Auth=sysio.authex. [[sysio::action]] void linkswept(name wire_account, opp::types::ChainKind chain, @@ -143,13 +119,6 @@ namespace sysio { uint64_t external_epoch_ref, uint32_t share_bps); - /// Permissionless crank: prune up to `max_rows` expired ledger rows - /// (`pending_claims`, `unmapped_tokens`). Erasing a credited row leaves - /// its WIRE in the `sysio.dclaim` balance — it reverts into the staking - /// capital fund for redistribution. Bounded. - [[sysio::action]] - void flushexpired(uint32_t max_rows); - /// One row of an import batch: a pre-launch holder's WIRE credit on /// `chain`. `native_address` is the raw on-chain key (20 B for ETH, /// 32 B for Solana). `wire_atomic` is denominated in WIRE's 9-decimal @@ -189,13 +158,10 @@ namespace sysio { struct [[sysio::table("pclaims")]] pending_claim { name wire_account; asset balance = asset{0, WIRE_SYM}; - /// Seconds since epoch after which `flushexpired` reverts this - /// balance to the capital fund. Refreshed on every credit. - uint32_t expires_at_sec = 0; uint64_t primary_key() const { return wire_account.value; } - SYSLIB_SERIALIZE(pending_claim, (wire_account)(balance)(expires_at_sec)) + SYSLIB_SERIALIZE(pending_claim, (wire_account)(balance)) }; using pclaims_t = sysio::kv::table<"pclaims"_n, pclaim_key, pending_claim>; @@ -213,7 +179,6 @@ namespace sysio { opp::types::ChainKind chain_kind = opp::types::ChainKind::CHAIN_KIND_UNKNOWN; std::vector native_pubkey; asset balance = asset{0, WIRE_SYM}; - uint32_t expires_at_sec = 0; uint64_t primary_key() const { return id; } @@ -221,7 +186,7 @@ namespace sysio { return chain_addr_key(chain_kind, native_pubkey); } - SYSLIB_SERIALIZE(unmapped_token, (id)(chain_kind)(native_pubkey)(balance)(expires_at_sec)) + SYSLIB_SERIALIZE(unmapped_token, (id)(chain_kind)(native_pubkey)(balance)) }; using unmapped_t = sysio::kv::table<"unmapped"_n, unmapped_key, unmapped_token, @@ -263,10 +228,7 @@ namespace sysio { struct [[sysio::table("capcfg")]] cap_config { /// One-way flag protecting the bootstrap `importseed` action. bool imported_complete = false; - /// Claimable-reward window in seconds (configurable; default 180d). - uint32_t claim_window_sec = DEFAULT_CLAIM_WINDOW_SEC; - - SYSLIB_SERIALIZE(cap_config, (imported_complete)(claim_window_sec)) + SYSLIB_SERIALIZE(cap_config, (imported_complete)) }; using capcfg_t = sysio::kv::global<"capcfg"_n, cap_config>; diff --git a/contracts/sysio.dclaim/src/sysio.dclaim.cpp b/contracts/sysio.dclaim/src/sysio.dclaim.cpp index 682756e309..c72417f42b 100644 --- a/contracts/sysio.dclaim/src/sysio.dclaim.cpp +++ b/contracts/sysio.dclaim/src/sysio.dclaim.cpp @@ -15,12 +15,6 @@ using opp::types::ChainKind; // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. constexpr name ram_payer = "sysio"_n; -/// Deterministic wall-clock seconds (block time). Used for the claimable -/// window; epoch indices carried on the attestation are for audit only. -uint32_t now_sec() { - return static_cast(current_time_point().sec_since_epoch()); -} - /// Exact-match scan over a uint128 secondary index: `lower_bound` then walk /// while the narrowing key still matches, returning the first row the /// predicate accepts (or `idx.end()`). The uint128 key only narrows; the @@ -36,12 +30,6 @@ auto scan_find(Index& idx, uint128_t key, KeyFn key_of, MatchFn matches) { return idx.end(); } -/// Current claimable-reward window (seconds) from config, default if unset. -uint32_t config_window(name self) { - dclaim::capcfg_t cfg(self); - return cfg.get_or_default(dclaim::cap_config{}).claim_window_sec; -} - /// Allocate the next id from one of the monotonic counters. `pick` returns a /// reference to the field to bump. template @@ -67,47 +55,28 @@ inline void add_wire_capped(asset& balance, const asset& amt) { balance.amount += (amt.amount <= room ? amt.amount : room); } -/** - * Credit a pending account balance with an explicit absolute expiry. - * - * Normal rewards replace the aggregate expiry so a new reward refreshes the - * account-level claim window. Link migration instead retains the later - * effective deadline: folding an older parked reward into an account must not - * make newer rewards expire early. A zero deadline means no expiry and is - * therefore later than every finite deadline. - */ -void credit_pending(name self, name wacct, const asset& amt, uint32_t expires_at_sec, - bool retain_later_expiry = false) { +/// Credit a pending account balance that remains claimable indefinitely. +void credit_pending(name self, name wacct, const asset& amt) { dclaim::pclaims_t pclaims(self); auto it = pclaims.find(dclaim::pclaim_key{wacct.value}); if (it == pclaims.end()) { pclaims.emplace(ram_payer, dclaim::pclaim_key{wacct.value}, dclaim::pending_claim{ .wire_account = wacct, - .balance = amt, - .expires_at_sec = expires_at_sec }); + .balance = amt }); } else { pclaims.modify(same_payer, dclaim::pclaim_key{wacct.value}, [&](auto& r) { add_wire_capped(r.balance, amt); - if (!retain_later_expiry) { - r.expires_at_sec = expires_at_sec; - } else if (r.expires_at_sec != 0 && - (expires_at_sec == 0 || expires_at_sec > r.expires_at_sec)) { - r.expires_at_sec = expires_at_sec; - } }); } } /// Credit `amt` WIRE to the staker. Linked (`wacct` set) -> `pending_claims`; -/// otherwise parked in `unmapped_tokens` keyed by (chain, addr). A new reward -/// refreshes the destination row to now + window. Link migration bypasses this -/// helper so it can retain the parked row's original absolute expiry. +/// otherwise parked in `unmapped_tokens` keyed by (chain, addr). Both ledgers +/// retain unclaimed balances indefinitely. void credit_wire(name self, name wacct, ChainKind chain, - const std::vector& addr, const asset& amt, uint32_t window) { - const uint32_t exp = now_sec() + window; - + const std::vector& addr, const asset& amt) { if (wacct.value != 0) { - credit_pending(self, wacct, amt, exp); + credit_pending(self, wacct, amt); return; } @@ -126,13 +95,11 @@ void credit_wire(name self, name wacct, ChainKind chain, dclaim::unmapped_token{ .id = id, .chain_kind = chain, .native_pubkey = addr, - .balance = amt, - .expires_at_sec = exp }); + .balance = amt }); } else { uint64_t rid = it->id; unmapped.modify(same_payer, dclaim::unmapped_key{rid}, [&](auto& r) { add_wire_capped(r.balance, amt); - r.expires_at_sec = exp; }); } } @@ -186,22 +153,6 @@ void dclaim::setconfig() { } } -// --------------------------------------------------------------------------- -// setclmwindow -// --------------------------------------------------------------------------- -void dclaim::setclmwindow(uint32_t window_sec) { - require_auth(get_self()); - check(window_sec > 0, "window_sec must be positive"); - // Reject a window so large that `now_sec() + window_sec` overflows uint32 and - // wraps the claim expiry into the past, which would let flushexpired prune - // freshly credited rewards immediately. - check(window_sec <= MAX_CLAIM_WINDOW_SEC, "window_sec exceeds the ten-year ceiling"); - capcfg_t cfg(get_self()); - cap_config c = cfg.get_or_default(cap_config{}); - c.claim_window_sec = window_sec; - cfg.set(c, ram_payer); -} - // --------------------------------------------------------------------------- // claim // --------------------------------------------------------------------------- @@ -238,19 +189,10 @@ void dclaim::linkswept(name wire_account, ChainKind chain, std::vector nat return r.chain_kind == chain && r.native_pubkey == native_pubkey; }); if (uit != uidx.end()) { - // Linking must not give an already-expired parked balance a fresh claim window. Erase it - // exactly as flushexpired would: its WIRE remains in the DClaim capital fund. - if (uit->expires_at_sec != 0 && now_sec() >= uit->expires_at_sec) { - unmapped.erase(unmapped_key{uit->id}); - return; - } const asset bal = uit->balance; - const uint32_t expires_at_sec = uit->expires_at_sec; const uint64_t row_id = uit->id; unmapped.erase(unmapped_key{row_id}); - // Preserve the parked row's deadline when it creates the account aggregate. If an aggregate - // already exists, its later effective deadline governs so newer rewards cannot expire early. - credit_pending(get_self(), wire_account, bal, expires_at_sec, true); + credit_pending(get_self(), wire_account, bal); } } @@ -294,8 +236,7 @@ void dclaim::onreward(uint64_t chain_code, // conversion and source-chain precision scaling are outpost-side -- so the // claim ledger is credited directly. credit_wire(get_self(), wacct, reward_chain, staker_native_addr, - asset{ static_cast(reward_amount), WIRE_SYM }, - config_window(get_self())); + asset{ static_cast(reward_amount), WIRE_SYM }); // Pull funding from sysio.system's drainable pool so the dclaim balance // covers this credit immediately -- a staker can claim in the next block @@ -310,35 +251,6 @@ void dclaim::onreward(uint64_t chain_code, ).send(); } -// --------------------------------------------------------------------------- -// flushexpired — prune expired rows; credited WIRE reverts to the capital -// fund (it simply stays in the sysio.dclaim balance once the row is erased). -// --------------------------------------------------------------------------- -void dclaim::flushexpired(uint32_t max_rows) { - const uint32_t cutoff = now_sec(); - uint32_t budget = max_rows; - - pclaims_t pclaims(get_self()); - for (auto it = pclaims.begin(); it != pclaims.end() && budget > 0; ) { - const pending_claim row = *it; - ++it; - if (row.expires_at_sec != 0 && cutoff >= row.expires_at_sec) { - pclaims.erase(pclaim_key{row.wire_account.value}); - --budget; - } - } - - unmapped_t unmapped(get_self()); - for (auto it = unmapped.begin(); it != unmapped.end() && budget > 0; ) { - const unmapped_token row = *it; - ++it; - if (row.expires_at_sec != 0 && cutoff >= row.expires_at_sec) { - unmapped.erase(unmapped_key{row.id}); - --budget; - } - } -} - // --------------------------------------------------------------------------- // importseed — bootstrap pre-launch holders into unmapped_tokens // --------------------------------------------------------------------------- @@ -351,18 +263,16 @@ void dclaim::importseed(ChainKind chain, std::vector credits) { if (credits.empty()) return; - const uint32_t window = current_cfg.claim_window_sec; - for (const auto& credit : credits) { check(credit.wire_atomic >= 0, "negative wire_atomic"); check(!credit.native_address.empty(), "empty native_address"); if (credit.wire_atomic == 0) continue; // Pre-launch holders are unlinked by definition -> name{} routes the - // credit to unmapped_tokens, with the same upsert + expiry path as + // credit to unmapped_tokens, with the same non-expiring upsert path as // staking rewards (one implementation in credit_wire). credit_wire(get_self(), name{}, chain, credit.native_address, - asset{ credit.wire_atomic, WIRE_SYM }, window); + asset{ credit.wire_atomic, WIRE_SYM }); } } diff --git a/contracts/sysio.dclaim/sysio.dclaim.abi b/contracts/sysio.dclaim/sysio.dclaim.abi index 81cd477f62..e3d84170fe 100644 --- a/contracts/sysio.dclaim/sysio.dclaim.abi +++ b/contracts/sysio.dclaim/sysio.dclaim.abi @@ -10,10 +10,6 @@ { "name": "imported_complete", "type": "bool" - }, - { - "name": "claim_window_sec", - "type": "uint32" } ] }, @@ -41,16 +37,6 @@ } ] }, - { - "name": "flushexpired", - "base": "", - "fields": [ - { - "name": "max_rows", - "type": "uint32" - } - ] - }, { "name": "import_credit", "base": "", @@ -161,10 +147,6 @@ { "name": "balance", "type": "asset" - }, - { - "name": "expires_at_sec", - "type": "uint32" } ] }, @@ -204,16 +186,6 @@ } ] }, - { - "name": "setclmwindow", - "base": "", - "fields": [ - { - "name": "window_sec", - "type": "uint32" - } - ] - }, { "name": "setconfig", "base": "", @@ -248,10 +220,6 @@ { "name": "balance", "type": "asset" - }, - { - "name": "expires_at_sec", - "type": "uint32" } ] } @@ -262,11 +230,6 @@ "type": "claim", "ricardian_contract": "" }, - { - "name": "flushexpired", - "type": "flushexpired", - "ricardian_contract": "" - }, { "name": "importdone", "type": "importdone", @@ -287,11 +250,6 @@ "type": "onreward", "ricardian_contract": "" }, - { - "name": "setclmwindow", - "type": "setclmwindow", - "ricardian_contract": "" - }, { "name": "setconfig", "type": "setconfig", diff --git a/contracts/sysio.dclaim/sysio.dclaim.wasm b/contracts/sysio.dclaim/sysio.dclaim.wasm index d824580e6a..fae4f84a10 100755 Binary files a/contracts/sysio.dclaim/sysio.dclaim.wasm and b/contracts/sysio.dclaim/sysio.dclaim.wasm differ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp index cb8114b322..60f97e6a2e 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp @@ -21,25 +21,24 @@ * 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. + * these helpers generalize it so `sysio.system` and `sysio.opreg` share one + * audited implementation rather than duplicate 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 + * without corrupting that contract's read-only-action return codegen. 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 units of the - * row's token (WIRE for `payclaims` and `wireclaims`; the + * row's token (WIRE for `payclaims`; the * row's own `token_code` for `sysio.opreg::remitclaims`) -- * these helpers never name a symbol; `pay_out`'s caller * supplies it - * * `uint32_t expires_at_sec` -- optional; when present it is maintained by `credit` and - * makes the row eligible for `sweep_expired` + * All consumers retain credited balances indefinitely, until the recipient claims them. */ #include @@ -51,28 +50,9 @@ #include #include -#include -#include -#include namespace sysio::opp::claimable { -/// Compile-time detection of the optional `expires_at_sec` member on a claimable row. -/// -/// A row that omits the field opts out of expiry entirely: nothing stamps it and `sweep_expired` -/// cannot select it. Every row in this tree currently CARRIES the field -- `payclaims`, -/// `wireclaims` and `remitclaims` alike -- so the false branch is the shape a future bounded-set -/// contract may choose, not a description of any table today. Whether a stamped row is ever acted -/// on is a separate, per-contract decision (only `wireclaims` is swept; see WIRE-339). -template -struct has_expiry : std::false_type {}; - -template -struct has_expiry().expires_at_sec)>> : std::true_type {}; - -template -inline constexpr bool has_expiry_v = has_expiry::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` @@ -96,26 +76,16 @@ inline uint64_t add_capped(uint64_t balance, uint64_t amount) { /// @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`). +/// fields (`account`, ...) and this function sets `balance`. /// @param amount atomic units to credit, in the caller's token (see the row contract above). -/// @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 -void credit(Table& tbl, sysio::name payer, const Key& key, Row fresh, uint64_t amount, - uint32_t expires_at_sec = 0) { +void credit(Table& tbl, sysio::name payer, const Key& key, Row fresh, uint64_t amount) { if (amount == 0) return; fresh.balance = add_capped(0, amount); - if constexpr (has_expiry_v) { - 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) { - r.expires_at_sec = expires_at_sec; - } }); } @@ -154,47 +124,4 @@ uint64_t pay_out(Table& tbl, const Key& key, sysio::name self, sysio::name token return amount; } -/// 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 -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; - - std::vector 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 diff --git a/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp b/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp index f370b51596..45c649a350 100644 --- a/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp +++ b/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp @@ -308,8 +308,8 @@ namespace sysio { void sweepyield(sysio::slug_name token_code); /// Permissionless: no authority is required, because the credit can only land in `account`'s - /// own `remitclaims{account, WIRE}` row -- a keeper may crank it, e.g. to rescue a TERMINATED - /// operator's yield before `prune` erases the row. First, when the registry's own `sysio.liq` + /// own `remitclaims{account, WIRE}` row. Banked yield survives pruning and re-registration in + /// `yielddebts`; no operator record is required to collect that debt. First, when the registry's own `sysio.liq` /// row is owed anything, pull it in (`opp::shadow::custody::pull`: push /// `sysio.liq::claim(self, symbol)` and add it to `yieldpool[token_code].received`); a failure /// in that claim reaches only whoever signed this action. Then settle the operator's @@ -317,8 +317,8 @@ namespace sysio { /// `min(owed, received - credited)` to /// `remitclaims{account, opp::wire::token_code}`, taking it off the row; the operator pulls /// the WIRE with `claimremit(account, WIRE)`. Works in every status, including SLASHED (yield - /// earned before a slash stays claimable) and TERMINATED -- a terminated operator must claim - /// before `prune` erases its row, when anything still owed is forfeited (see `terminate`). + /// earned before a slash stays claimable) and TERMINATED. Neither removing the operator row + /// nor re-registering expires or redirects earned yield; unpaid debt never accrues new yield. /// /// Reverts with "no yield owed" when the row has earned nothing (or is not a shadow row), and /// with a rounding-dust message when all it is owed is dust the pool does not cover -- the @@ -336,7 +336,8 @@ namespace sysio { /// first-come-first-served across operators, so a dust shortfall falls on whoever claims last, /// not on the row whose flooring created it. The mechanism /// is the `opp::shadow::custody` library; this contract supplies only the policy (which rows - /// earn, where credits go, and the claim window). + /// earn and where credits go). Debt and live yield share this same backing cap. A full WIRE + /// remit rejects the claim atomically rather than saturating away earned value. [[sysio::action]] void claimyield(name account, sysio::slug_name token_code); @@ -505,25 +506,28 @@ namespace sysio { SYSLIB_SERIALIZE(remitclaim_key, (account)(token_code)) }; - static constexpr uint32_t REMIT_CLAIM_WINDOW_SEC = 365 * 24 * 60 * 60; - + /// Returned collateral and credited yield remain available indefinitely, independently of operator records. struct [[sysio::table("remitclaims")]] remit_claim { sysio::name account; sysio::slug_name token_code; ///< Depot-native token owed: WIRE or a shadow LIQ code. uint64_t balance = 0; ///< Atomic units of `token_code` owed, not yet claimed. - uint32_t expires_at_sec = 0; // recorded by `credit`; read by nothing yet (WIRE-339) - uint128_t by_expiry() const { - return (static_cast(expires_at_sec) << 64) | account.value; - } + SYSLIB_SERIALIZE(remit_claim, (account)(token_code)(balance)) + }; + + using remitclaims_t = sysio::kv::table<"remitclaims"_n, remitclaim_key, remit_claim>; - SYSLIB_SERIALIZE(remit_claim, (account)(token_code)(balance)(expires_at_sec)) + /// Earned WIRE not yet covered by a shadow token's yield pool, retained across operator + /// pruning and re-registration. No new yield accrues here; only banked debt is transferred. + /// The wider accumulator preserves debts from repeated registrations without saturation. + struct [[sysio::table("yielddebts")]] yield_debt { + sysio::name account; + sysio::slug_name token_code; + uint128_t owed_wire = 0; + SYSLIB_SERIALIZE(yield_debt, (account)(token_code)(owed_wire)) }; - using remitclaims_t = sysio::kv::table<"remitclaims"_n, remitclaim_key, remit_claim, - sysio::kv::index<"byexpiry"_n, - sysio::const_mem_fun> - >; + using yielddebts_t = sysio::kv::table<"yielddebts"_n, remitclaim_key, yield_debt>; /// Key of a `yieldpool` row: the depot-native shadow token whose yield the row accounts for. struct yield_pool_key { diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 53ca4422ea..78ca771f85 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -15,6 +15,7 @@ #include #include #include +#include namespace sysio { @@ -56,6 +57,12 @@ constexpr std::string_view no_yield_owed_msg = "no yield owed"; constexpr std::string_view yield_not_covered_msg = "owed yield is rounding dust the WIRE received from sysio.liq does not cover; only later slack can cover it"; +/// A corrupt or exhausted archived-debt accumulator cannot be erased with its operator record. +constexpr std::string_view yield_debt_overflow_msg = "yield debt overflow"; + +/// Collecting yield must not saturate away value already removed from a debt and backing pool. +constexpr std::string_view yield_remit_full_msg = "claim WIRE remit before collecting more yield"; + /// Message of the `check` `sweepyield` raises for WIRE, which is not a shadow token. constexpr std::string_view wire_earns_no_yield_msg = "WIRE collateral earns no shadow yield"; @@ -92,16 +99,9 @@ bool earns_shadow_yield(sysio::slug_name chain_code, sysio::slug_name token_code void credit_remit_claim(name self, name account, sysio::slug_name token_code, uint64_t amount) { if (amount == 0) return; - // The expiry stamp is RECORDED, not acted on: no sweep is wired against `remitclaims` (see the - // note on the table). Refreshing it on every credit means an operator still being remitted - // never ages, so the stamp already carries the "last had activity" signal a future retention - // pass (WIRE-339) needs, rather than that history starting the day a sweep lands. - const uint32_t now_sec = current_time_point().sec_since_epoch(); - opreg::remitclaims_t claims(self); sysio::opp::claimable::credit(claims, ram_payer, opreg::remitclaim_key{account.value, token_code}, - opreg::remit_claim{.account = account, .token_code = token_code}, amount, - now_sec + opreg::REMIT_CLAIM_WINDOW_SEC); + opreg::remit_claim{.account = account, .token_code = token_code}, amount); } uint64_t current_time_ms() { @@ -175,6 +175,23 @@ bool is_fully_settled(const opreg::operator_entry& op) { return true; } +/// Preserve already-banked yield before erasing a principal-settled operator. Both callers are +/// caller-signed actions, so an impossible accumulator overflow rejects the erase atomically. +/// This does not create backed claims or touch a yield pool; claimyield later applies its cap. +void preserve_yield_debt(name self, const opreg::operator_entry& op) { + opreg::yielddebts_t debts(self); + for (const auto& balance : op.balances) { + const uint64_t owed = balance.shadow_yield.owed_wire; + if (owed == 0) continue; + const opreg::remitclaim_key key{op.account.value, balance.token_code}; + auto debt = debts.try_get(key).value_or(opreg::yield_debt{ + .account = op.account, .token_code = balance.token_code}); + check(debt.owed_wire <= ~uint128_t{0} - owed, yield_debt_overflow_msg); + debt.owed_wire += owed; + debts.upsert(ram_payer, key, debt); + } +} + } // anonymous namespace // --------------------------------------------------------------------------- @@ -308,6 +325,7 @@ void opreg::regoperator(name account, check(is_fully_settled(existing), "operator has unsettled collateral: a terminated operator may only " "re-register once its balances are drained"); + preserve_yield_debt(get_self(), existing); ops.erase(op_pk); } @@ -503,6 +521,15 @@ uint64_t balance_of(const opreg::operator_entry& o, namespace custody = opp::shadow::custody; +/// Take backed yield up to the destination remit's remaining capacity, leaving the untaken +/// entitlement on the original position. The custody helper remains the sole backing cap. +uint64_t take_bounded_yield(custody::position& position, custody::yield_pool& pool, uint64_t limit) { + custody::position payable{.owed_wire = std::min(position.owed_wire, limit)}; + const uint64_t taken = custody::take(payable, pool); + position.owed_wire -= taken; + return taken; +} + /// The `sysio.liq` shadow symbol behind depot-native `token_code`, or `std::nullopt` when the /// resolver finds none (WIRE, or an unknown code). Never throws, so the never-throw paths skip on /// `std::nullopt` -- impossible for a row that was bonded through the resolver. @@ -526,13 +553,15 @@ std::optional earning_symbol(const opreg::balance_entry& b) { /// covers (`custody::settle_and_take`); the uncovered rest stays banked on the row. Returns the /// amount taken, which the caller credits to the operator's WIRE claim; 0 for any other row. Never /// throws. -uint64_t take_yield(name self, opreg::balance_entry& b) { +uint64_t take_yield(name self, opreg::balance_entry& b, + uint64_t limit = std::numeric_limits::max()) { const auto sym = earning_symbol(b); if (!sym) return 0; opreg::yieldpool_t pools(self); const opreg::yield_pool_key key{b.token_code}; auto pool = pools.try_get(key).value_or(custody::yield_pool{}); - const uint64_t taken = custody::settle_and_take(b.shadow_yield, b.balance, pool, opreg::LIQ_ACCOUNT, *sym); + custody::settle(b.shadow_yield, b.balance, custody::live_index(opreg::LIQ_ACCOUNT, *sym)); + const uint64_t taken = take_bounded_yield(b.shadow_yield, pool, limit); if (taken > 0) pools.upsert(ram_payer, key, pool); return taken; } @@ -1305,7 +1334,7 @@ void terminate_inline(name self, name account, const std::string& reason) { } // Settle earned shadow yield alongside principal, up to the yield pool balance. - // Uncovered yield stays banked for claimyield during the termination claim window. + // Uncovered yield stays banked for claimyield indefinitely, including after operator removal. std::vector yield_credits; ops.modify(same_payer, op_pk, [&](auto& o) { o.status = OperatorStatus::OPERATOR_STATUS_TERMINATED; @@ -1381,34 +1410,63 @@ void opreg::sweepyield(sysio::slug_name token_code) { // claimyield - credit an operator's earned shadow yield to its WIRE claim row. // // Permissionless: the credit can only land in `account`'s own `remitclaims{account, WIRE}` row, so -// anyone may crank it -- a keeper can rescue a TERMINATED operator's yield before `prune` erases the -// row. First pulls whatever sysio.liq owes the registry's row into the pool, then credits the +// anyone may crank it, including after pruning or re-registration archived the debt. +// First pulls whatever sysio.liq owes the registry's row into the pool, then credits the // row's earned yield up to what the pool covers. Credits rather than transfers, like every other // payout here, so the WIRE leaves only through `claimremit` under the operator's own authority. // Allowed in any status: yield earned before a slash stays the operator's. void opreg::claimyield(name account, sysio::slug_name token_code) { operators_t ops(get_self()); const auto op_pk = operator_key{account.value}; - check(ops.contains(op_pk), "operator not found"); + yielddebts_t debts(get_self()); + const remitclaim_key debt_key{account.value, token_code}; + auto debt = debts.try_get(debt_key); + const bool has_operator = ops.contains(op_pk); + check(has_operator || debt.has_value(), "operator not found"); + + remitclaims_t claims(get_self()); + const auto existing = claims.try_get(remitclaim_key{account.value, opp::wire::token_code}); + const uint64_t balance = existing ? existing->balance : 0; + check(balance < opp::safe::depot_amount_max, yield_remit_full_msg); + const uint64_t room = opp::safe::depot_amount_max - balance; // WIRE and unknown codes have no shadow symbol and fall through to "no yield owed" below. if (const auto sym = shadow_symbol_of(token_code)) { pull_registry_yield(get_self(), token_code, *sym); } - uint64_t credited = 0; - uint64_t remaining = 0; - ops.modify(same_payer, op_pk, [&](auto& o) { - for (auto& b : o.balances) { - if (b.chain_code == opp::wire::chain_code && b.token_code == token_code) { - credited = take_yield(get_self(), b); - remaining = b.shadow_yield.owed_wire; - } + uint64_t credited = 0; + bool has_remaining = false; + if (debt) { + yieldpool_t pools(get_self()); + const yield_pool_key pool_key{token_code}; + auto pool = pools.try_get(pool_key).value_or(custody::yield_pool{}); + custody::position banked{.owed_wire = static_cast( + std::min(debt->owed_wire, uint128_t{std::numeric_limits::max()}))}; + const uint64_t taken = take_bounded_yield(banked, pool, room); + credited = taken; + debt->owed_wire -= taken; + has_remaining = debt->owed_wire > 0; + if (taken > 0) { + pools.upsert(ram_payer, pool_key, pool); + if (debt->owed_wire == 0) debts.erase(debt_key); + else debts.upsert(ram_payer, debt_key, *debt); } - }); - check(credited > 0 || remaining > 0, no_yield_owed_msg); + } + if (has_operator) { + ops.modify(same_payer, op_pk, [&](auto& o) { + for (auto& b : o.balances) { + if (b.chain_code == opp::wire::chain_code && b.token_code == token_code) { + credited += take_yield(get_self(), b, room - credited); + has_remaining = has_remaining || b.shadow_yield.owed_wire > 0; + } + } + }); + } + check(credited > 0 || has_remaining, no_yield_owed_msg); check(credited > 0, yield_not_covered_msg); + // Both takes were limited to the same remaining room, so credit cannot saturate away debt. // No OperatorAction names a yield claim, so none is appended to `recent_actions`. credit_remit_claim(get_self(), account, opp::wire::token_code, credited); } @@ -1547,7 +1605,7 @@ void opreg::prune() { operators_t ops(get_self()); auto status_idx = ops.get_index<"bystatus"_n>(); - // Wait for both the retention delay and complete principal/yield settlement. + // Wait for the retention delay and principal settlement; preserve unpaid yield separately. // Count examined rows so unsettled entries cannot make this crank unbounded. uint32_t examined = 0; for (auto it = status_idx.lower_bound( @@ -1559,6 +1617,7 @@ void opreg::prune() { const bool delay_elapsed = it->terminated_at > 0 && now - it->terminated_at >= cfg.terminate_prune_delay_ms; if (delay_elapsed && is_fully_settled(*it)) { + preserve_yield_debt(get_self(), *it); it = status_idx.erase(std::move(it)); } else { ++it; diff --git a/contracts/sysio.opreg/sysio.opreg.abi b/contracts/sysio.opreg/sysio.opreg.abi index 397c5339e9..0e544d5f7d 100644 --- a/contracts/sysio.opreg/sysio.opreg.abi +++ b/contracts/sysio.opreg/sysio.opreg.abi @@ -520,10 +520,6 @@ { "name": "balance", "type": "uint64" - }, - { - "name": "expires_at_sec", - "type": "uint32" } ] }, @@ -717,6 +713,24 @@ } ] }, + { + "name": "yield_debt", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "owed_wire", + "type": "uint128" + } + ] + }, { "name": "yield_pool", "base": "", @@ -900,14 +914,7 @@ "index_type": "i64", "key_names": ["account","token_code"], "key_types": ["uint64","slug_name"], - "table_id": 45499, - "secondary_indexes": [ - { - "name": "byexpiry", - "key_type": "uint128", - "table_id": 43905 - } - ] + "table_id": 45499 }, { "name": "wtdwqueue", @@ -929,6 +936,14 @@ } ] }, + { + "name": "yielddebts", + "type": "yield_debt", + "index_type": "i64", + "key_names": ["account","token_code"], + "key_types": ["uint64","slug_name"], + "table_id": 2779 + }, { "name": "yieldpool", "type": "yield_pool", diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index bd09c28a12..4663042a2f 100755 Binary files a/contracts/sysio.opreg/sysio.opreg.wasm and b/contracts/sysio.opreg/sysio.opreg.wasm differ diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index ddddb3ec8c..cc6a64ba3b 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -179,8 +179,9 @@ kicker (`kicker_bps` of each intake from the swap, the intake that is yield) fro the pool, so `fundclaim` names its recipient, which must be one of those two contracts. -Unclaimed rows expire after `cap_config.claim_window_sec` and revert to the -dclaim pool via `flushexpired`. `fundclaim` and the whole OPP inbound path are +Unclaimed DClaim balances never expire, including rewards and imported credits +awaiting AuthX linking. They remain owed until claimed and are not forfeited to +the capital fund; their rows retain RAM indefinitely. `fundclaim` and the whole OPP inbound path are never-throw (transfers are capped / soft-dropped so a bad row cannot abort the message chain), whereas `claimnodedis` and `claim` are ordinary user actions that `check`-abort on bad input. diff --git a/contracts/sysio.system/include/sysio.system/emissions.hpp b/contracts/sysio.system/include/sysio.system/emissions.hpp index 7a09910be6..574c2a147e 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -235,49 +235,26 @@ struct node_claim_result { // operators churn, and every departed account that never calls `claimpay` leaves a row billed to // the sysio RAM pool forever, so system-funded claim storage grows with historical participants // rather than with the live set. That is a known, accepted cost here: expiring earned pay is an -// economic decision, not a RAM one, and the alternative (`sysio.reserv::wireclaims`, whose -// recipient set is unbounded AND caller-influenced) buys its sweep by forfeiting balances. +// economic decision, not a RAM one. Returned collateral, banked operator yield and DClaim +// rewards follow the same no-expiry policy. // -// 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 by the time WIRE-339 picks the question up there is real age data to -// reason about instead of 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, so the -// stamp is recorded and ignored. `PAY_CLAIM_WINDOW_SEC` is the provisional window that stamp is -// computed from, not a commitment — whether earned pay should be forfeited at all, and over what -// window, is the open economic decision in WIRE-339. +/// WIRE-339 policy: earned pay never expires, including after a recipient stops participating. +/// Rows deliberately omit expiry metadata and indexes; balances remain reserved until claimed. struct payclaim_key { uint64_t account_name; SYSLIB_SERIALIZE(payclaim_key, (account_name)) }; -// Provisional retention window used only to compute `pay_claim::expires_at_sec`. Mirrors -// `sysio.reserv::WIRE_CLAIM_WINDOW_SEC` so the two claimable tables age on the same scale; the -// value is revisited when (and if) a sweep is wired. -inline constexpr uint32_t PAY_CLAIM_WINDOW_SEC = 365 * 24 * 60 * 60; - +/// Earned WIRE held indefinitely for the recipient to claim. struct [[sysio::table("payclaims"), sysio::contract("sysio.system")]] pay_claim { sysio::name account_name; - uint64_t balance = 0; // atomic WIRE 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(expires_at_sec) << 64) | account_name.value; - } + uint64_t balance = 0; ///< Atomic WIRE units owed, not yet claimed. - SYSLIB_SERIALIZE(pay_claim, (account_name)(balance)(expires_at_sec)) + SYSLIB_SERIALIZE(pay_claim, (account_name)(balance)) }; -using payclaims_t = sysio::kv::table<"payclaims"_n, payclaim_key, pay_claim, - sysio::kv::index<"byexpiry"_n, - sysio::const_mem_fun> ->; +using payclaims_t = sysio::kv::table<"payclaims"_n, payclaim_key, pay_claim>; // Running total of every outstanding `payclaims` balance. // diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 9559066077..a6299c2d40 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -202,16 +202,9 @@ void credit_pay(name self, name to, int64_t amount, std::string_view /*memo_str* if (amount <= 0) return; const uint64_t amt = static_cast(amount); - // The expiry stamp is RECORDED, not acted on: no sweep is wired against `payclaims` (see the - // note on the table). Refreshing it on every credit means an account still being paid never - // ages, so the stamp already carries the "last had activity" signal a future retention pass - // (WIRE-339) needs, rather than that history starting the day a sweep lands. - const uint32_t now_sec = current_time_point().sec_since_epoch(); - payclaims_t claims(self); sysio::opp::claimable::credit(claims, self, payclaim_key{to.value}, - pay_claim{.account_name = to}, amt, - now_sec + PAY_CLAIM_WINDOW_SEC); + pay_claim{.account_name = to}, amt); // Reserve the credited WIRE so fundclaim and the epoch gate cannot re-commit it. Saturates on // the same cap as the row itself, keeping the counter consistent with the sum of the rows. diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index fb7454bbdc..76c60e37d9 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -915,10 +915,6 @@ { "name": "balance", "type": "uint64" - }, - { - "name": "expires_at_sec", - "type": "uint32" } ] }, @@ -2315,14 +2311,7 @@ "index_type": "i64", "key_names": ["account_name"], "key_types": ["uint64"], - "table_id": 829, - "secondary_indexes": [ - { - "name": "byexpiry", - "key_type": "uint128", - "table_id": 24323 - } - ] + "table_id": 829 }, { "name": "payclaimtot", diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 26a5ad5f60..aef3622d03 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ diff --git a/contracts/tests/depot_flow_coverage.json b/contracts/tests/depot_flow_coverage.json index 450d259d20..823de4e9b7 100644 --- a/contracts/tests/depot_flow_coverage.json +++ b/contracts/tests/depot_flow_coverage.json @@ -22,7 +22,7 @@ "tests": [ "sysio_msgch_chain_tests/terminate_at_duty_rotation_via_advance", "sysio_opreg_tests/termcheck_terminates_after_default_consecutive_boundary", - "sysio_opreg_tests/claimremit_pays_terminated_operator_and_clears_row", + "sysio_opreg_tests/claimremit_never_expires_and_clears_row_after_payment", "sysio_opreg_tests/terminate_credits_earned_yield_with_the_principal" ] }, diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 186f3cf4cc..72f8ffdf59 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -3499,6 +3499,37 @@ BOOST_FIXTURE_TEST_CASE( blocking_producer_cannot_stall_payepoch, sysio_emission BOOST_REQUIRE_EQUAL( 0, get_wire_balance("producerb"_n).get_amount() ); } FC_LOG_AND_RETHROW() +/// Earned pay remains reserved and claimable after years without a new credit. +BOOST_FIXTURE_TEST_CASE( payclaims_never_expire, sysio_emissions_tester ) try { + create_t5_holding_accounts(); + setup_producers(3); + wait_for_producer_schedule(); + produce_complete_cycles(3, 2); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + + const auto producer = "producera"_n; + const int64_t owed = pay_claimable(producer); + const int64_t outstanding = pay_outstanding_total(); + const int64_t balance_before_claim = get_wire_balance(producer).get_amount(); + BOOST_REQUIRE_GT( owed, 0 ); + + constexpr uint32_t INACTIVE_DAYS = 3 * 365; + produce_block(); + produce_block(fc::days(INACTIVE_DAYS)); + produce_blocks(2); + BOOST_REQUIRE_EQUAL( owed, pay_claimable(producer) ); + BOOST_REQUIRE_EQUAL( outstanding, pay_outstanding_total() ); + + BOOST_REQUIRE_EQUAL( success(), + push_system_action(producer, "claimpay"_n, mvo()("account_name", producer)) ); + BOOST_REQUIRE_EQUAL( balance_before_claim + owed, get_wire_balance(producer).get_amount() ); + BOOST_REQUIRE_EQUAL( 0, pay_claimable(producer) ); + BOOST_REQUIRE_EQUAL( outstanding - owed, pay_outstanding_total() ); +} FC_LOG_AND_RETHROW() + // --------------------------------------------------------------------------- // Holding account stub transfers // --------------------------------------------------------------------------- diff --git a/contracts/tests/sysio.authex_tests.cpp b/contracts/tests/sysio.authex_tests.cpp index aaf088c08e..35c7c23666 100644 --- a/contracts/tests/sysio.authex_tests.cpp +++ b/contracts/tests/sysio.authex_tests.cpp @@ -337,20 +337,21 @@ BOOST_FIXTURE_TEST_CASE( createlink_eth_sweeps_prelink_dclaim_rewards, sysio_aut BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 5000); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( createlink_forfeits_expired_prelink_dclaim_rewards, +BOOST_FIXTURE_TEST_CASE( createlink_preserves_old_prelink_dclaim_rewards, sysio_authex_tester ) try { deploy_dclaim(); - BOOST_REQUIRE_EQUAL(success(), - sysio_system::test_support::push_contract_action_and_produce_block( - *this, DCLAIM, dclaim_abi_ser, DCLAIM, "setclmwindow"_n, - mvo()("window_sec", uint32_t{1}))); auto link = make_eth_link("alice", now_ms()); const auto address_bytes = fc::crypto::ethereum::address_to_bytes(link.pub); const std::vector native_address(address_bytes.begin(), address_bytes.end()); BOOST_REQUIRE_EQUAL(success(), onreward(native_address, 5000)); BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); - produce_blocks(10); - produce_block(fc::seconds(5)); + constexpr uint32_t years_without_link_sec = 3u * 365u * 24u * 60u * 60u; + produce_block(fc::seconds(years_without_link_sec)); + // Refresh the link proof's nonce after the wait; the reward itself remains old. + link.nonce = now_ms(); + const auto message = build_link_message(link.pub, "alice", ChainKind::CHAIN_KIND_EVM, link.nonce); + const auto message_hash = fc::crypto::keccak256::hash(message); + link.sig = link.priv.sign(fc::sha256(reinterpret_cast(message_hash.data()), 32)); BOOST_REQUIRE_EQUAL(success(), createlink( "alice"_n, ChainKind::CHAIN_KIND_EVM, "alice", link.sig, link.pub, link.nonce)); @@ -358,7 +359,9 @@ BOOST_FIXTURE_TEST_CASE( createlink_forfeits_expired_prelink_dclaim_rewards, BOOST_REQUIRE(!get_link(0).is_null()); BOOST_REQUIRE(get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); - BOOST_REQUIRE(get_dclaim_row("pclaims"_n, "pending_claim", "alice"_n.to_uint64_t()).is_null()); + const auto pending = get_dclaim_row("pclaims"_n, "pending_claim", "alice"_n.to_uint64_t()); + BOOST_REQUIRE(!pending.is_null()); + BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 5000); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( recordlink_records_link_when_dclaim_is_missing, sysio_authex_tester ) try { diff --git a/contracts/tests/sysio.dclaim_tests.cpp b/contracts/tests/sysio.dclaim_tests.cpp index 09414ed258..3dab0dd6d3 100644 --- a/contracts/tests/sysio.dclaim_tests.cpp +++ b/contracts/tests/sysio.dclaim_tests.cpp @@ -26,6 +26,8 @@ class sysio_dclaim_tester : public tester { static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr uint32_t YEARS_WITHOUT_CLAIM_SEC = 3u * 365u * 24u * 60u * 60u; + inline static const symbol WIRE_SYMBOL{9, "WIRE"}; sysio_dclaim_tester() { produce_blocks(2); @@ -95,6 +97,35 @@ class sysio_dclaim_tester : public tester { fc::variant unmapped_row(uint64_t id) { return get_kv("unmapped"_n, "unmapped_token", id); } fc::variant cursor_row(uint64_t id) { return get_kv("rwdcursors"_n, "reward_cursor", id); } + /// Fund DClaim with real WIRE so delayed claims exercise transfers and row erasure. + void fund_claims() { + set_code(TOKEN_ACCOUNT, contracts::token_wasm()); + set_abi(TOKEN_ACCOUNT, contracts::token_abi().data()); + set_privileged(TOKEN_ACCOUNT); + produce_blocks(); + base_tester::push_action(TOKEN_ACCOUNT, "create"_n, TOKEN_ACCOUNT, + mvo()("issuer", "sysio")("maximum_supply", "1000000000.000000000 WIRE")); + base_tester::push_action(TOKEN_ACCOUNT, "issue"_n, config::system_account_name, + mvo()("to", "sysio")("quantity", "1.000000000 WIRE")("memo", "seed")); + base_tester::push_action(TOKEN_ACCOUNT, "transfer"_n, config::system_account_name, + mvo()("from", "sysio")("to", DCLAIM_ACCOUNT) + ("quantity", "1.000000000 WIRE")("memo", "fund claims")); + } + + /// Claim once after arbitrary inactivity, checking exact payment and replay rejection. + void check_claim(name account, int64_t amount) { + const auto before = get_currency_balance(TOKEN_ACCOUNT, WIRE_SYMBOL, account); + const auto funding_before = get_currency_balance(TOKEN_ACCOUNT, WIRE_SYMBOL, DCLAIM_ACCOUNT); + BOOST_REQUIRE_EQUAL(push_dclaim(account, "claim"_n, mvo()("wire_account", account)), success()); + BOOST_REQUIRE(pending_of(account).is_null()); + BOOST_REQUIRE_EQUAL(get_currency_balance(TOKEN_ACCOUNT, WIRE_SYMBOL, account).get_amount(), + before.get_amount() + amount); + BOOST_REQUIRE_EQUAL(get_currency_balance(TOKEN_ACCOUNT, WIRE_SYMBOL, DCLAIM_ACCOUNT).get_amount(), + funding_before.get_amount() - amount); + BOOST_REQUIRE_EQUAL(push_dclaim(account, "claim"_n, mvo()("wire_account", account)), + wasm_assert_msg("no pending claim")); + } + std::vector addr20{std::vector(20, char(0xA1))}; abi_serializer dclaim_abi_ser; @@ -138,28 +169,6 @@ BOOST_FIXTURE_TEST_CASE(importdone_locks_subsequent_importseed, sysio_dclaim_tes success()); } FC_LOG_AND_RETHROW() } -// -- setclmwindow -- - -BOOST_FIXTURE_TEST_CASE(setclmwindow_rejects_zero, sysio_dclaim_tester) { try { - BOOST_REQUIRE_NE(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, mvo()("window_sec", 0)), success()); - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, mvo()("window_sec", 3600)), success()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setclmwindow_rejects_excess_window, sysio_dclaim_tester) { try { - // The ten-year ceiling is accepted; one second beyond it is rejected before - // the window could overflow `now_sec() + window_sec` and mark fresh claims - // expired the moment they are written. - constexpr uint32_t ten_years_sec = 10u * 365 * 24 * 60 * 60; - BOOST_REQUIRE_EQUAL(success(), - push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, mvo()("window_sec", ten_years_sec))); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("window_sec exceeds the ten-year ceiling"), - push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, mvo()("window_sec", ten_years_sec + 1))); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setclmwindow_requires_self_auth, sysio_dclaim_tester) { try { - BOOST_REQUIRE_NE(push_dclaim("alice"_n, "setclmwindow"_n, mvo()("window_sec", 3600)), success()); -} FC_LOG_AND_RETHROW() } - // -- onreward auth + routing -- BOOST_FIXTURE_TEST_CASE(onreward_requires_msgch_auth, sysio_dclaim_tester) { try { @@ -199,18 +208,11 @@ BOOST_FIXTURE_TEST_CASE(onreward_unlinked_parks_unmapped_then_linkswept, sysio_d BOOST_REQUIRE_EQUAL(pending_of("bob"_n)["balance"].as().get_amount(), 5000); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(linkswept_preserves_original_unmapped_expiry, sysio_dclaim_tester) { try { - constexpr uint32_t original_window_sec = 120; - constexpr uint32_t replacement_window_sec = 3600; - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, - mvo()("window_sec", original_window_sec)), success()); +BOOST_FIXTURE_TEST_CASE(linkswept_preserves_old_unmapped_rewards, sysio_dclaim_tester) { try { + fund_claims(); BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "", ChainKind::CHAIN_KIND_EVM, addr20, 5000, 7, 100), success()); - const auto original_expiry = unmapped_row(1)["expires_at_sec"].as(); - - produce_block(fc::seconds(5)); - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, - mvo()("window_sec", replacement_window_sec)), success()); + produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); BOOST_REQUIRE_EQUAL(push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() ("wire_account", "bob") ("chain", ChainKind::CHAIN_KIND_EVM) @@ -219,27 +221,20 @@ BOOST_FIXTURE_TEST_CASE(linkswept_preserves_original_unmapped_expiry, sysio_dcla const auto pending = pending_of("bob"_n); BOOST_REQUIRE(!pending.is_null()); BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 5000); - BOOST_REQUIRE_EQUAL(pending["expires_at_sec"].as(), original_expiry); + BOOST_REQUIRE(unmapped_row(1).is_null()); + produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); + check_claim("bob"_n, 5000); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(linkswept_does_not_shorten_newer_aggregate_expiry, sysio_dclaim_tester) { try { - constexpr uint32_t older_window_sec = 120; - constexpr uint32_t newer_window_sec = 3600; +BOOST_FIXTURE_TEST_CASE(linkswept_adds_old_rewards_to_newer_pending_balance, sysio_dclaim_tester) { try { + fund_claims(); const std::vector newer_addr(20, char(0xB2)); - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, - mvo()("window_sec", older_window_sec)), success()); BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "", ChainKind::CHAIN_KIND_EVM, addr20, 5000, 7, 100), success()); - const auto older_expiry = unmapped_row(1)["expires_at_sec"].as(); - - produce_block(fc::seconds(5)); - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, - mvo()("window_sec", newer_window_sec)), success()); + produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "bob", ChainKind::CHAIN_KIND_EVM, newer_addr, 2000, 8, 101), success()); - const auto newer_expiry = pending_of("bob"_n)["expires_at_sec"].as(); - BOOST_REQUIRE_GT(newer_expiry, older_expiry); BOOST_REQUIRE_EQUAL(push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() ("wire_account", "bob") @@ -248,27 +243,19 @@ BOOST_FIXTURE_TEST_CASE(linkswept_does_not_shorten_newer_aggregate_expiry, sysio const auto pending = pending_of("bob"_n); BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 7000); - BOOST_REQUIRE_EQUAL(pending["expires_at_sec"].as(), newer_expiry); + BOOST_REQUIRE(unmapped_row(1).is_null()); + check_claim("bob"_n, 7000); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(linkswept_adopts_newer_migrated_expiry, sysio_dclaim_tester) { try { - constexpr uint32_t older_window_sec = 120; - constexpr uint32_t newer_window_sec = 3600; +BOOST_FIXTURE_TEST_CASE(linkswept_adds_newer_rewards_to_old_pending_balance, sysio_dclaim_tester) { try { + fund_claims(); const std::vector newer_addr(20, char(0xB2)); - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, - mvo()("window_sec", older_window_sec)), success()); BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "bob", ChainKind::CHAIN_KIND_EVM, addr20, 2000, 7, 100), success()); - const auto older_expiry = pending_of("bob"_n)["expires_at_sec"].as(); - - produce_block(fc::seconds(5)); - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, - mvo()("window_sec", newer_window_sec)), success()); + produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "", ChainKind::CHAIN_KIND_EVM, newer_addr, 5000, 8, 101), success()); - const auto newer_expiry = unmapped_row(1)["expires_at_sec"].as(); - BOOST_REQUIRE_GT(newer_expiry, older_expiry); BOOST_REQUIRE_EQUAL(push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() ("wire_account", "bob") @@ -277,24 +264,24 @@ BOOST_FIXTURE_TEST_CASE(linkswept_adopts_newer_migrated_expiry, sysio_dclaim_tes const auto pending = pending_of("bob"_n); BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 7000); - BOOST_REQUIRE_EQUAL(pending["expires_at_sec"].as(), newer_expiry); + BOOST_REQUIRE(unmapped_row(1).is_null()); + check_claim("bob"_n, 7000); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(linkswept_forfeits_expired_unmapped_balance, sysio_dclaim_tester) { try { - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, - mvo()("window_sec", uint32_t{1})), success()); - BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "", ChainKind::CHAIN_KIND_EVM, - addr20, 5000, 7, 100), success()); - BOOST_REQUIRE(!unmapped_row(1).is_null()); - produce_blocks(10); - produce_block(fc::seconds(5)); - +BOOST_FIXTURE_TEST_CASE(imported_balances_remain_claimable_after_years, sysio_dclaim_tester) { try { + fund_claims(); + BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "importseed"_n, mvo() + ("chain", ChainKind::CHAIN_KIND_EVM) + ("credits", fc::variants{mvo()("native_address", addr20)("wire_atomic", int64_t{5000})})), success()); + BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "importdone"_n, mvo{}), success()); + produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); BOOST_REQUIRE_EQUAL(push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() ("wire_account", "bob") ("chain", ChainKind::CHAIN_KIND_EVM) ("native_pubkey", addr20)), success()); BOOST_REQUIRE(unmapped_row(1).is_null()); - BOOST_REQUIRE(pending_of("bob"_n).is_null()); + produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); + check_claim("bob"_n, 5000); } FC_LOG_AND_RETHROW() } // -- dedupe cursor -- @@ -318,21 +305,19 @@ BOOST_FIXTURE_TEST_CASE(onreward_dedupes_stale_external_ref, sysio_dclaim_tester BOOST_REQUIRE_EQUAL(pending_of("alice"_n)["balance"].as().get_amount(), 2000); } FC_LOG_AND_RETHROW() } -// -- claimable window expiry / reversion -- +// -- indefinitely claimable balances -- -BOOST_FIXTURE_TEST_CASE(flushexpired_reverts_expired_pending, sysio_dclaim_tester) { try { - BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setclmwindow"_n, mvo()("window_sec", 1)), success()); +BOOST_FIXTURE_TEST_CASE(pending_rewards_remain_claimable_after_years, sysio_dclaim_tester) { try { + fund_claims(); BOOST_REQUIRE_EQUAL( onreward(MSGCH_ACCOUNT, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 7, 100), success()); BOOST_REQUIRE(!pending_of("alice"_n).is_null()); - // Advance chain time well past the 1-second window. - produce_blocks(10); - produce_block(fc::seconds(5)); - - BOOST_REQUIRE_EQUAL(push_dclaim("alice"_n, "flushexpired"_n, mvo()("max_rows", 50)), success()); - BOOST_REQUIRE(pending_of("alice"_n).is_null()); // reverted to capital fund + produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); + BOOST_REQUIRE_EQUAL(pending_of("alice"_n)["balance"].as().get_amount(), 1000); + BOOST_REQUIRE_NE(push_dclaim("bob"_n, "claim"_n, mvo()("wire_account", "alice")), success()); + check_claim("alice"_n, 1000); } FC_LOG_AND_RETHROW() } // onreward runs inside the OPP inbound dispatch chain (msgch::evalcons), where an abort rolls back diff --git a/contracts/tests/sysio.opreg_tests.cpp b/contracts/tests/sysio.opreg_tests.cpp index 70640bde9d..0676c39722 100644 --- a/contracts/tests/sysio.opreg_tests.cpp +++ b/contracts/tests/sysio.opreg_tests.cpp @@ -619,6 +619,41 @@ class sysio_opreg_tester : public tester { abi_serializer::create_yield_function(abi_serializer_max_time)); } + /// Banked yield retained independently of an operator's registration, or zero when absent. + fc::uint128_t yield_debt(name account, std::string_view token_code = kLiqEthCodename) { + const auto data = get_row_by_id(OPREG_ACCOUNT, account, "yielddebts"_n, cn(token_code).value); + return data.empty() ? fc::uint128_t{0} : opreg_abi_ser.binary_to_variant( + "yield_debt", data, abi_serializer::create_yield_function(abi_serializer_max_time))["owed_wire"].as_uint128(); + } + + /// Seed an existing ledger field at an arithmetic boundary without issuing impossible token + /// supplies. Used only to verify that rejected claims/archival roll back the whole transaction. + void rewrite_claim_field(name table, const char* row_type, name account, std::string_view token_code, + const char* field, const fc::variant& value) { + std::string key; + const auto append_word = [&](uint64_t word) { + for (int shift = 56; shift >= 0; shift -= 8) key.push_back(char(word >> shift)); + }; + if (table != "yieldpool"_n) append_word(account.to_uint64_t()); + append_word(cn(token_code).value); + const auto& idx = control->db().get_index(); + const auto it = idx.find(boost::make_tuple(OPREG_ACCOUNT, compute_table_id(table.to_uint64_t()), + std::string_view(key))); + BOOST_REQUIRE(it != idx.end()); + const std::vector data(it->value.data(), it->value.data() + it->value.size()); + mvo row(opreg_abi_ser.binary_to_variant(row_type, data, + abi_serializer::create_yield_function(abi_serializer_max_time)).get_object()); + row.set(field, value); + const auto encoded = opreg_abi_ser.variant_to_binary(row_type, row, + abi_serializer::create_yield_function(abi_serializer_max_time)); + auto& db = const_cast(control->db()); + db.modify(*it, [&](auto& r) { r.value.assign(encoded.data(), encoded.size()); }); + const std::vector stored(it->value.data(), it->value.data() + it->value.size()); + const auto observed = opreg_abi_ser.binary_to_variant(row_type, stored, + abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE_EQUAL(value.as_string(), observed[field].as_string()); + } + /// Operator-authorized pull of the `token_code` claim row. action_result claimremit(name account, std::string_view token_code) { return push_opreg_action(account, collateral_action::claimremit, mvo() @@ -1768,10 +1803,8 @@ BOOST_FIXTURE_TEST_CASE(terminate_survives_operator_blocking_its_own_remit, sysi BOOST_REQUIRE_EQUAL(DEPOSIT, get_remitclaim(OPERATOR, kWireCodename)["balance"].as_uint64()); } FC_LOG_AND_RETHROW() } -// Positive control: a cooperative operator pulls its terminated collateral normally, and -// the claim row is consumed. Pairs with the test above -- together they pin that the claimable -// path pays everyone except the account that refuses payment. -BOOST_FIXTURE_TEST_CASE(claimremit_pays_terminated_operator_and_clears_row, sysio_opreg_tester) { try { +/// Returned collateral remains claimable after years of inactivity and operator pruning. +BOOST_FIXTURE_TEST_CASE(claimremit_never_expires_and_clears_row_after_payment, sysio_opreg_tester) { try { const auto OPERATOR = "batchop.a"_n; const uint64_t DEPOSIT = 5000; @@ -1782,6 +1815,13 @@ BOOST_FIXTURE_TEST_CASE(claimremit_pays_terminated_operator_and_clears_row, sysi BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kWireCodename, DEPOSIT)); BOOST_REQUIRE_EQUAL(success(), terminate(OPERATOR, "rolling-24h miss")); + BOOST_REQUIRE_EQUAL(DEPOSIT, get_remitclaim(OPERATOR, kWireCodename)["balance"].as_uint64()); + constexpr uint32_t INACTIVE_DAYS = 3 * 365; + produce_block(); + produce_block(fc::days(INACTIVE_DAYS)); + produce_blocks(2); + BOOST_REQUIRE_EQUAL(success(), prune()); + BOOST_REQUIRE(get_operator(OPERATOR).is_null()); BOOST_REQUIRE_EQUAL(DEPOSIT, get_remitclaim(OPERATOR, kWireCodename)["balance"].as_uint64()); const int64_t balance_before_claim = wire_balance(OPERATOR); BOOST_REQUIRE_EQUAL(success(), claimremit(OPERATOR, kWireCodename)); @@ -1966,6 +2006,10 @@ BOOST_FIXTURE_TEST_CASE(withdraw_shadow_flush_credits_token_claim_and_claimremit BOOST_REQUIRE_EQUAL(DEPOSIT, claim["balance"].as_uint64()); BOOST_REQUIRE(get_remitclaim(OPERATOR, kWireCodename).is_null()); + constexpr unsigned InactiveYears = 3; + produce_blocks(); + produce_block(fc::days(InactiveYears * 365)); + produce_blocks(); const int64_t operator_shadow_before = shadow_balance(OPERATOR); const int64_t opreg_shadow_before = shadow_balance(OPREG_ACCOUNT); BOOST_REQUIRE_EQUAL(success(), claimremit(OPERATOR, kLiqEthCodename)); @@ -2716,10 +2760,9 @@ BOOST_FIXTURE_TEST_CASE(yield_credit_never_exceeds_wire_received_from_liq, sysio /// Termination can pay only what the pool covers, since it cannot pull from sysio.liq. The rest -- /// here a whole unswept distribution plus flooring dust -- stays banked on the terminated row, and -/// the operator collects it with `claimyield`, which pulls first. Banked yield never makes the row -/// unsettled: `prune` erases it with dust still owed once the claim window (the prune delay) ends, -/// and that remainder is forfeited to the registry. -BOOST_FIXTURE_TEST_CASE(terminated_operator_claims_uncovered_yield_before_prune, sysio_opreg_tester) { try { +/// the operator collects it with `claimyield`, which pulls first. Pruning archives the remaining +/// debt indefinitely without retaining the operator registration or spending WIRE collateral. +BOOST_FIXTURE_TEST_CASE(terminated_operator_yield_survives_prune, sysio_opreg_tester) { try { const int64_t received = setup_yield_dust(); BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); // not swept before termination const int64_t a_owed = yield_reference::owed(kYieldBondA, liq_index(), 0); @@ -2740,16 +2783,33 @@ BOOST_FIXTURE_TEST_CASE(terminated_operator_claims_uncovered_yield_before_prune, BOOST_REQUIRE_EQUAL(static_cast(dust), depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64()); - // The dust does not hold the row: prune erases it after the delay and the dust is forfeited. + // The dust does not hold the operator row: pruning archives the exact amount owed. // Commit the claim first: a transaction left pending across the time jump would expire. produce_blocks(); produce_block(fc::milliseconds(kDefaultPruneDelayMs)); produce_blocks(); BOOST_REQUIRE_EQUAL(success(), prune()); BOOST_REQUIRE(get_operator(kYieldBonderA).is_null()); + BOOST_REQUIRE_EQUAL(fc::uint128_t{static_cast(dust)}, yield_debt(kYieldBonderA)); + + produce_blocks(); + produce_block(fc::days(3 * 365)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(error(std::string(kYieldNotCoveredError)), claimyield(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(fc::uint128_t{static_cast(dust)}, yield_debt(kYieldBonderA)); + + // Returned shadow still held by the registry earns slack; it can cover the debt without an + // operator record. A keeper can collect it only for the original account. + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + BOOST_REQUIRE_EQUAL(success(), push_opreg_action(kYieldKeeper, yield_action::claimyield, mvo() + (withdrawal_field::account, kYieldBonderA)(withdrawal_field::token_code, kLiqEthCodename))); + BOOST_REQUIRE_EQUAL(fc::uint128_t{0}, yield_debt(kYieldBonderA)); + BOOST_REQUIRE(get_remitclaim(kYieldKeeper, kWireCodename).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(a_owed), + get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); - BOOST_REQUIRE_EQUAL(static_cast(kYieldPrincipal), wire_balance(OPREG_ACCOUNT)); + BOOST_REQUIRE_GE(wire_balance(OPREG_ACCOUNT), static_cast(kYieldPrincipal)); } FC_LOG_AND_RETHROW() } /// `claimyield` is permissionless: a keeper with no stake cranks it for a TERMINATED operator whose @@ -2800,10 +2860,9 @@ BOOST_FIXTURE_TEST_CASE(rebond_after_full_withdrawal_earns_nothing_for_the_gap, claim_yield_credit(kYieldBonderA)); } FC_LOG_AND_RETHROW() } -/// Re-registering a settled TERMINATED operator replaces its row, and with it any yield still -/// banked on it -- the claim window closes -- while the claim rows termination already credited -/// live in `remitclaims`, independent of the operator row, and still pay. -BOOST_FIXTURE_TEST_CASE(reregistration_forfeits_banked_yield_but_keeps_remit_claims, sysio_opreg_tester) { try { +/// Re-registration replaces only operator state: already-earned debt and backed remits remain +/// payable, even though the new registration has no collateral or yield position. +BOOST_FIXTURE_TEST_CASE(reregistration_preserves_banked_yield_and_remit_claims, sysio_opreg_tester) { try { // A is the only bonder, so the first sweep's pool covers exactly A's first share and the unswept // second distribution stays banked on A's row at termination. setup_yield_holders(); @@ -2816,12 +2875,13 @@ BOOST_FIXTURE_TEST_CASE(reregistration_forfeits_banked_yield_but_keeps_remit_cla const uint64_t covered = get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64(); BOOST_REQUIRE_LT(0u, covered); BOOST_REQUIRE_LE(covered, static_cast(first_swept)); - BOOST_REQUIRE_LT(0u, depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64()); + const uint64_t owed = depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64(); + BOOST_REQUIRE_LT(0u, owed); produce_blocks(); BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); - BOOST_REQUIRE(depot_native_row(kYieldBonderA, kLiqEthCodename).is_null()); // banked yield gone with the row - BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE(depot_native_row(kYieldBonderA, kLiqEthCodename).is_null()); + BOOST_REQUIRE_EQUAL(fc::uint128_t{owed}, yield_debt(kYieldBonderA)); BOOST_REQUIRE_EQUAL(covered, get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); BOOST_REQUIRE_EQUAL(kYieldBondA, get_remitclaim(kYieldBonderA, kLiqEthCodename)["balance"].as_uint64()); @@ -2829,6 +2889,134 @@ BOOST_FIXTURE_TEST_CASE(reregistration_forfeits_banked_yield_but_keeps_remit_cla BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); BOOST_REQUIRE_EQUAL(wire_before + static_cast(covered), wire_balance(kYieldBonderA)); BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(success(), claimyield(kYieldBonderA, kLiqEthCodename)); + const uint64_t paid = get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64(); + BOOST_REQUIRE_EQUAL(fc::uint128_t{owed}, fc::uint128_t{paid} + yield_debt(kYieldBonderA)); +} FC_LOG_AND_RETHROW() } + +/// Two retirement cycles accumulate debt under the same token; a fresh position earns only +/// its own interval, and a single claim conserves both archived debt and current yield. +BOOST_FIXTURE_TEST_CASE(repeated_reregistration_preserves_debt_and_live_yield, sysio_opreg_tester) { try { + setup_yield_holders(); + fc::uint128_t total_debt = 0; + constexpr unsigned RetirementCycles = 2; + for (unsigned cycle = 0; cycle < RetirementCycles; ++cycle) { + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + total_debt += depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64(); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, false)); + BOOST_REQUIRE_EQUAL(total_debt, yield_debt(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kLiqEthCodename)); + } + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + const auto checkpoint = liq_index(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const uint64_t new_yield = yield_reference::owed(kYieldBondA, liq_index(), checkpoint); + const int64_t credited = claim_yield_credit(kYieldBonderA); + const uint64_t live_owed = depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64(); + BOOST_REQUIRE_EQUAL(total_debt + new_yield, + fc::uint128_t{static_cast(credited)} + yield_debt(kYieldBonderA) + live_owed); + BOOST_REQUIRE_EQUAL(kYieldBondA, depot_native_row(kYieldBonderA, kLiqEthCodename)["balance"].as_uint64()); + const int64_t before = wire_balance(kYieldBonderA); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(before + credited, wire_balance(kYieldBonderA)); +} FC_LOG_AND_RETHROW() } + +/// A full WIRE remit must reject collection: neither detached debt, the pool, nor +/// the registry's unswept LIQ yield can be consumed by a saturating credit. +BOOST_FIXTURE_TEST_CASE(full_remit_rolls_back_archived_yield_claim, sysio_opreg_tester) { try { + setup_yield_dust(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, false)); + const auto debt_before = yield_debt(kYieldBonderA); + const auto claim_before = get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64(); + const auto registry_before = wire_balance(OPREG_ACCOUNT); + const auto liq_owed_before = registry_liq_owed(); + rewrite_claim_field("remitclaims"_n, "remit_claim", kYieldBonderA, kWireCodename, "balance", + uint64_t{asset::max_amount}); + constexpr auto FullRemitError = "assertion failure with message: claim WIRE remit before collecting more yield"; + BOOST_REQUIRE_EQUAL(error(FullRemitError), claimyield(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(debt_before, yield_debt(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(registry_before, wire_balance(OPREG_ACCOUNT)); + BOOST_REQUIRE_EQUAL(liq_owed_before, registry_liq_owed()); + rewrite_claim_field("remitclaims"_n, "remit_claim", kYieldBonderA, kWireCodename, "balance", claim_before); + produce_blocks(); + const int64_t credited = claim_yield_credit(kYieldBonderA); + BOOST_REQUIRE_EQUAL(debt_before, fc::uint128_t{static_cast(credited)} + yield_debt(kYieldBonderA)); +} FC_LOG_AND_RETHROW() } + +/// Archived debt can exceed one asset across registrations. Collection must fill at most one +/// remit, preserve the remainder, and make progress again once that remit has been collected. +BOOST_FIXTURE_TEST_CASE(large_archived_yield_collects_in_bounded_parts, sysio_opreg_tester) { try { + setup_yield_dust(); + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, false)); + const uint64_t old_credited = get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64(); + const uint64_t capacity = asset::max_amount; + const fc::uint128_t debt = fc::uint128_t{capacity} + 1; + // Boundary-only state injection: exercising arithmetic beyond a single token supply does not + // require minting that supply. No transfer is attempted against the synthetic backing pool. + rewrite_claim_field("yielddebts"_n, "yield_debt", kYieldBonderA, kLiqEthCodename, "owed_wire", debt); + rewrite_claim_field("yieldpool"_n, "yield_pool", kYieldBonderA, kLiqEthCodename, "received", + old_credited + capacity + 1); + rewrite_claim_field("remitclaims"_n, "remit_claim", kYieldBonderA, kWireCodename, "balance", uint64_t{0}); + BOOST_REQUIRE_EQUAL(success(), claimyield(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(capacity, get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(fc::uint128_t{1}, yield_debt(kYieldBonderA)); + rewrite_claim_field("remitclaims"_n, "remit_claim", kYieldBonderA, kWireCodename, "balance", uint64_t{0}); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), claimyield(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(1u, get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(fc::uint128_t{0}, yield_debt(kYieldBonderA)); +} FC_LOG_AND_RETHROW() } + +/// A downstream LIQ claim failure rolls back the predicted pool receipt, archived-debt debit +/// and WIRE remit together; restoring LIQ lets the same entitlement be collected once. +BOOST_FIXTURE_TEST_CASE(failed_liq_pull_preserves_archived_yield, sysio_opreg_tester) { try { + setup_yield_holders(); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, false)); + const auto debt_before = yield_debt(kYieldBonderA); + const auto balance_before = wire_balance(OPREG_ACCOUNT); + const auto owed_before = registry_liq_owed(); + // The token dispatcher rejects LIQ's claim action. Keep LIQ's tables/ABI so the opreg + // preflight reads valid state and the queued downstream action is the failing boundary. + set_code(LIQ_ACCOUNT, contracts::token_wasm()); + produce_blocks(); + BOOST_REQUIRE(claimyield(kYieldBonderA, kLiqEthCodename) != success()); + BOOST_REQUIRE_EQUAL(debt_before, yield_debt(kYieldBonderA)); + BOOST_REQUIRE(get_remitclaim(kYieldBonderA, kWireCodename).is_null()); + BOOST_REQUIRE_EQUAL(balance_before, wire_balance(OPREG_ACCOUNT)); + BOOST_REQUIRE_EQUAL(owed_before, registry_liq_owed()); + set_code(LIQ_ACCOUNT, contracts::liq_wasm()); + produce_blocks(); + const auto credited = claim_yield_credit(kYieldBonderA); + BOOST_REQUIRE_EQUAL(debt_before, fc::uint128_t{static_cast(credited)} + yield_debt(kYieldBonderA)); +} FC_LOG_AND_RETHROW() } + +/// A missing debt key for another token cannot consume the real token's pool or change its debt. +BOOST_FIXTURE_TEST_CASE(archived_yield_keeps_token_identity, sysio_opreg_tester) { try { + setup_yield_holders(); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, false)); + const auto debt_before = yield_debt(kYieldBonderA); + const auto registry_before = wire_balance(OPREG_ACCOUNT); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(debt_before, yield_debt(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(fc::uint128_t{0}, yield_debt(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(registry_before, wire_balance(OPREG_ACCOUNT)); + BOOST_REQUIRE_EQUAL(success(), claimyield(kYieldBonderA, kLiqEthCodename)); } FC_LOG_AND_RETHROW() } // This is the dual-shadow producer flow's depot policy with generic symbols. diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index e413c9cea8..8a0b74c41c 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -250,6 +250,33 @@ the absent-key case rather than the short-decode one (see ## Downgrades +### No-expiry claim layouts (WIRE-339 / WIRE-353) + +This pre-launch change requires fresh state for the changed serialized layouts. Operator +pay (`sysio.system::payclaims`), per-token returned collateral and credited yield +(`sysio.opreg::remitclaims`), and DClaim pending/unmapped balances omit expiry fields and +indexes. DClaim also removes the claim-window field from `capcfg` and the `setclmwindow` +and `flushexpired` actions. There is no in-place table migration or compatibility shim. + +Operator pruning and re-registration preserve any earned but uncredited shadow yield in +`sysio.opreg::yielddebts`, keyed by account and token. `claimyield` can collect this debt +without an operator record, only to the original account's WIRE remit and only within +that token's received-yield pool. Debt does not accrue again or expire; rounding dust +remains owed until backing is available. A full WIRE remit rejects collection atomically. +These system-funded rows deliberately retain storage until the beneficiary collects them. + +`sysio.reserv` and `sysio.uwrit` are retired by the syndication architecture. Do not +restore their old sweep actions or deployment edges. `sysio.bond` already retains unpaid +claims, and its paid-only cleanup remains unchanged. + +Before any separately designed rollback, stop all affected claim writers and settle or +explicitly preserve every liability, including archived yield debt and unlinked DClaim +credits. Restoring the old ABI alone cannot decode these rows. DClaim's finalized +`imported_complete` flag must remain set when adapting `capcfg`; restoring its old claim +window must never reopen imports. Removal of an inline action requires retiring its +callers first; addition requires deploying the callee first. Fresh-state bootstrap must +use a matching contract set and generated SDK interface. + > **WIRE-350 pre-launch note:** Provider nodes that previously started with the old automatic > snapshot schedule must delete their local `snapshot-schedule.json` once before starting this > build. The old `(spacing=25000, start=24999, end=MAX)` request and the corrected @@ -267,10 +294,11 @@ roll back, the claim tables may hold value that only the NEW code can pay out: | `payclaims` | `sysio.system` | `claimpay` | earned epoch pay | | `wireclaims` | `sysio.bond` | `claimwire` | WIRE yield earned by underwriters and banked at `claim` | | `remitclaims` | `sysio.opreg` | `claimremit` | debited operator collateral | +| `yielddebts` | `sysio.opreg` | `claimyield`, then `claimremit` | banked yield retained after operator removal | +| `pclaims`, `unmapped` | `sysio.dclaim` | `claim`, after `linkswept` for unlinked credits | rewards and imported credits | -Every one of those balances is value already taken from someone's spendable -position and parked behind an action the old build does not have. Rolling back -with rows present does not degrade — it strands. +These rows record obligations, including earned yield that its pool cannot yet cover. +Rolling back without preserving them can strand both backed claims and unpaid yield debt. There is also a live-writer hazard with no upgrade counterpart: rolling `sysio.epoch` back while the new `sysio.system` is still deployed lets `payepoch` diff --git a/docs/shadow-custody-integration.md b/docs/shadow-custody-integration.md index 61463c1f86..0879bc6ff7 100644 --- a/docs/shadow-custody-integration.md +++ b/docs/shadow-custody-integration.md @@ -98,8 +98,10 @@ is allowed only because the transfer is sent in the same action (obligation 0). 4. **Credit sub-holders only with what `settle_and_take` returns**, into a ledger they pull from (`claimable.hpp`). Never push a transfer from a path that must not throw. 5. **Decide your policy** for yield the pool cannot yet cover, and for yield left unclaimed when a - row is erased. `sysio.opreg` keeps uncovered yield on the row, and forfeits whatever is unclaimed - when `prune` erases a terminated operator. + row is erased. `sysio.opreg` keeps uncovered yield on the live row, then archives banked debt in + `yielddebts` before pruning or replacing a terminated operator. The original account can collect + it indefinitely through `claimyield`, without an operator registration. Archived debt earns no + further yield and uses the same token-specific backing cap; it never draws on WIRE collateral. ## What never throws