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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 11 additions & 49 deletions contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
#include <sysio/kv_table.hpp>
#include <sysio/asset.hpp>
#include <sysio/crypto.hpp>
#include <sysio/system.hpp>
#include <sysio/opp/types/types.pb.hpp>
#include <sysio.opp.common/opp_table_types.hpp>
#include <sysio.opp.common/wire_asset.hpp>
Expand Down Expand Up @@ -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 +
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>;
Expand All @@ -213,15 +179,14 @@ namespace sysio {
opp::types::ChainKind chain_kind = opp::types::ChainKind::CHAIN_KIND_UNKNOWN;
std::vector<char> native_pubkey;
asset balance = asset{0, WIRE_SYM};
uint32_t expires_at_sec = 0;

uint64_t primary_key() const { return id; }

uint128_t by_chain_addr() const {
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,
Expand Down Expand Up @@ -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>;
Expand Down
114 changes: 12 additions & 102 deletions contracts/sysio.dclaim/src/sysio.dclaim.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t>(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
Expand All @@ -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<class Pick>
Expand All @@ -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<char>& addr, const asset& amt, uint32_t window) {
const uint32_t exp = now_sec() + window;

const std::vector<char>& addr, const asset& amt) {
if (wacct.value != 0) {
credit_pending(self, wacct, amt, exp);
credit_pending(self, wacct, amt);
return;
}

Expand All @@ -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;
});
}
}
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -238,19 +189,10 @@ void dclaim::linkswept(name wire_account, ChainKind chain, std::vector<char> 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);
}
}

Expand Down Expand Up @@ -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<int64_t>(reward_amount), WIRE_SYM },
config_window(get_self()));
asset{ static_cast<int64_t>(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
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -351,18 +263,16 @@ void dclaim::importseed(ChainKind chain, std::vector<import_credit> 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 });
}
}

Expand Down
Loading
Loading