diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index f795542fcb..9ca1153f2e 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -53,6 +53,7 @@ add_subdirectory(sysio.msgch) add_subdirectory(sysio.uwrit) add_subdirectory(sysio.chalg) add_subdirectory(sysio.reserv) +add_subdirectory(sysio.dclaim) add_subdirectory(sysio.token) add_subdirectory(sysio.wrap) diff --git a/contracts/sysio.dclaim/CMakeLists.txt b/contracts/sysio.dclaim/CMakeLists.txt new file mode 100644 index 0000000000..1b3aa88450 --- /dev/null +++ b/contracts/sysio.dclaim/CMakeLists.txt @@ -0,0 +1,46 @@ +set(contract_name sysio.dclaim) +bootstrap_contract(${contract_name}) + +if(BUILD_SYSTEM_CONTRACTS) + find_cdt_magic_enum() + file(GLOB_RECURSE SOURCES src/*.cpp) + file(GLOB_RECURSE HEADERS include/*.hpp) + add_contract(${contract_name} ${contract_name} ${SOURCES}) + set(targets ${contract_name}) + + if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) + list(APPEND targets ${contract_name}_native) + add_native_contract( + TARGET ${contract_name}_native + SOURCES ${SOURCES} + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include + CONTRACT_CLASS "sysio::dclaim" + HEADERS ${HEADERS} + ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi + ) + endif() + + foreach(target ${targets}) + if(NOT TARGET ${target}) + message(WARNING "Target ${target} not found, skipping include directory setup") + continue() + endif() + + target_include_directories(${target} + PUBLIC + $ + $ + $ + $ + $ + $ + $ + $ + ) + + target_link_libraries(${target} + INTERFACE + magic_enum::magic_enum + ) + endforeach() +endif() diff --git a/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp b/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp new file mode 100644 index 0000000000..05570657c4 --- /dev/null +++ b/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp @@ -0,0 +1,307 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace sysio { + + /** + * @brief sysio.dclaim — depot-side WIRE distribution and claim ledger. + * + * Holds the pending-WIRE balances owed to LIQ-token stakers and pre-launch + * pretoken purchasers, and the per-staker WIRE-side leg of the + * `STAKING_REWARD` flow. + * + * Inbound `STAKING_REWARD` attestations route through `sysio.msgch`, which + * dispatches the per-staker body here via `onreward`. The reward arrives + * already WIRE-denominated — native -> WIRE conversion and source-chain + * precision scaling happen outpost-side — so `onreward` simply credits the + * staker's claim ledger. + * + * Ledgers: + * - `pending_claims` — per-Wire-account WIRE owed. `claim` drains a row via + * inline transfer. + * - `unmapped_tokens` — per-(chain, native_pubkey) WIRE owed for stakers / + * purchasers without a Wire account yet. Completing AuthX linking + * inline-calls `linkswept`, which moves the credit into `pending_claims`. + * - `reward_cursors` — per-(outpost_id, chain, native_pubkey) high-water + * mark of the last processed source-chain epoch reference. Replays / + * 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. + * + * No cooldown/withdrawal machinery for v1 (no withdrawal flow in this + * wave). When withdrawals come online post-launch, the cooldown-queue + + * maturation-flush pattern can be added back — see opreg's + * `withdraw_queue` for reference. + */ + class [[sysio::contract("sysio.dclaim")]] dclaim : public contract { + public: + using contract::contract; + + // Well-known accounts. + static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; + static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; + static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr name RESERV_ACCOUNT = "sysio.reserv"_n; + + // WIRE token symbol. 9 decimals system-wide. + static constexpr symbol WIRE_SYM = symbol("WIRE", 9); + + // 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; + + // ----------------------------------------------------------------------- + // Actions + // ----------------------------------------------------------------------- + + /// Initialize the config singleton (idempotent). The claimable-reward + /// window defaults to `DEFAULT_CLAIM_WINDOW_SEC`. + [[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. + [[sysio::action]] + void claim(name wire_account); + + /// 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. No-op if nothing matches. + /// Auth=sysio.authex. + [[sysio::action]] + void linkswept(name wire_account, + opp::types::ChainKind chain, + std::vector native_pubkey); + + /// Per-staker WIRE-side credit of a `STAKING_REWARD`. Dispatched inline + /// by `sysio.msgch` (the proto body flattened to primitives). Dedupes + /// on the source-chain epoch reference and credits `pending_claims` (if + /// linked) or `unmapped_tokens` (if not). The reward arrives already + /// WIRE-denominated; native -> WIRE conversion is outpost-side. + /// Auth=sysio.msgch. + /// + /// @param outpost_id Emitting outpost (dedupe scope). + /// @param staker_wire_account Staker's Wire account name, or "" when not + /// yet AuthX-linked (then parked by native + /// address until the link sweep). + /// @param reward_chain Source chain (parking + dedupe key). + /// @param staker_native_addr Staker's raw native address (dedupe + + /// parking key; always populated). + /// @param reward_amount Absolute WIRE reward amount in atomic + /// units (already the staker's prorated + /// portion). + /// @param reward_epoch_index WIRE epoch index (informational / audit). + /// @param external_epoch_ref Source-chain epoch reference; monotonic + /// per (outpost, staker) — dedupe key. + /// @param share_bps Staker share in bps (informational only). + [[sysio::action]] + void onreward(uint64_t outpost_id, + std::string staker_wire_account, + opp::types::ChainKind reward_chain, + std::vector staker_native_addr, + uint64_t reward_amount, + uint32_t reward_epoch_index, + 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 + /// atomic units; the off-chain converter floors the source pretoken + /// value at the 1e9 boundary (sub-atomic dust dropped — total dust + /// pool bounded at < num_users * 1 atomic WIRE). + struct import_credit { + std::vector native_address; + int64_t wire_atomic = 0; + + SYSLIB_SERIALIZE(import_credit, (native_address)(wire_atomic)) + }; + + /// Bootstrap import: privileged, batched insert/update of + /// `unmapped_tokens` rows for pre-launch holders. Same `native_address` + /// across batches sums into the existing row. Aborts once + /// `cap_config::imported_complete` is true (set by `importdone`). + [[sysio::action]] + void importseed(opp::types::ChainKind chain, std::vector credits); + + /// Finalize the bootstrap import. Flips `imported_complete` to true; + /// subsequent `importseed` calls revert. + [[sysio::action]] + void importdone(); + + // ----------------------------------------------------------------------- + // Tables + // ----------------------------------------------------------------------- + + /// Pending-claims: per-Wire-account WIRE owed. + struct pclaim_key { + uint64_t wire_account; + uint64_t primary_key() const { return wire_account; } + SYSLIB_SERIALIZE(pclaim_key, (wire_account)) + }; + + 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)) + }; + + using pclaims_t = sysio::kv::table<"pclaims"_n, pclaim_key, pending_claim>; + + /// Unmapped-tokens: per-(chain, native_pubkey) WIRE owed for stakers / + /// purchasers who don't have a Wire account yet. + struct unmapped_key { + uint64_t id; + uint64_t primary_key() const { return id; } + SYSLIB_SERIALIZE(unmapped_key, (id)) + }; + + struct [[sysio::table("unmapped")]] unmapped_token { + uint64_t id = 0; + 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; } + + 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)) + }; + + using unmapped_t = sysio::kv::table<"unmapped"_n, unmapped_key, unmapped_token, + sysio::kv::index<"bychainad"_n, + sysio::const_mem_fun> + >; + + /// Per-(outpost_id, chain, native_pubkey) dedupe cursor: the highest + /// source-chain epoch reference processed. Anything `<=` is a replay. + struct rwdcur_key { + uint64_t id; + uint64_t primary_key() const { return id; } + SYSLIB_SERIALIZE(rwdcur_key, (id)) + }; + + struct [[sysio::table("rwdcursors")]] reward_cursor { + uint64_t id = 0; + uint64_t outpost_id = 0; + opp::types::ChainKind chain = opp::types::ChainKind::CHAIN_KIND_UNKNOWN; + std::vector native_pubkey; + uint64_t last_external_epoch_ref = 0; + + uint64_t primary_key() const { return id; } + + uint128_t by_outpost_addr() const { + return outpost_addr_key(outpost_id, chain, native_pubkey); + } + + SYSLIB_SERIALIZE(reward_cursor, + (id)(outpost_id)(chain)(native_pubkey)(last_external_epoch_ref)) + }; + + using rwdcursors_t = sysio::kv::table<"rwdcursors"_n, rwdcur_key, reward_cursor, + sysio::kv::index<"byoutaddr"_n, + sysio::const_mem_fun> + >; + + /// Cap-staking config singleton. + 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)) + }; + + using capcfg_t = sysio::kv::global<"capcfg"_n, cap_config>; + + /// Monotonic id counters. + struct [[sysio::table("capcounters")]] cap_counters { + uint64_t next_unmapped_id = 1; + uint64_t next_cursor_id = 1; + + SYSLIB_SERIALIZE(cap_counters, (next_unmapped_id)(next_cursor_id)) + }; + + using capcounters_t = sysio::kv::global<"capcounters"_n, cap_counters>; + + // ----------------------------------------------------------------------- + // Shared key derivation + // + // uint128 secondary keys only NARROW a scan; the full identity is + // re-checked by an exact predicate (see scan_find in the .cpp), so an + // address-prefix collision is resolved deterministically rather than + // risking a hash collision on a 64-bit primary key. + // ----------------------------------------------------------------------- + + /// (chain, native address) -> uint128 narrowing key. High 64 bits = + /// chain; low 64 = first 8 bytes of the address. + static uint128_t chain_addr_key(opp::types::ChainKind chain, + const std::vector& addr) { + if (addr.empty()) return 0; + uint64_t prefix = 0; + const size_t n = addr.size() < sizeof(uint64_t) ? addr.size() : sizeof(uint64_t); + std::memcpy(&prefix, addr.data(), n); + return (static_cast(chain) << 64) | prefix; + } + + /// (outpost_id, chain, native address) -> uint128 narrowing key. High 64 + /// bits = outpost_id; low 64 = chain (high 32) xored with the first 4 + /// address bytes. + static uint128_t outpost_addr_key(uint64_t outpost_id, + opp::types::ChainKind chain, + const std::vector& addr) { + uint32_t prefix = 0; + const size_t n = addr.size() < sizeof(uint32_t) ? addr.size() : sizeof(uint32_t); + if (n > 0) std::memcpy(&prefix, addr.data(), n); + uint64_t lo = (static_cast(chain) << 32) ^ static_cast(prefix); + return (static_cast(outpost_id) << 64) | lo; + } + + private: + using ChainKind = opp::types::ChainKind; + using TokenKind = opp::types::TokenKind; + }; + +} // namespace sysio diff --git a/contracts/sysio.dclaim/src/sysio.dclaim.cpp b/contracts/sysio.dclaim/src/sysio.dclaim.cpp new file mode 100644 index 0000000000..7d9580b8e2 --- /dev/null +++ b/contracts/sysio.dclaim/src/sysio.dclaim.cpp @@ -0,0 +1,316 @@ +#include + +#include +#include +#include + +namespace sysio { + +namespace { + +using opp::types::ChainKind; + +/// 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 +/// predicate resolves prefix collisions deterministically. One implementation +/// shared by the unmapped + cursor lookups (no duplicated scan loops). +template +auto scan_find(Index& idx, uint128_t key, KeyFn key_of, MatchFn matches) { + auto it = idx.lower_bound(key); + for (; it != idx.end() && key_of(*it) == key; ++it) { + if (matches(*it)) break; + } + if (it != idx.end() && key_of(*it) == key && matches(*it)) return it; + 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 +uint64_t next_id(name self, Pick pick) { + dclaim::capcounters_t cnt(self); + dclaim::cap_counters c = cnt.get_or_default(dclaim::cap_counters{}); + uint64_t& field = pick(c); + uint64_t id = field++; + cnt.set(c, self); + return id; +} + +/// Credit `amt` WIRE to the staker. Linked (`wacct` set) -> `pending_claims`; +/// otherwise parked in `unmapped_tokens` keyed by (chain, addr). Either way +/// the row's expiry is refreshed to now + window. Shared by `onreward`, +/// `retryconvert`, `linkswept`, and `importseed` so the upsert + expiry logic +/// lives in exactly one place. +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; + + if (wacct.value != 0) { + dclaim::pclaims_t pclaims(self); + auto it = pclaims.find(dclaim::pclaim_key{wacct.value}); + if (it == pclaims.end()) { + pclaims.emplace(self, dclaim::pclaim_key{wacct.value}, + dclaim::pending_claim{ .wire_account = wacct, + .balance = amt, + .expires_at_sec = exp }); + } else { + pclaims.modify(same_payer, dclaim::pclaim_key{wacct.value}, [&](auto& r) { + r.balance += amt; + r.expires_at_sec = exp; + }); + } + return; + } + + dclaim::unmapped_t unmapped(self); + auto idx = unmapped.template get_index<"bychainad"_n>(); + auto it = scan_find(idx, dclaim::chain_addr_key(chain, addr), + [](const auto& r) { return r.by_chain_addr(); }, + [&](const auto& r) { + return r.chain_kind == chain && r.native_pubkey == addr; + }); + if (it == idx.end()) { + uint64_t id = next_id(self, [](dclaim::cap_counters& c) -> uint64_t& { + return c.next_unmapped_id; + }); + unmapped.emplace(self, dclaim::unmapped_key{id}, + dclaim::unmapped_token{ .id = id, + .chain_kind = chain, + .native_pubkey = addr, + .balance = amt, + .expires_at_sec = exp }); + } else { + uint64_t rid = it->id; + unmapped.modify(same_payer, dclaim::unmapped_key{rid}, [&](auto& r) { + r.balance += amt; + r.expires_at_sec = exp; + }); + } +} + +/// Dedupe at ingest. Returns true if `(outpost_id, chain, addr)` has not seen +/// `ext_ref` (or any >= it) yet — and advances the cursor. Returns false for a +/// replay / out-of-order duplicate (`ext_ref <= last`). Advancing here (not at +/// conversion time) means a replay is rejected even while an earlier reward is +/// still staged awaiting a quote. +bool cursor_admit(name self, uint64_t outpost_id, ChainKind chain, + const std::vector& addr, uint64_t ext_ref) { + dclaim::rwdcursors_t cur(self); + auto idx = cur.template get_index<"byoutaddr"_n>(); + auto it = scan_find(idx, dclaim::outpost_addr_key(outpost_id, chain, addr), + [](const auto& r) { return r.by_outpost_addr(); }, + [&](const auto& r) { + return r.outpost_id == outpost_id + && r.chain == chain + && r.native_pubkey == addr; + }); + if (it == idx.end()) { + uint64_t id = next_id(self, [](dclaim::cap_counters& c) -> uint64_t& { + return c.next_cursor_id; + }); + cur.emplace(self, dclaim::rwdcur_key{id}, + dclaim::reward_cursor{ .id = id, + .outpost_id = outpost_id, + .chain = chain, + .native_pubkey = addr, + .last_external_epoch_ref = ext_ref }); + return true; + } + if (ext_ref <= it->last_external_epoch_ref) return false; + uint64_t rid = it->id; + cur.modify(same_payer, dclaim::rwdcur_key{rid}, [&](auto& r) { + r.last_external_epoch_ref = ext_ref; + }); + return true; +} + +} // anonymous namespace + +// --------------------------------------------------------------------------- +// setconfig +// --------------------------------------------------------------------------- +void dclaim::setconfig() { + require_auth(get_self()); + capcfg_t cfg(get_self()); + if (!cfg.exists()) { + cfg.set(cap_config{}, get_self()); + } +} + +// --------------------------------------------------------------------------- +// setclmwindow +// --------------------------------------------------------------------------- +void dclaim::setclmwindow(uint32_t window_sec) { + require_auth(get_self()); + check(window_sec > 0, "window_sec must be positive"); + capcfg_t cfg(get_self()); + cap_config c = cfg.get_or_default(cap_config{}); + c.claim_window_sec = window_sec; + cfg.set(c, get_self()); +} + +// --------------------------------------------------------------------------- +// claim +// --------------------------------------------------------------------------- +void dclaim::claim(name wire_account) { + require_auth(wire_account); + + pclaims_t pclaims(get_self()); + auto it = pclaims.find(pclaim_key{wire_account.value}); + check(it != pclaims.end(), "no pending claim"); + const asset payout = it->balance; + check(payout.amount > 0, "zero pending balance"); + pclaims.erase(it); + + action( + permission_level{ get_self(), "active"_n }, + TOKEN_ACCOUNT, + "transfer"_n, + std::make_tuple(get_self(), wire_account, payout, std::string("sysio.dclaim claim")) + ).send(); +} + +// --------------------------------------------------------------------------- +// linkswept — AuthX link completed: sweep unmapped -> pending. +// --------------------------------------------------------------------------- +void dclaim::linkswept(name wire_account, ChainKind chain, std::vector native_pubkey) { + require_auth(AUTHEX_ACCOUNT); + + const uint32_t window = config_window(get_self()); + + // Sweep an unmapped balance into the staker's pending_claims row. + unmapped_t unmapped(get_self()); + auto uidx = unmapped.template get_index<"bychainad"_n>(); + auto uit = scan_find(uidx, chain_addr_key(chain, native_pubkey), + [](const auto& r) { return r.by_chain_addr(); }, + [&](const auto& r) { + return r.chain_kind == chain && r.native_pubkey == native_pubkey; + }); + if (uit != uidx.end()) { + const asset bal = uit->balance; + const uint64_t row_id = uit->id; + unmapped.erase(unmapped_key{row_id}); + // wire_account is set -> routed to pending_claims, expiry refreshed. + credit_wire(get_self(), wire_account, chain, native_pubkey, bal, window); + } +} + +// --------------------------------------------------------------------------- +// onreward — per-staker WIRE-side credit of a STAKING_REWARD +// --------------------------------------------------------------------------- +void dclaim::onreward(uint64_t outpost_id, + std::string staker_wire_account, + opp::types::ChainKind reward_chain, + std::vector staker_native_addr, + uint64_t reward_amount, + uint32_t reward_epoch_index, + uint64_t external_epoch_ref, + uint32_t share_bps) { + require_auth(MSGCH_ACCOUNT); + + // Tolerate degenerate input rather than aborting the inbound OPP envelope + // (the verifier role lives upstream in msgch::evalcons; dclaim trusts but + // must not break the message chain on a malformed row). + if (reward_amount == 0 || staker_native_addr.empty()) return; + + // Dedupe at ingest so a replay / out-of-order duplicate is rejected. + if (!cursor_admit(get_self(), outpost_id, reward_chain, + staker_native_addr, external_epoch_ref)) { + return; + } + + name wacct; // value 0 == not yet AuthX-linked + if (!staker_wire_account.empty()) { + wacct = name(staker_wire_account); + } + + // reward_amount arrives already WIRE-denominated -- native -> WIRE + // 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())); +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- +void dclaim::importseed(ChainKind chain, std::vector credits) { + require_auth(get_self()); + + capcfg_t cfg(get_self()); + const cap_config current_cfg = cfg.get_or_default(cap_config{}); + check(!current_cfg.imported_complete, "import already finalized"); + + 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 + // staking rewards (one implementation in credit_wire). + credit_wire(get_self(), name{}, chain, credit.native_address, + asset{ credit.wire_atomic, WIRE_SYM }, window); + } +} + +// --------------------------------------------------------------------------- +// importdone +// --------------------------------------------------------------------------- +void dclaim::importdone() { + require_auth(get_self()); + capcfg_t cfg(get_self()); + cap_config current = cfg.get_or_default(cap_config{}); + check(!current.imported_complete, "import already finalized"); + current.imported_complete = true; + cfg.set(current, get_self()); +} + +} // namespace sysio diff --git a/contracts/sysio.dclaim/sysio.dclaim.abi b/contracts/sysio.dclaim/sysio.dclaim.abi new file mode 100644 index 0000000000..bf6de3de5a --- /dev/null +++ b/contracts/sysio.dclaim/sysio.dclaim.abi @@ -0,0 +1,384 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "cap_config", + "base": "", + "fields": [ + { + "name": "imported_complete", + "type": "bool" + }, + { + "name": "claim_window_sec", + "type": "uint32" + } + ] + }, + { + "name": "cap_counters", + "base": "", + "fields": [ + { + "name": "next_unmapped_id", + "type": "uint64" + }, + { + "name": "next_cursor_id", + "type": "uint64" + } + ] + }, + { + "name": "claim", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + } + ] + }, + { + "name": "flushexpired", + "base": "", + "fields": [ + { + "name": "max_rows", + "type": "uint32" + } + ] + }, + { + "name": "import_credit", + "base": "", + "fields": [ + { + "name": "native_address", + "type": "bytes" + }, + { + "name": "wire_atomic", + "type": "int64" + } + ] + }, + { + "name": "importdone", + "base": "", + "fields": [] + }, + { + "name": "importseed", + "base": "", + "fields": [ + { + "name": "chain", + "type": "ChainKind" + }, + { + "name": "credits", + "type": "import_credit[]" + } + ] + }, + { + "name": "linkswept", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + }, + { + "name": "chain", + "type": "ChainKind" + }, + { + "name": "native_pubkey", + "type": "bytes" + } + ] + }, + { + "name": "onreward", + "base": "", + "fields": [ + { + "name": "outpost_id", + "type": "uint64" + }, + { + "name": "staker_wire_account", + "type": "string" + }, + { + "name": "reward_chain", + "type": "ChainKind" + }, + { + "name": "staker_native_addr", + "type": "bytes" + }, + { + "name": "reward_amount", + "type": "uint64" + }, + { + "name": "reward_epoch_index", + "type": "uint32" + }, + { + "name": "external_epoch_ref", + "type": "uint64" + }, + { + "name": "share_bps", + "type": "uint32" + } + ] + }, + { + "name": "pclaim_key", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "uint64" + } + ] + }, + { + "name": "pending_claim", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + }, + { + "name": "balance", + "type": "asset" + }, + { + "name": "expires_at_sec", + "type": "uint32" + } + ] + }, + { + "name": "reward_cursor", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "outpost_id", + "type": "uint64" + }, + { + "name": "chain", + "type": "ChainKind" + }, + { + "name": "native_pubkey", + "type": "bytes" + }, + { + "name": "last_external_epoch_ref", + "type": "uint64" + } + ] + }, + { + "name": "rwdcur_key", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + } + ] + }, + { + "name": "setclmwindow", + "base": "", + "fields": [ + { + "name": "window_sec", + "type": "uint32" + } + ] + }, + { + "name": "setconfig", + "base": "", + "fields": [] + }, + { + "name": "unmapped_key", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + } + ] + }, + { + "name": "unmapped_token", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "native_pubkey", + "type": "bytes" + }, + { + "name": "balance", + "type": "asset" + }, + { + "name": "expires_at_sec", + "type": "uint32" + } + ] + } + ], + "actions": [ + { + "name": "claim", + "type": "claim", + "ricardian_contract": "" + }, + { + "name": "flushexpired", + "type": "flushexpired", + "ricardian_contract": "" + }, + { + "name": "importdone", + "type": "importdone", + "ricardian_contract": "" + }, + { + "name": "importseed", + "type": "importseed", + "ricardian_contract": "" + }, + { + "name": "linkswept", + "type": "linkswept", + "ricardian_contract": "" + }, + { + "name": "onreward", + "type": "onreward", + "ricardian_contract": "" + }, + { + "name": "setclmwindow", + "type": "setclmwindow", + "ricardian_contract": "" + }, + { + "name": "setconfig", + "type": "setconfig", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "capcfg", + "type": "cap_config", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 41893 + }, + { + "name": "capcounters", + "type": "cap_counters", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 40418 + }, + { + "name": "pclaims", + "type": "pending_claim", + "index_type": "i64", + "key_names": ["wire_account"], + "key_types": ["uint64"], + "table_id": 16291 + }, + { + "name": "rwdcursors", + "type": "reward_cursor", + "index_type": "i64", + "key_names": ["id"], + "key_types": ["uint64"], + "table_id": 64468, + "secondary_indexes": [ + { + "name": "byoutaddr", + "key_type": "uint128", + "table_id": 35362 + } + ] + }, + { + "name": "unmapped", + "type": "unmapped_token", + "index_type": "i64", + "key_names": ["id"], + "key_types": ["uint64"], + "table_id": 60901, + "secondary_indexes": [ + { + "name": "bychainad", + "key_type": "uint128", + "table_id": 48559 + } + ] + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [], + "enums": [ + { + "name": "ChainKind", + "type": "int32", + "values": [ + { + "name": "CHAIN_KIND_UNKNOWN", + "value": 0 + }, + { + "name": "CHAIN_KIND_WIRE", + "value": 1 + }, + { + "name": "CHAIN_KIND_EVM", + "value": 2 + }, + { + "name": "CHAIN_KIND_SVM", + "value": 3 + } + ] + } + ] +} \ No newline at end of file diff --git a/contracts/sysio.dclaim/sysio.dclaim.wasm b/contracts/sysio.dclaim/sysio.dclaim.wasm new file mode 100755 index 0000000000..c8727667f6 Binary files /dev/null and b/contracts/sysio.dclaim/sysio.dclaim.wasm differ diff --git a/contracts/sysio.dclaim/tools/convert_import.py b/contracts/sysio.dclaim/tools/convert_import.py new file mode 100755 index 0000000000..6e8ef910a1 --- /dev/null +++ b/contracts/sysio.dclaim/tools/convert_import.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Convert the indexer JSON dump into sysio.dclaim::importseed action batches. + +Live sources: + ETH: curl -H 'x-api-key: ' https://index.wire.foundation/opp/balances + SOL: curl -H 'x-api-key: ' https://index.wire.foundation/opp/solana/balances + +Schema (verified 2026-05-13): + metadata bookkeeping; not consumed by the contract. Notable fields: + generatedAt, totalMessages, yieldDust (ETH; not present on SOL). + purchasers[] {address, totalPretokens, yieldClaimed?, ...} + owed = totalPretokens (already net of yieldClaimed; field + absent on SOL). + stakers[] {address, pretokenYield, yieldClaimed?, ...} + owed = pretokenYield - yieldClaimed (yieldClaimed absent + on SOL -> defaults to 0). + +A given address may appear in both arrays (~9 such addresses in current +SOL snapshot); contributions sum per address. + +Per-chain conventions: + CHAIN_KIND_ETHEREUM + address 0x-prefixed lowercase hex, 20 raw bytes + source 18 decimals (wei-style) + divisor 10^9 (source 1e18 -> WIRE atomic 1e9) + CHAIN_KIND_SOLANA + address base58 (case-sensitive), 32 raw bytes + source 9 decimals (lamport-style; same as WIRE atomic) + divisor 1 (no scaling needed) + +Per-address conversion: + total = sum(purchaser.totalPretokens) + + sum(staker.pretokenYield - staker.yieldClaimed) + wire_atomic = total // divisor (floor; drop sub-atomic dust) + +Rows with wire_atomic == 0 are filtered. Output is a JSON array of +importseed action arg objects, each batched up to --batch-size credits +per call. `native_address` is emitted as the hex spelling of the raw +bytes (no 0x prefix), which the sysio.dclaim ABI consumes as `bytes`. + +Batching: default is 10,000 credits per trx, which fits well inside +the 150ms execution / 500KB transaction-size envelope. At launch +scale (under ~50K users per chain) this typically produces a single +batch; importseed accrues into `unmapped_tokens` with zero inline +transfers, so the only cost driver is the per-row table write. +Operators only need to lower --batch-size if a trx hits the size or +execution limit at very high user counts. + +Usage: + ./convert_import.py eth_balances.json --chain CHAIN_KIND_ETHEREUM > eth.json + ./convert_import.py sol_balances.json --chain CHAIN_KIND_SOLANA > sol.json +""" + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path +from typing import Callable + + +# --------------------------------------------------------------------------- +# Base58 decoder (Bitcoin / Solana alphabet). Stdlib has no base58. +# --------------------------------------------------------------------------- +B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +B58_INDEX = {c: i for i, c in enumerate(B58_ALPHABET)} + + +def base58_decode(s: str) -> bytes: + n = 0 + for c in s: + if c not in B58_INDEX: + raise ValueError(f"invalid base58 character: {c!r}") + n = n * 58 + B58_INDEX[c] + leading_ones = len(s) - len(s.lstrip("1")) + payload = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b"" + return b"\x00" * leading_ones + payload + + +def eth_decode(addr: str) -> bytes: + return bytes.fromhex(addr.lower().removeprefix("0x")) + + +# --------------------------------------------------------------------------- +# Chain configuration +# --------------------------------------------------------------------------- +CHAIN_CONFIG = { + "CHAIN_KIND_ETHEREUM": { + "decoder": eth_decode, + "addr_len": 20, + "divisor": 10**9, # source 1e18 -> WIRE atomic 1e9 + }, + "CHAIN_KIND_SOLANA": { + "decoder": base58_decode, + "addr_len": 32, + "divisor": 1, # source 1e9 already matches WIRE atomic + }, +} + + +# --------------------------------------------------------------------------- +# Pipeline +# --------------------------------------------------------------------------- +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser( + description="Emit sysio.dclaim::importseed batches from the indexer JSON.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + ap.add_argument("input", type=Path, + help="Indexer JSON file (ETH or SOL /opp/[solana/]balances shape)") + ap.add_argument("--batch-size", type=int, default=10_000, + help="Credits per importseed call (default: 10000; " + "fits the 150ms/500KB trx envelope with headroom). " + "Lower this only if you hit a trx size or execution limit.") + ap.add_argument("--chain", default="CHAIN_KIND_ETHEREUM", + choices=sorted(CHAIN_CONFIG.keys()), + help="ChainKind enum name (default: CHAIN_KIND_ETHEREUM)") + return ap.parse_args() + + +def accumulate(data: dict, decoder: Callable[[str], bytes], addr_len: int) -> dict[bytes, int]: + """Return {raw_address_bytes: total_pretokens_owed}. + + Addresses appearing in both `purchasers` and `stakers` sum together. + The accumulator is keyed by decoded raw bytes so that case / format + normalization happens exactly once at decode time. + """ + acc: dict[bytes, int] = defaultdict(int) + + def addr_bytes(s: str) -> bytes: + b = decoder(s) + if len(b) != addr_len: + raise ValueError(f"address {s!r} decoded to {len(b)} bytes, expected {addr_len}") + return b + + for row in data.get("purchasers") or []: + acc[addr_bytes(row["address"])] += int(row["totalPretokens"]) + for row in data.get("stakers") or []: + owed = int(row["pretokenYield"]) - int(row.get("yieldClaimed", "0")) + if owed > 0: + acc[addr_bytes(row["address"])] += owed + return acc + + +def to_credits(accumulator: dict[bytes, int], divisor: int) -> tuple[list[dict], int]: + """Floor each total at `divisor`; return (credits, dropped_dust_total).""" + credits = [] + dropped_dust = 0 + for addr_bytes, total in sorted(accumulator.items()): + atomic, dust = divmod(total, divisor) + dropped_dust += dust + if atomic <= 0: + continue + credits.append({"native_address": addr_bytes.hex(), "wire_atomic": atomic}) + return credits, dropped_dust + + +def batched(credits: list[dict], chain: str, batch_size: int) -> list[dict]: + return [ + {"chain": chain, "credits": credits[i:i + batch_size]} + for i in range(0, len(credits), batch_size) + ] + + +def main() -> int: + args = parse_args() + cfg = CHAIN_CONFIG[args.chain] + + data = json.loads(args.input.read_text()) + accumulator = accumulate(data, cfg["decoder"], cfg["addr_len"]) + credits, dropped_dust = to_credits(accumulator, cfg["divisor"]) + batches = batched(credits, args.chain, args.batch_size) + + json.dump(batches, sys.stdout, indent=2) + sys.stdout.write("\n") + + total_atomic = sum(c["wire_atomic"] for c in credits) + meta = data.get("metadata") or {} + lines = [ + f"input: {args.input}", + f"chain: {args.chain}", + f"generatedAt: {meta.get('generatedAt', '')}", + f"totalMessages: {meta.get('totalMessages', '')}", + f"indexer yieldDust: {meta.get('yieldDust', '')}" + " (indexer-side ledger; ETH only)", + f"unique addresses: {len(accumulator)}", + f"non-zero credits: {len(credits)}", + f"batches: {len(batches)} (size {args.batch_size})", + f"total credited: {total_atomic} atomic WIRE" + f" ({total_atomic / 10**9:.6f} WIRE)", + f"dropped dust: {dropped_dust} sub-atomic units" + f" (divisor {cfg['divisor']})", + ] + print(*lines, sep="\n", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 127e18c003..f514e7b605 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -501,30 +501,33 @@ void dispatch_attestation(name self, uint64_t attestation_id, break; case AttestationType::ATTESTATION_TYPE_STAKING_REWARD: - // Outpost-side staker reward — credit the outpost-side reserve. - // The matching WIRE-side payout to the staker is a separate - // next-epoch action owned by the staking work stream. - // - // Post v6: chain + reserve + token identity are all carried on - // the attestation as codenames; `from_chain` (VM family) is no - // longer the routing key. + // Per-staker staking reward -> sysio.dclaim claim ledger. The v6 + // staking-reward path does not deposit back to a reserve (the + // external-pool credit and native -> WIRE conversion are + // outpost-side), so the pre-v6 reserv::onreward leg is dropped and + // reward_amount.amount is forwarded as the WIRE-denominated credit. { opp::attestations::StakingReward sr; auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; auto rc = in(sr); if (rc != zpp::bits::errc{}) break; - // Split reward_amount (TokenAmount) into (chain_code, token_code, - // reserve_code, amount) on the inline action per the - // no-proto-messages-in-actions rule. const uint64_t reward_raw = static_cast(static_cast(sr.reward_amount.amount)); + // staker_wire_account.name is the raw account string (empty => + // staker not yet AuthX-linked, so sysio.dclaim parks by native + // address). staker_native_address carries the chain (kind) and + // the raw address bytes. action( permission_level{self, "active"_n}, - RESERV_ACCOUNT, "onreward"_n, - std::make_tuple(sysio::slug_name{sr.chain_code}, - sysio::slug_name{sr.reward_amount.token_code}, - sysio::slug_name{sr.reserve_code}, - reward_raw) + "sysio.dclaim"_n, "onreward"_n, + std::make_tuple(sr.outpost_id, + sr.staker_wire_account.name, + sr.staker_native_address.kind, + sr.staker_native_address.address, + reward_raw, + sr.reward_epoch_index, + sr.external_epoch_ref, + sr.share_bps) ).send(); } break; diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index d76f99a302..fbfd9d3d53 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp index f20e7a23e3..07486a6a5b 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp @@ -565,18 +565,21 @@ DataStream& operator>>(DataStream& ds, NodeOwnerReg& t) { return ds >> t.owner_address >> t.token_id >> t.nft_address; } -// StakingReward — v6: adds chain_code + reserve_code. +// StakingReward — the single staker-reward feedback path. `sysio.msgch` +// routes it twice: the aggregate native amount to `sysio.reserv::onreward` +// (outpost-side reserve), and the per-staker body to `sysio.dclaim::onreward` +// (WIRE-side claim ledger). Field order mirrors the proto (1..7). template DataStream& operator<<(DataStream& ds, const StakingReward& t) { - return ds << t.chain_code << t.staker_wire_account << t.share_bps - << t.period_start_ms << t.period_end_ms << t.reward_amount - << t.chain_code << t.reserve_code; + return ds << t.outpost_id << t.staker_wire_account << t.share_bps + << t.reward_epoch_index << t.external_epoch_ref + << t.reward_amount << t.staker_native_address; } template DataStream& operator>>(DataStream& ds, StakingReward& t) { - return ds >> t.chain_code >> t.staker_wire_account >> t.share_bps - >> t.period_start_ms >> t.period_end_ms >> t.reward_amount - >> t.chain_code >> t.reserve_code; + return ds >> t.outpost_id >> t.staker_wire_account >> t.share_bps + >> t.reward_epoch_index >> t.external_epoch_ref + >> t.reward_amount >> t.staker_native_address; } // --------------------------------------------------------------------------- diff --git a/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp b/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp index b69625875b..ecbeea16f1 100644 --- a/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp +++ b/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp @@ -14,6 +14,9 @@ #include +#include +#include + namespace sysio { /** diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 3e9f28ecc9..f5e749fd24 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -49,7 +49,7 @@ constexpr uint32_t ACTIVE_PRODUCER_WEIGHT = 15; // > any standby weight (1..cfg. // Basis-point denominator for all category / sub-split ratios. constexpr int64_t BPS_DENOMINATOR = 10000; -constexpr sysio::name CAPITAL_ACCOUNT = "sysio.cap"_n; +constexpr sysio::name CAPITAL_ACCOUNT = "sysio.dclaim"_n; constexpr sysio::name GOVERNANCE_ACCOUNT = "sysio.gov"_n; // Capex ("capital expenditure") bucket lives on sysio.ops -- operational spend. constexpr sysio::name CAPEX_OPERATIONS_ACCOUNT = "sysio.ops"_n; diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 89773653ef..3c9d6e687d 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ diff --git a/contracts/sysio.uwrit/src/sysio.uwrit.cpp b/contracts/sysio.uwrit/src/sysio.uwrit.cpp index a870b959e8..b067fe5488 100644 --- a/contracts/sysio.uwrit/src/sysio.uwrit.cpp +++ b/contracts/sysio.uwrit/src/sysio.uwrit.cpp @@ -455,12 +455,14 @@ void emit_swap_remit(name self, /// /// **Per `feedback_opp_handlers_never_throw.md` — this MUST stay /// non-throwing.** It's called from `try_select_winner`, which runs inside -/// the evalcons inline-action chain; a `check()` failure here halts -/// consensus. The defensive size+tag bounds catch the obvious cases; the -/// `sysio::recover_key_nothrow` intrinsic catches everything else the -/// host crypto path can raise (malformed bytes, unactivated signature -/// type, recovery math failure, subjective-size limit) and returns -/// `std::nullopt` instead. +/// the evalcons inline-action chain; a `check()`/throw here halts +/// consensus. The defensive size+tag bounds below reject the structurally +/// invalid signatures before recovery. Recovery itself uses +/// `sysio::recover_key`; converting its remaining contract-observable +/// throws (unactivated variant, recovery-math failure) to an rc = -1 +/// sentinel — so this is non-throwing on every input — is tracked as +/// separate host+CDT work landing in its own PR. Until then a crafted but +/// size/tag-valid signature can still throw here. bool verify_uic_signature(name underwriter, const std::vector& uic_bytes) { if (uic_bytes.empty()) return false; diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index f26012353e..4a507fc4a8 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -30,6 +30,8 @@ struct contracts { static std::vector chalg_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.chalg/sysio.chalg.abi"); } static std::vector reserve_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.reserv/sysio.reserv.wasm"); } static std::vector reserve_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.reserv/sysio.reserv.abi"); } + static std::vector dclaim_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.dclaim/sysio.dclaim.wasm"); } + static std::vector dclaim_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.dclaim/sysio.dclaim.abi"); } static std::vector chains_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.chains/sysio.chains.wasm"); } static std::vector chains_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.chains/sysio.chains.abi"); } static std::vector tokens_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.tokens/sysio.tokens.wasm"); } diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index b926e29ccf..91c0330155 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -408,7 +408,7 @@ class sysio_emissions_tester : public tester { // ----------------------------- void create_t5_holding_accounts() { vector accts = { - "sysio.cap"_n, "sysio.gov"_n, "sysio.batch"_n, "sysio.ops"_n + "sysio.dclaim"_n, "sysio.gov"_n, "sysio.batch"_n, "sysio.ops"_n }; for (auto a : accts) { if (!control->db().find(a)) { @@ -2486,7 +2486,7 @@ BOOST_FIXTURE_TEST_CASE( active_producers_get_equal_share, sysio_emissions_teste // --------------------------------------------------------------------------- BOOST_FIXTURE_TEST_CASE( holding_accounts_receive_correct_amounts, sysio_emissions_tester ) try { - // Category bucket recipients are fixed accounts (sysio.cap / sysio.gov / sysio.ops). + // Category bucket recipients are fixed accounts (sysio.dclaim / sysio.gov / sysio.ops). // The batch-op share is NOT sent to a holding account -- it is split across the // members of the current sysio.epoch batch_op_groups rotation slot. When no // operators are registered, the entire batch_pool stays in the treasury. @@ -2494,7 +2494,7 @@ BOOST_FIXTURE_TEST_CASE( holding_accounts_receive_correct_amounts, sysio_emissio const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - asset cap_before = get_wire_balance("sysio.cap"_n); + asset dclaim_before = get_wire_balance("sysio.dclaim"_n); asset gov_before = get_wire_balance("sysio.gov"_n); asset batch_before = get_wire_balance("sysio.batch"_n); asset ops_before = get_wire_balance("sysio.ops"_n); @@ -2507,12 +2507,12 @@ BOOST_FIXTURE_TEST_CASE( holding_accounts_receive_correct_amounts, sysio_emissio int64_t gov = log["governance_amount"].as(); int64_t capex_base = test_split_bps(emission, CAPEX_BPS); - int64_t cap_received = get_wire_balance("sysio.cap"_n).get_amount() - cap_before.get_amount(); + int64_t dclaim_received = get_wire_balance("sysio.dclaim"_n).get_amount() - dclaim_before.get_amount(); int64_t gov_received = get_wire_balance("sysio.gov"_n).get_amount() - gov_before.get_amount(); int64_t batch_received = get_wire_balance("sysio.batch"_n).get_amount() - batch_before.get_amount(); int64_t ops_received = get_wire_balance("sysio.ops"_n).get_amount() - ops_before.get_amount(); - BOOST_REQUIRE_EQUAL( cap_received, capital ); + BOOST_REQUIRE_EQUAL( dclaim_received, capital ); BOOST_REQUIRE_EQUAL( gov_received, gov ); // sysio.batch is not an emissions recipient anymore -- batch pay goes to the // current rotation group, not a holding account. diff --git a/contracts/tests/sysio.dclaim_tests.cpp b/contracts/tests/sysio.dclaim_tests.cpp new file mode 100644 index 0000000000..a9e56a80ca --- /dev/null +++ b/contracts/tests/sysio.dclaim_tests.cpp @@ -0,0 +1,246 @@ +#include +#include +#include + +#include + +#include "contracts.hpp" +#include + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; +using namespace fc; +using namespace sysio::opp::types; + +using mvo = fc::mutable_variant_object; + +/// Test fixture for sysio.dclaim. Deploys sysio.dclaim and creates sysio.msgch / +/// sysio.authex as the authorized inbound callers. The v6 staking-reward path +/// credits a WIRE-denominated amount directly (native -> WIRE conversion is +/// outpost-side), so no sysio.reserv deployment is needed. +class sysio_dclaim_tester : public tester { +public: + static constexpr auto DCLAIM_ACCOUNT = "sysio.dclaim"_n; + static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; + static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; + static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; + + sysio_dclaim_tester() { + produce_blocks(2); + // sysio.authex is created by the tester bootstrap (account-linking + // system account); it is signed for directly to drive linkswept, never + // re-created here. + create_accounts({ + DCLAIM_ACCOUNT, MSGCH_ACCOUNT, TOKEN_ACCOUNT, "alice"_n, "bob"_n, + }); + produce_blocks(2); + + set_code(DCLAIM_ACCOUNT, contracts::dclaim_wasm()); + set_abi(DCLAIM_ACCOUNT, contracts::dclaim_abi().data()); + set_privileged(DCLAIM_ACCOUNT); + produce_blocks(); + + dclaim_abi_ser.set_abi(load_abi(DCLAIM_ACCOUNT), + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + abi_def load_abi(name account) { + const auto* accnt = control->find_account_metadata(account); + BOOST_REQUIRE(accnt != nullptr); + abi_def abi; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt->abi, abi), true); + return abi; + } + + action_result push(name code, abi_serializer& ser, name signer, + name action_name, const variant_object& data) { + try { + string atype = ser.get_action_type(action_name); + action act; + act.account = code; + act.name = action_name; + act.data = ser.variant_to_binary( + atype, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + act.authorization = vector{{signer, config::active_name}}; + signed_transaction trx; + trx.actions.emplace_back(std::move(act)); + set_transaction_headers(trx); + trx.sign(get_private_key(signer, "active"), control->get_chain_id()); + push_transaction(trx); + // Close a block per applied action: each OPP inbound / crank is its + // own transaction in production, and distinct TaPoS makes an + // intentional replay (e.g. the dedupe test) a new transaction that + // actually reaches the contract instead of being rejected chain-side + // as a duplicate. + produce_block(); + return success(); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + } + + action_result push_dclaim(name signer, name action_name, const variant_object& data) { + return push(DCLAIM_ACCOUNT, dclaim_abi_ser, signer, action_name, data); + } + + /// Dispatch a STAKING_REWARD per-staker body to dclaim::onreward. `amount` is + /// the WIRE-denominated reward (native -> WIRE conversion is outpost-side). + action_result onreward(name signer, uint64_t outpost_id, + const std::string& wire_account, ChainKind chain, + const std::vector& native_addr, + uint64_t amount, uint32_t epoch_index, + uint64_t external_epoch_ref, uint32_t share_bps = 10000) { + return push_dclaim(signer, "onreward"_n, mvo() + ("outpost_id", outpost_id) + ("staker_wire_account", wire_account) + ("reward_chain", chain) + ("staker_native_addr", native_addr) + ("reward_amount", amount) + ("reward_epoch_index", epoch_index) + ("external_epoch_ref", external_epoch_ref) + ("share_bps", share_bps)); + } + + fc::variant get_kv(name table, const char* type, uint64_t id) { + auto data = get_row_by_id(DCLAIM_ACCOUNT, DCLAIM_ACCOUNT, table, id); + return data.empty() ? fc::variant() + : dclaim_abi_ser.binary_to_variant( + type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + fc::variant pending_of(name acct) { return get_kv("pclaims"_n, "pending_claim", acct.to_uint64_t()); } + 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); } + + std::vector addr20{std::vector(20, char(0xA1))}; + + abi_serializer dclaim_abi_ser; +}; + +BOOST_AUTO_TEST_SUITE(sysio_dclaim_tests) + +// -- config / import surface -- + +BOOST_FIXTURE_TEST_CASE(setconfig_initializes_singleton, sysio_dclaim_tester) { try { + BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "setconfig"_n, mvo{}), success()); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(claim_rejects_empty_ledger, sysio_dclaim_tester) { try { + BOOST_REQUIRE_NE(push_dclaim("alice"_n, "claim"_n, mvo()("wire_account", "alice")), success()); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(importseed_accepts_credit_batch, sysio_dclaim_tester) { try { + BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "importseed"_n, mvo + ("chain", ChainKind::CHAIN_KIND_EVM) + ("credits", fc::variants{ mvo()("native_address", addr20)("wire_atomic", 982953049502) })), + success()); + // Pre-launch import lands as an unmapped balance (unlinked by definition). + auto u = unmapped_row(1); + BOOST_REQUIRE(!u.is_null()); + BOOST_REQUIRE_EQUAL(u["balance"].as_string().substr(0, 3), std::string("982")); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(importseed_rejects_negative_atomic, sysio_dclaim_tester) { try { + BOOST_REQUIRE_NE(push_dclaim(DCLAIM_ACCOUNT, "importseed"_n, mvo + ("chain", ChainKind::CHAIN_KIND_EVM) + ("credits", fc::variants{ mvo()("native_address", addr20)("wire_atomic", -1) })), + success()); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(importdone_locks_subsequent_importseed, sysio_dclaim_tester) { try { + BOOST_REQUIRE_EQUAL(push_dclaim(DCLAIM_ACCOUNT, "importdone"_n, mvo{}), success()); + BOOST_REQUIRE_NE(push_dclaim(DCLAIM_ACCOUNT, "importseed"_n, mvo + ("chain", ChainKind::CHAIN_KIND_EVM) + ("credits", fc::variants{ mvo()("native_address", addr20)("wire_atomic", 1) })), + 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_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 { + BOOST_REQUIRE_NE( + onreward("alice"_n, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 7, 100), + success()); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(onreward_linked_credits_pending_claims, sysio_dclaim_tester) { try { + BOOST_REQUIRE_EQUAL( + onreward(MSGCH_ACCOUNT, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 7, 100), + success()); + // reward_amount is already WIRE-denominated -> credited verbatim. + auto p = pending_of("alice"_n); + BOOST_REQUIRE(!p.is_null()); + BOOST_REQUIRE_EQUAL(p["balance"].as().get_amount(), 1000); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(onreward_unlinked_parks_unmapped_then_linkswept, sysio_dclaim_tester) { try { + // Empty wire account -> parked in unmapped by native address. + BOOST_REQUIRE_EQUAL( + onreward(MSGCH_ACCOUNT, 1, "", ChainKind::CHAIN_KIND_EVM, addr20, 5000, 7, 100), + success()); + BOOST_REQUIRE(pending_of("bob"_n).is_null()); + auto u = unmapped_row(1); + BOOST_REQUIRE(!u.is_null()); + BOOST_REQUIRE_EQUAL(u["balance"].as().get_amount(), 5000); + + // AuthX link sweeps it into pending_claims for bob. + 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_EQUAL(pending_of("bob"_n)["balance"].as().get_amount(), 5000); +} FC_LOG_AND_RETHROW() } + +// -- dedupe cursor -- + +BOOST_FIXTURE_TEST_CASE(onreward_dedupes_stale_external_ref, sysio_dclaim_tester) { try { + BOOST_REQUIRE_EQUAL( + onreward(MSGCH_ACCOUNT, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 7, 100), + success()); + BOOST_REQUIRE_EQUAL(pending_of("alice"_n)["balance"].as().get_amount(), 1000); + + // Replay same external_epoch_ref -> admitted=false -> no extra credit. + BOOST_REQUIRE_EQUAL( + onreward(MSGCH_ACCOUNT, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 7, 100), + success()); + BOOST_REQUIRE_EQUAL(pending_of("alice"_n)["balance"].as().get_amount(), 1000); + + // A newer external_epoch_ref is accepted and adds more. + BOOST_REQUIRE_EQUAL( + onreward(MSGCH_ACCOUNT, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 8, 101), + success()); + BOOST_REQUIRE_EQUAL(pending_of("alice"_n)["balance"].as().get_amount(), 2000); +} FC_LOG_AND_RETHROW() } + +// -- claimable window expiry / reversion -- + +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_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 +} FC_LOG_AND_RETHROW() } + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp index 70ab8eb367..9b4705edfa 100644 --- a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp +++ b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp @@ -52,7 +52,7 @@ class sysio_epoch_flushwtdw_tester : public tester { // RAM to new accounts — too small for the sysio.token deploy // (~100KB). Doing creation first sidesteps that. // - // sysio.cap / sysio.gov / sysio.ops are payepoch destinations for + // sysio.dclaim / sysio.gov / sysio.ops are payepoch destinations for // the capital / governance / capex emission buckets; without them // the first pay epoch's WIRE transfer fails with "to account does // not exist". sysio.authex is auto-created by base_tester (see @@ -61,7 +61,7 @@ class sysio_epoch_flushwtdw_tester : public tester { create_accounts({ TOKEN_ACCOUNT, EPOCH_ACCOUNT, OPREG_ACCOUNT, MSGCH_ACCOUNT, CHALG_ACCOUNT, CHAINS_ACCOUNT, UWRIT_ACCOUNT, BATCHOP, UWRIT_OP, - "sysio.cap"_n, "sysio.gov"_n, "sysio.ops"_n + "sysio.dclaim"_n, "sysio.gov"_n, "sysio.ops"_n }); produce_blocks(2); diff --git a/libraries/opp/proto/sysio/opp/attestations/attestations.proto b/libraries/opp/proto/sysio/opp/attestations/attestations.proto index 6e813d6a53..6c2cbf301d 100644 --- a/libraries/opp/proto/sysio/opp/attestations/attestations.proto +++ b/libraries/opp/proto/sysio/opp/attestations/attestations.proto @@ -277,16 +277,42 @@ message NodeOwnerReg { sysio.opp.types.ChainAddress nft_address = 3; } -// Staking reward distribution (Outpost -> Depot). +// Staking reward distribution (Outpost -> Depot), per staker per source-chain +// reward period. +// +// Emitted by an outpost when a staker's slice of a validator-reward +// distribution has been computed. `sysio.msgch` routes it two ways: the +// aggregate native amount credits the outpost-side reserve +// (`sysio.reserv::onreward`, keeps `swapquote` solvent), and the per-staker +// body is dispatched to `sysio.cap::onreward`, which prices native -> WIRE off +// the reserve and credits the staker's claim ledger (`pending_claims` if the +// staker has an AuthX-linked WIRE account, otherwise parked in +// `unmapped_tokens` keyed by the native address until they link). message StakingReward { - uint64 outpost_id = 1; + // Outpost id emitting the reward — identifies the (chain, validator) context. + uint64 outpost_id = 1; + // The staker's WIRE account. May be empty when the staker has not yet + // completed AuthX linking; in that case the depot parks the reward by + // `staker_native_address` until the link sweep moves it to the claim ledger. sysio.opp.types.WireAccount staker_wire_account = 2; - uint32 share_bps = 3; - uint64 period_start_ms = 4; - uint64 period_end_ms = 5; - sysio.opp.types.TokenAmount reward_amount = 6; - uint64 chain_code = 7; - uint64 reserve_code = 8; + // The staker's share of the reward in basis points (10000 = 100%). + // Informational / audit only — `reward_amount` is already the staker's + // absolute, already-prorated portion (the depot does not re-split). + uint32 share_bps = 3; + // WIRE epoch index in which this reward is being recorded. Informational for + // roll-up / audit; dedupe and pruning key off `external_epoch_ref`. + uint32 reward_epoch_index = 4; + // Source-chain reward-period reference (the external chain's epoch / period + // number for this distribution). Monotonic per (outpost, staker); the depot + // dedupes and rolls up against it and rejects anything at or below the last + // processed value, so no per-period history is retained (data-leak safe). + uint64 external_epoch_ref = 5; + // Absolute reward amount + token kind being credited (native-denominated). + sysio.opp.types.TokenAmount reward_amount = 6; + // The staker's native (ETH / SOL / ...) address — the address they staked + // from. Always populated so a not-yet-linked staker's reward can be parked + // and made claimable the moment they complete AuthX linking. + sysio.opp.types.ChainAddress staker_native_address = 7; } message StakeResult {