diff --git a/OVERVIEW.md b/OVERVIEW.md index 3f38fc3b21..a27b66d7df 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -30,7 +30,7 @@ Envelope (one per outpost per epoch, per direction) - **Epochs**: Time-bounded intervals managed by `sysio.epoch` — one global epoch clock for every chain. Each epoch has one group of batch operators on duty as relayers. - **Envelopes**: Epoch-level containers. Each envelope carries the epoch index and timestamp, its route endpoints, and `previous_envelope_hash` — the digest of the previous envelope on the same (depot, outpost) stream — so each direction forms a hash chain. An envelope is capped at 32,768 bytes on every chain. - **Messages**: Individual cross-chain payloads containing a header (message ID, sequence, checksum, encoding flags) and a payload (an array of attestation entries). -- **Attestations**: Typed data units that represent cross-chain actions — operator rosters, liquidity syndication and desyndication, yield reports, node-owner registrations, staking rewards. +- **Attestations**: Typed data units that represent cross-chain actions — operator rosters, liquidity syndication and desyndication, yield reports and node-owner registrations. ### Chain Addressing @@ -71,7 +71,6 @@ This allows a single unified message format across all supported chains. | **Liquidity Syndication** | `SYNDICATE_LIQ`, `LIQ_YIELD` | outpost → depot | A user syndicating liq tokens into outpost custody; realized custody yield | | **Liquidity Desyndication** | `DESYNDICATE_LIQ` | depot → outpost | Return syndicated liq tokens to a holder on the outpost | | **Node Owners** | `NODE_OWNER_REG` | outpost → depot | Node-owner NFT committed on Ethereum, registering the owner on the depot | -| **Staking** | `STAKING_REWARD` | outpost → depot | Per-staker reward credited to the `sysio.dclaim` claim ledger (validator-staking lifecycle types are reserved but deferred) | Cross-chain swaps routed through outposts, outpost reserves and outpost-side operator collateral have been removed. Swaps are a depot-local AMM (`sysio.swap`) with no outpost participation; operator collateral is held on the depot by `sysio.opreg`. @@ -112,7 +111,7 @@ Holders of a node-owner NFT who commit it on the Ethereum outpost (`NODE_OWNER_R | **sysio.bond** | Generic bonded underwriting of provable statements | | **sysio.liq** | Shadow liq tokens (`LIQETH`, `LIQSOL`) minted against outpost custody, with a WIRE yield index | | **sysio.swap** | Depot-local constant-product AMM; every pair's second leg is WIRE | -| **sysio.dclaim** | WIRE claim ledger (launch import, staking rewards) | +| **sysio.dclaim** | Funded WIRE launch import, account linking and claims | | **sysio.andon** | Emergency stop: one cord freezing depot fund egress | | **sysio.roa** | Resources and node-owner registry | | **sysio.authex** | Links a depot account to its external-chain keys | diff --git a/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp b/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp index af96e12269..2bad18cc48 100644 --- a/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp +++ b/contracts/sysio.dclaim/include/sysio.dclaim/sysio.dclaim.hpp @@ -19,15 +19,8 @@ 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. + * Holds funded pre-launch WIRE distribution credits and preserves existing claims. + * External staking rewards no longer create credits or draw from T5. * * Ledgers: * - `pending_claims` — per-Wire-account WIRE owed. `claim` drains a row via @@ -35,10 +28,6 @@ namespace sysio { * - `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-(chain_code, 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). * * Credited and imported balances never expire, including balances waiting * for AuthX linking. They remain owed until claimed; there is no expiry @@ -56,10 +45,7 @@ namespace sysio { // 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; - static constexpr name SYSTEM_ACCOUNT = "sysio"_n; /// WIRE token symbol with the system-wide nine-decimal precision. static constexpr symbol WIRE_SYM = opp::wire::asset_symbol; @@ -88,37 +74,6 @@ namespace sysio { 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 chain_code Emitting outpost's chain code (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 (chain, staker) — dedupe key. - /// @param share_bps Staker share in bps (informational only). - [[sysio::action]] - void onreward(uint64_t chain_code, - 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); - /// 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 @@ -194,37 +149,7 @@ namespace sysio { sysio::const_mem_fun> >; - /// Per-(chain_code, 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 chain_code = 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_chaincode_addr() const { - return chaincode_addr_key(chain_code, chain, native_pubkey); - } - - SYSLIB_SERIALIZE(reward_cursor, - (id)(chain_code)(chain)(native_pubkey)(last_external_epoch_ref)) - }; - - using rwdcursors_t = sysio::kv::table<"rwdcursors"_n, rwdcur_key, reward_cursor, - sysio::kv::index<"bychaincode"_n, - sysio::const_mem_fun> - >; - - /// Cap-staking config singleton. + /// Bootstrap import config singleton. struct [[sysio::table("capcfg")]] cap_config { /// One-way flag protecting the bootstrap `importseed` action. bool imported_complete = false; @@ -236,9 +161,8 @@ namespace sysio { /// 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)) + SYSLIB_SERIALIZE(cap_counters, (next_unmapped_id)) }; using capcounters_t = sysio::kv::global<"capcounters"_n, cap_counters>; @@ -263,19 +187,6 @@ namespace sysio { return (static_cast(chain) << 64) | prefix; } - /// (chain_code, chain, native address) -> uint128 narrowing key. High 64 - /// bits = chain_code; low 64 = chain (high 32) xored with the first 4 - /// address bytes. - static uint128_t chaincode_addr_key(uint64_t chain_code, - 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(chain_code) << 64) | lo; - } - private: using ChainKind = opp::types::ChainKind; using TokenKind = opp::types::TokenKind; diff --git a/contracts/sysio.dclaim/src/sysio.dclaim.cpp b/contracts/sysio.dclaim/src/sysio.dclaim.cpp index c72417f42b..de305ce1ee 100644 --- a/contracts/sysio.dclaim/src/sysio.dclaim.cpp +++ b/contracts/sysio.dclaim/src/sysio.dclaim.cpp @@ -1,5 +1,4 @@ #include -#include #include #include @@ -18,8 +17,7 @@ constexpr name ram_payer = "sysio"_n; /// 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). +/// predicate resolves prefix collisions deterministically for unmapped-credit lookups. template auto scan_find(Index& idx, uint128_t key, KeyFn key_of, MatchFn matches) { auto it = idx.lower_bound(key); @@ -42,14 +40,9 @@ uint64_t next_id(name self, Pick pick) { return id; } -/// Non-throwing validation of a string destined for `name(std::string_view)`. Shared with every -/// other OPP inbound handler via `sysio.opp.common/safe_ops.hpp` so the never-throw name domain is -/// defined and audited in exactly one place. - /// Saturating WIRE credit. `asset::operator+=` aborts on overflow past `asset::max_amount` -/// (2^62-1); credit_wire runs inside the never-throw OPP inbound path (via onreward), so cap at -/// the asset maximum rather than abort. A single staker balance approaching 2^62 atomic WIRE units -/// is not reachable in practice — the cap exists purely to preserve the never-throw contract. +/// (2^62-1). AuthX linkswept is reachable through OPP node-owner registration and must not +/// abort that delivery. Both imported and swept balances preserve saturation at the asset maximum. inline void add_wire_capped(asset& balance, const asset& amt) { const int64_t room = asset::max_amount - balance.amount; // balance.amount in [0, max_amount] balance.amount += (amt.amount <= room ? amt.amount : room); @@ -70,16 +63,9 @@ void credit_pending(name self, name wacct, const asset& amt) { } } -/// Credit `amt` WIRE to the staker. Linked (`wacct` set) -> `pending_claims`; -/// 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) { - if (wacct.value != 0) { - credit_pending(self, wacct, amt); - return; - } - +/// Park an imported WIRE credit by native identity until AuthX links it to a claim account. +void credit_unmapped(name self, ChainKind chain, + const std::vector& addr, const asset& amt) { dclaim::unmapped_t unmapped(self); auto idx = unmapped.template get_index<"bychainad"_n>(); auto it = scan_find(idx, dclaim::chain_addr_key(chain, addr), @@ -104,42 +90,6 @@ void credit_wire(name self, name wacct, ChainKind chain, } } -/// Dedupe at ingest. Returns true if `(chain_code, 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 chain_code, ChainKind chain, - const std::vector& addr, uint64_t ext_ref) { - dclaim::rwdcursors_t cur(self); - auto idx = cur.template get_index<"bychaincode"_n>(); - auto it = scan_find(idx, dclaim::chaincode_addr_key(chain_code, chain, addr), - [](const auto& r) { return r.by_chaincode_addr(); }, - [&](const auto& r) { - return r.chain_code == chain_code - && 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(ram_payer, dclaim::rwdcur_key{id}, - dclaim::reward_cursor{ .id = id, - .chain_code = chain_code, - .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 // --------------------------------------------------------------------------- @@ -196,61 +146,6 @@ void dclaim::linkswept(name wire_account, ChainKind chain, std::vector nat } } -// --------------------------------------------------------------------------- -// onreward — per-staker WIRE-side credit of a STAKING_REWARD -// --------------------------------------------------------------------------- -void dclaim::onreward(uint64_t chain_code, - 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). reward_amount is cross-chain-supplied; - // bound it to the asset range here so an oversized value soft-drops instead of aborting later - // when the WIRE asset is constructed/credited (asset()/operator+= range-check abort). - if (reward_amount == 0 || reward_amount > static_cast(asset::max_amount) || - staker_native_addr.empty()) return; - - // Dedupe at ingest so a replay / out-of-order duplicate is rejected. - if (!cursor_admit(get_self(), chain_code, reward_chain, - staker_native_addr, external_epoch_ref)) { - return; - } - - name wacct; // value 0 == not yet AuthX-linked - // Validate the cross-chain-supplied account string before constructing name(): an invalid - // string is treated as unlinked (credit parked by native address) rather than aborting the - // inbound dispatch via name()'s internal check(). `is_valid_literal` is name's OWN predicate -- - // the one its constructor uses -- so it cannot drift from what the constructor accepts. - if (!staker_wire_account.empty() && sysio::name::is_valid_literal(staker_wire_account)) { - 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 }); - - // Pull funding from sysio.system's drainable pool so the dclaim balance - // covers this credit immediately -- a staker can claim in the next block - // rather than waiting for a pay-epoch. sysio.system::fundclaim caps to - // the remaining pool and never throws, preserving the never-throw - // contract for OPP inbound dispatch. - action( - permission_level{ get_self(), "active"_n }, - SYSTEM_ACCOUNT, - "fundclaim"_n, - std::make_tuple(get_self(), static_cast(reward_amount)) - ).send(); -} - // --------------------------------------------------------------------------- // importseed — bootstrap pre-launch holders into unmapped_tokens // --------------------------------------------------------------------------- @@ -268,10 +163,8 @@ void dclaim::importseed(ChainKind chain, std::vector credits) { 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 non-expiring upsert path as - // staking rewards (one implementation in credit_wire). - credit_wire(get_self(), name{}, chain, credit.native_address, + // Pre-launch holders remain unmapped until an AuthX link sweeps their credit. + credit_unmapped(get_self(), chain, credit.native_address, asset{ credit.wire_atomic, WIRE_SYM }); } } diff --git a/contracts/sysio.dclaim/sysio.dclaim.abi b/contracts/sysio.dclaim/sysio.dclaim.abi index e3d84170fe..05e7714553 100644 --- a/contracts/sysio.dclaim/sysio.dclaim.abi +++ b/contracts/sysio.dclaim/sysio.dclaim.abi @@ -20,10 +20,6 @@ { "name": "next_unmapped_id", "type": "uint64" - }, - { - "name": "next_cursor_id", - "type": "uint64" } ] }, @@ -88,44 +84,6 @@ } ] }, - { - "name": "onreward", - "base": "", - "fields": [ - { - "name": "chain_code", - "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": "", @@ -150,42 +108,6 @@ } ] }, - { - "name": "reward_cursor", - "base": "", - "fields": [ - { - "name": "id", - "type": "uint64" - }, - { - "name": "chain_code", - "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": "setconfig", "base": "", @@ -245,11 +167,6 @@ "type": "linkswept", "ricardian_contract": "" }, - { - "name": "onreward", - "type": "onreward", - "ricardian_contract": "" - }, { "name": "setconfig", "type": "setconfig", @@ -281,21 +198,6 @@ "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": "bychaincode", - "key_type": "uint128", - "table_id": 60686 - } - ] - }, { "name": "unmapped", "type": "unmapped_token", diff --git a/contracts/sysio.dclaim/sysio.dclaim.wasm b/contracts/sysio.dclaim/sysio.dclaim.wasm index fae4f84a10..c1b9c6294f 100755 Binary files a/contracts/sysio.dclaim/sysio.dclaim.wasm and b/contracts/sysio.dclaim/sysio.dclaim.wasm differ diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 18c986d39b..2cfc67510d 100755 Binary files a/contracts/sysio.epoch/sysio.epoch.wasm and b/contracts/sysio.epoch/sysio.epoch.wasm differ diff --git a/contracts/sysio.liq/README.md b/contracts/sysio.liq/README.md index be42397847..dbb9690541 100644 --- a/contracts/sysio.liq/README.md +++ b/contracts/sysio.liq/README.md @@ -35,18 +35,13 @@ contract. The swap sells it in clips through the pool and pays the proceeds in through `addyield`, which advances the index by `quantity / supply`, carries the remainder and pulls the WIRE by inline transfer. -**The kicker.** On the swap's intake, the one that is yield, `addyield` requests -`kicker_bps` (default 200) of the intake from T5 through -`sysio.system::fundclaim(sysio.liq, amount)`, then folds what actually landed into -the same index with `addkicker`, measured against the balance the pull left. A -donation from any other account distributes only itself: the treasury never tops -up what is not yield, or a near-sole holder could donate, claim it back with the -kicker on top, and repeat. A short T5 reduces the kicker only. `setkicker` (auth -`sysio`, the account council proposals execute as) changes the next intake. +Yield distributions credit only WIRE supplied by the swap or a donor. No T5-funded bonus is added. **De-syndication reconciliation.** `sysio.synd::desyndicate` burns through `burn`; the burn is final. An outpost refusal is reconciled from its log by governance -through `recredit`. +through `sysio.synd::refundreturn(request_id)` only after irreversible external cancellation. +A final external payment is acknowledged with `finishreturn`; pending returns remain open. +`recredit` is reserved for exceptional repairs and must not recover a tracked return. **Launch ingestion (epoch-0 bootstrap window, privileged caller).** `regliqpool` mints the LCO liq to `sysio`, deposits it with the T5 dex earmark WIRE into @@ -62,29 +57,26 @@ and sets the tick parameters. The pre-launch positions are replayed by | `accounts` | holder / symbol code | `balance`, `index_checkpoint` (uint128), `owed_wire` | | `yieldidx` | symbol code | `index` (uint128), `pot`, `carry` | | `liqpending` | symbol code | `quantity` minted by LIQ_YIELD and not yet queued; counts against the asset range beside supply | -| `liqconfig` | singleton | `kicker_bps` | ## Actions | Action | Auth | Purpose | |---|---|---| | `create(sym, chain_code, token_code)` | self | Register a shadow for an active `TOKEN_KIND_LIQ` token bound to an active outpost | -| `setkicker(bps)` | `sysio` | Kicker for the intakes from now on | -| `recredit(holder, quantity)` | self | Mint back after an outpost refused a de-syndication | +| `recredit(holder, quantity)` | self | Exceptional privileged repair; tracked returns use `sysio.synd::refundreturn` to avoid double minting | | `mint(to, token_code, amount)` | `sysio.synd` | Mint shadow into a holder row | | `burn(token_code, amount)` | `sysio.synd` | Burn shadow out of `sysio.synd`'s own row | | `mintyield(chain_code, token_code, amount)` | `sysio.synd` | LIQ_YIELD into the pending balance | | `queueyield(sym)` | none | Pending yield into the swap's reservoir | | `transfer`, `open`, `close`, `claim` | holder | `sysio.token`'s shape; `close` refuses while yield is owed | | `addyield(from, quantity, target)` | `from` | Distribute WIRE to `target`'s holders | -| `addkicker(sym, base_balance, requested)` | self | Inline from `addyield` | | `regliqpool(...)` | privileged caller, epoch 0 | Seed a shadow's yield pool | ## Deployment In order: `sysio.roa::setsyscode` for `sysio.liq`; the chain and its liq token registered and active in `sysio.chains` / `sysio.tokens`; `create` per shadow -symbol; `setkicker` if the default is not wanted; `regliqpool` per pool; then +symbol; `regliqpool` per pool; then `sysio.synd`'s `importsynd` batches and `importdone`. `sysio.swap` must be configured (`setconfig`) before `regliqpool`, and the batch-operator crank pushes `queueyield` per symbol and `sysio.swap::tickyield` per pair. diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp index 38fd9b9672..83f7c1ea00 100644 --- a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -17,7 +17,7 @@ * hands to sysio.swap's reservoir. Return recovery is request-keyed in sysio.synd; * `recredit` remains exceptional privileged supply repair. * Yield intake: sysio.swap's tick sells reservoir shadow and pays the proceeds in - * through `addyield`, which also draws the kicker from T5 (sysio.system::fundclaim). + * through `addyield`, which distributes only the supplied WIRE proceeds. * Launch: `regliqpool` seeds the swap's yield pool inside the epoch-0 bootstrap window. * * Emergency stop: while the `sysio.andon` cord is pulled, `transfer` to anything but a @@ -30,7 +30,6 @@ #include #include -#include #include #include #include @@ -61,13 +60,9 @@ namespace sysio { /// treasury the bootstrap drains and the holder of the protocol's pool shares. static constexpr name SYSTEM_ACCOUNT = "sysio"_n; - /// The yield asset. The kicker arrives in it from sysio.system, so every - /// pot is in WIRE and nothing else. + /// The yield asset: every pot holds supplied WIRE proceeds. static constexpr symbol WIRE_SYM = opp::wire::asset_symbol; - /// The kicker at launch: 2% of every yield intake, drawn from T5. - static constexpr uint32_t DEFAULT_KICKER_BPS = 200; - // ----------------------------------------------------------------------- // Deployment and governance // ----------------------------------------------------------------------- @@ -81,16 +76,8 @@ namespace sysio { /// Requires this contract's authority. [[sysio::action]] void create(symbol sym, sysio::slug_name chain_code, sysio::slug_name token_code); - /// Set the kicker, in basis points of each yield intake, for the intakes - /// from now on. Auth=sysio: council proposals execute as it. - [[sysio::action]] void setkicker(uint32_t bps); - - /// Governance: mint `quantity` back to `holder` after its outpost refused - /// a de-syndication (reconciled from the outpost log). Requires this - /// contract's authority. - /// Legacy exceptional supply repair, still requiring this contract's authority. Normal external - /// return recovery must use sysio.synd::refundreturn so request identity and exactly-once handling - /// are enforced. This action is not evidence that an external return was rejected. + /// Privileged supply repair requiring this contract's authority; never recover a return + /// tracked in sysio.synd::returns, which must use request-keyed refundreturn instead. [[sysio::action]] void recredit(name holder, asset quantity); // ----------------------------------------------------------------------- @@ -158,13 +145,8 @@ namespace sysio { [[sysio::action]] void claim(name holder, symbol_code sym); /// Distribute `quantity` WIRE to `target`'s holders: the index advances by /// quantity / supply with the remainder carried and the WIRE is pulled from - /// `from` by inline transfer. The kicker is requested from T5 only when - /// `from` is the swap; any other donation distributes itself alone. + /// `from` by inline transfer. No treasury bonus is added. [[sysio::action]] void addyield(name from, asset quantity, symbol_code target); - /// Fold the kicker `fundclaim` delivered into `sym`'s index: what this - /// contract's WIRE balance now exceeds `base_balance` by, at most - /// `requested`. Inline from `addyield`; this contract's authority. - [[sysio::action]] void addkicker(symbol_code sym, int64_t base_balance, uint64_t requested); // ----------------------------------------------------------------------- // Launch ingestion (privileged caller, epoch-0 bootstrap window) @@ -263,13 +245,6 @@ namespace sysio { return outstanding < range ? range - outstanding : 0; } - struct [[sysio::table("liqconfig")]] liq_config { - uint32_t kicker_bps = DEFAULT_KICKER_BPS; - SYSLIB_SERIALIZE(liq_config, (kicker_bps)) - }; - - using liqconfig_t = kv::global<"liqconfig"_n, liq_config>; - private: using ChainKind = opp::types::ChainKind; using u128 = opp::shadow::u128; @@ -282,8 +257,6 @@ namespace sysio { ChainKind kind_of_chain(sysio::slug_name chain_code) const; /// `sym`'s index now; zero before the first distribution. u128 current_index(symbol_code sym) const; - /// This contract's WIRE balance on sysio.token; zero without a row. - int64_t wire_balance() const; /// The one funnel every balance move goes through: settle `row` at `index`, /// stamp it, then apply `delta`. Nothing else writes `balance`. diff --git a/contracts/sysio.liq/src/sysio.liq.cpp b/contracts/sysio.liq/src/sysio.liq.cpp index 7785dca26c..924619fcc4 100644 --- a/contracts/sysio.liq/src/sysio.liq.cpp +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -2,14 +2,11 @@ #include #include #include -#include #include -#include #include #include #include -#include namespace sysio { @@ -82,15 +79,6 @@ void liq::create(symbol sym, sysio::slug_name chain_code, sysio::slug_name token }, "symbol already exists"); } -void liq::setkicker(uint32_t bps) { - require_auth(SYSTEM_ACCOUNT); - check(bps <= opp::amm::BPS_TOTAL, "kicker_bps out of range"); - liqconfig_t config(get_self()); - liq_config cfg = config.get_or_default(liq_config{}); - cfg.kicker_bps = bps; - config.set(cfg, ram_payer); -} - void liq::recredit(name holder, asset quantity) { require_auth(get_self()); check(is_account(holder), "holder account does not exist"); @@ -324,33 +312,6 @@ void liq::addyield(name from, asset quantity, symbol_code target) { distribute(target, static_cast(quantity.amount)); action(active_of(from), TOKEN_ACCOUNT, "transfer"_n, std::make_tuple(from, get_self(), quantity, std::string{ YIELD_MEMO })).send(); - - // The kicker: `kicker_bps` of the intake, requested from T5 -- only on the - // swap's intake, the one that is yield (tickyield's proceeds). A donation - // draws nothing: a near-sole holder could otherwise donate, claim it back - // with the kicker on top, and repeat against the treasury. fundclaim caps - // the draw and never throws, so what actually lands is folded in afterwards - // by addkicker, measured against the balance the pull above will have left. - if (from != SWAP_ACCOUNT) return; - liqconfig_t config(get_self()); - const uint32_t kicker_bps = config.get_or_default(liq_config{}).kicker_bps; - const uint64_t kicker = static_cast( - static_cast(quantity.amount) * kicker_bps / opp::amm::BPS_TOTAL); - if (kicker == 0) return; - const int64_t base_balance = wire_balance() + quantity.amount; - action(active_of(get_self()), SYSTEM_ACCOUNT, "fundclaim"_n, - std::make_tuple(get_self(), static_cast(kicker))).send(); - action(active_of(get_self()), get_self(), "addkicker"_n, - std::make_tuple(target, base_balance, kicker)).send(); -} - -void liq::addkicker(symbol_code sym, int64_t base_balance, uint64_t requested) { - require_auth(get_self()); - const int64_t received = wire_balance() - base_balance; - if (received <= 0) return; // T5 had nothing to give: base yield only - const currency_stats st = stat_of(sym); - if (st.supply.amount <= 0) return; - distribute(sym, std::min(static_cast(received), requested)); } // --------------------------------------------------------------------------- @@ -435,12 +396,6 @@ u128 liq::current_index(symbol_code sym) const { return idx ? idx->index : 0; } -int64_t liq::wire_balance() const { - token::accounts holdings(TOKEN_ACCOUNT, get_self().value); - const auto row = holdings.try_get(token::acct_key{ WIRE_SYM.code().raw() }); - return row ? row->balance.amount : 0; -} - void liq::settle_and_adjust(opp::shadow::account& row, u128 index, const asset& delta) { row.owed_wire = opp::shadow::owed(row, index); // settle before mutate row.index_checkpoint = index; diff --git a/contracts/sysio.liq/sysio.liq.abi b/contracts/sysio.liq/sysio.liq.abi index 2edc2235ed..eae479ea67 100644 --- a/contracts/sysio.liq/sysio.liq.abi +++ b/contracts/sysio.liq/sysio.liq.abi @@ -21,24 +21,6 @@ } ] }, - { - "name": "addkicker", - "base": "", - "fields": [ - { - "name": "sym", - "type": "symbol_code" - }, - { - "name": "base_balance", - "type": "int64" - }, - { - "name": "requested", - "type": "uint64" - } - ] - }, { "name": "addyield", "base": "", @@ -157,16 +139,6 @@ } ] }, - { - "name": "liq_config", - "base": "", - "fields": [ - { - "name": "kicker_bps", - "type": "uint32" - } - ] - }, { "name": "mint", "base": "", @@ -301,16 +273,6 @@ } ] }, - { - "name": "setkicker", - "base": "", - "fields": [ - { - "name": "bps", - "type": "uint32" - } - ] - }, { "name": "settle", "base": "", @@ -381,11 +343,6 @@ } ], "actions": [ - { - "name": "addkicker", - "type": "addkicker", - "ricardian_contract": "" - }, { "name": "addyield", "type": "addyield", @@ -446,11 +403,6 @@ "type": "regliqpool", "ricardian_contract": "" }, - { - "name": "setkicker", - "type": "setkicker", - "ricardian_contract": "" - }, { "name": "settle", "type": "settle", @@ -471,14 +423,6 @@ "key_types": ["name","uint64"], "table_id": 25660 }, - { - "name": "liqconfig", - "type": "liq_config", - "index_type": "i64", - "key_names": ["name"], - "key_types": ["name"], - "table_id": 57729 - }, { "name": "liqpending", "type": "pending_yield", diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm index 57c00f23ac..a3871235ca 100755 Binary files a/contracts/sysio.liq/sysio.liq.wasm and b/contracts/sysio.liq/sysio.liq.wasm differ diff --git a/contracts/sysio.msgch/README.md b/contracts/sysio.msgch/README.md index 2c93121160..f17d887b36 100644 --- a/contracts/sysio.msgch/README.md +++ b/contracts/sysio.msgch/README.md @@ -51,8 +51,6 @@ Inbound (outpost → depot): `sysio.synd::closeenv`, only when at least one of them reached `sysio.synd`. - `NODE_OWNER_REG` → `sysio.roa` (account creation and node-owner registration, recording the outpost link). -- `STAKING_REWARD` → `sysio.dclaim::onreward`, after binding the reward's chain to the proven - delivering outpost. - Every other type is dropped. Every refusal is a logged drop, never an abort. Outbound (depot → outpost), queued through `queueout`: @@ -65,4 +63,4 @@ Outbound (depot → outpost), queued through `queueout`: - Reads epoch state from `sysio.epoch` and operator status from `sysio.opreg` - Opens disputes on `sysio.chalg` (`opendispute`); `sysio.chalg::chkdispute` calls back into `resolvedisp` - Triggers `sysio.epoch::advance` from `chkcons` (and from `bootstrap` at epoch 0) -- Routes inbound attestations to `sysio.synd`, `sysio.roa` and `sysio.dclaim` +- Routes inbound attestations to `sysio.synd` and `sysio.roa`; node-owner linking can indirectly sweep funded DClaim imports diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index dacb318981..c9dc4172ed 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -309,17 +309,16 @@ name resolve_account_from_op_address(const opp::types::ChainAddress& op_address) /// `proven_chain_code`, which `deliver` validated as an active, non-depot row on /// `sysio.chains` before the envelope reached consensus. Every value-bearing attestation the /// envelope carries ALSO embeds its own chain identifier in the decoded payload -/// (`OperatorAction.chain_code`, `UnderwriteIntentCommit.chain_code`, `StakingReward.chain_code`, +/// (`OperatorAction.chain_code`, `UnderwriteIntentCommit.chain_code`, /// `ReserveAmount.chain_code`, `ReserveCreateCancel.chain_code`). That embedded identifier MUST /// equal the proven source outpost: in the OPP model an attestation about chain X is always relayed -/// by outpost X (confirmed across every leg in `sysio.dispatch_tests`, and stated in the proto — -/// `ReserveCreate.external_amount.chain_code` is documented as "the outpost's own chain"). +/// by outpost X, as exercised by the active intake paths in `sysio.dispatch_tests`. /// /// A mismatch means an envelope proven from outpost A is asking the depot to apply a value-bearing /// effect on a different chain B. Batch-operator consensus proves only that A's operators agree on /// the envelope bytes; it does NOT prove the depositor acted on B. Applying the effect anyway would /// let a compromised or malicious A-operator quorum drive deposits, withdrawals, swaps, underwrite -/// commits, reserve mutations, or staking credits against an unrelated chain B's ledger — the +/// value-bearing mutations against an unrelated chain B's ledger — the /// cross-chain provenance forgery WSA-005 describes. /// /// Per `feedback_opp_handlers_never_throw`, dispatch must never abort the envelope on a single bad @@ -735,49 +734,6 @@ void dispatch_node_owner_reg(const std::vector& data, uint64_t chain_code) // and no reserve-ledger reconciliation. The retired type (enum slot 60957) no // longer exists; any stray inbound falls through to the default drop below. - case AttestationType::ATTESTATION_TYPE_STAKING_REWARD: - // Per-staker staking reward -> sysio.dclaim claim ledger. The - // staking-reward path does not deposit back to a reserve (the - // external-pool credit and native -> WIRE conversion are - // outpost-side), so the pre-refactor 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; - // WSA-005: the reward-emitting chain (sr.chain_code) must be the proven delivering - // outpost before any claim credit is recorded. - if (!source_chain_binding_ok(chain_code, sr.chain_code, "staking_reward")) break; - // WSA-028: gate the signed reward through the shared fail-closed - // parser. A negative or out-of-range reward is dropped at the - // boundary so it never reaches the claim ledger (dclaim also - // soft-drops oversized values, but every inbound TokenAmount is - // validated uniformly at ingress). Never-throw: break, do not check(). - const std::optional reward_opt = - sysio::opp::safe::to_depot_amount(static_cast(sr.reward_amount.amount)); - if (!reward_opt) break; - const uint64_t reward_raw = *reward_opt; - // 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. Credit against the PROVEN chain_code - // (equal to sr.chain_code, enforced above), never the payload's copy. - action( - permission_level{self, "active"_n}, - "sysio.dclaim"_n, "onreward"_n, - std::make_tuple(chain_code, - 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; - case AttestationType::ATTESTATION_TYPE_SYNDICATE_LIQ: carried_syndication_value = dispatch_syndicate_liq(self, data, chain_code, epoch_index, envelope_digest); break; @@ -791,7 +747,6 @@ void dispatch_node_owner_reg(const std::vector& data, uint64_t chain_code) // outpost echoing one inbound is a benign no-op. break; - case AttestationType::ATTESTATION_TYPE_RESERVE_CREATE: case AttestationType::ATTESTATION_TYPE_RESERVE_CREATE_CANCEL: // Retired external reserve lifecycle. break; diff --git a/contracts/sysio.msgch/sysio.msgch.abi b/contracts/sysio.msgch/sysio.msgch.abi index d54a6586cf..b513a2158a 100644 --- a/contracts/sysio.msgch/sysio.msgch.abi +++ b/contracts/sysio.msgch/sysio.msgch.abi @@ -633,10 +633,6 @@ "name": "ATTESTATION_TYPE_NODE_OWNER_REG", "value": 60949 }, - { - "name": "ATTESTATION_TYPE_STAKING_REWARD", - "value": 60950 - }, { "name": "ATTESTATION_TYPE_STAKE_RESULT", "value": 60951 @@ -657,10 +653,6 @@ "name": "ATTESTATION_TYPE_DEPOSIT_REVERT", "value": 60956 }, - { - "name": "ATTESTATION_TYPE_RESERVE_CREATE", - "value": 60958 - }, { "name": "ATTESTATION_TYPE_RESERVE_CREATE_CANCEL", "value": 60959 diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index f7ab14b023..938bb5137a 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/claimable.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp index 60f97e6a2e..ef00b486b1 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp @@ -20,7 +20,7 @@ * the recipient later pulls it with an action carrying its own authority. A handler that aborts * then blocks only its own claim. * - * `sysio.dclaim` established this pattern (`onreward` credits `pending_claims`, `claim` pays out); + * `sysio.dclaim` uses this pattern (`linkswept` credits `pending_claims`, `claim` pays out); * these helpers generalize it so `sysio.system` and `sysio.opreg` share one * audited implementation rather than duplicate copies. * 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 5ad2b85b08..fea838cbc1 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 @@ -551,45 +551,10 @@ DataStream& operator>>(DataStream& ds, NodeOwnerReg& t) { return ds >> t.owner_address >> t.token_id >> t.nft_address; } -// StakingReward: the single staker-reward feedback path. `sysio.msgch` -// dispatches the per-staker body to `sysio.dclaim::onreward` (the WIRE-side -// claim ledger); the amount is already WIRE-denominated, so there is no -// reserve leg. 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.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.reward_epoch_index >> t.external_epoch_ref - >> t.reward_amount >> t.staker_native_address; -} - // --------------------------------------------------------------------------- // Reserve-flow attestations // --------------------------------------------------------------------------- -// ReserveCreate — reserve identity + custodied amount travel together in -// `external_amount` (a depot-frame `ReserveAmount`); field order mirrors -// the generated struct (proto fields 4,5,7,8,9,10,11,12). -template -DataStream& operator<<(DataStream& ds, const ReserveCreate& t) { - return ds << t.name << t.description - << t.requested_wire_amount << t.connector_weight_bps - << t.creator_addr << t.is_private << t.creator_pub_key - << t.external_amount; -} -template -DataStream& operator>>(DataStream& ds, ReserveCreate& t) { - return ds >> t.name >> t.description - >> t.requested_wire_amount >> t.connector_weight_bps - >> t.creator_addr >> t.is_private >> t.creator_pub_key - >> t.external_amount; -} - template DataStream& operator<<(DataStream& ds, const ReserveCreateCancel& t) { return ds << t.chain_code << t.token_code << t.reserve_code << t.creator_addr; diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp index 68cf5b4f50..29437d1deb 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp @@ -4,7 +4,7 @@ * @brief Non-throwing primitives for the OPP inbound-consensus dispatch path. * * Every OPP contract handler reachable from `sysio.msgch::deliver -> evalcons -> - * dispatch` (createuwreq, rcrdcommit, onreward, the swap-settlement tail, ...) + * dispatch` (syndication intake, yield reporting, node-owner linking, ...) * MUST NOT abort on operator-supplied data: a `check()`/abort there rolls back * the consensus-tipping delivery and stalls epoch advancement chain-wide * (`feedback_opp_handlers_never_throw.md`). The two recurring foot-guns are diff --git a/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp index 75d780109c..ad2b2c4855 100644 --- a/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp +++ b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp @@ -242,10 +242,13 @@ namespace sysio { /// of zero burst, releases no syndication of the pair; and a shadow whose precision is below /// `bond::BOND_INCREMENT_DECIMALS` can never be underwritten, so every envelope of it waits (only /// `dropenv` clears one). Auth=sysio. + /// `min_desyndicate` is a positive gross quantity in shadow base units, at most a nonzero + /// desyndication burst. Every configuration supplies this value explicitly. [[sysio::action]] void setconfig(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t synd_fee_bps, uint32_t desynd_fee_bps, uint64_t synd_burst, uint64_t synd_refill, uint64_t desynd_burst, uint64_t desynd_refill, - uint32_t window_sec, uint64_t bounty, uint64_t challenge_extra); + uint32_t window_sec, uint64_t bounty, uint64_t challenge_extra, + uint64_t min_desyndicate); /// Run bounded outcome synchronization, FIFO release, and underwriting, in that order. Sync /// visits at most `limit` envelopes; underwriting and release each have `limit` work units and @@ -603,8 +606,9 @@ namespace sysio { uint32_t window_sec = DEFAULT_WINDOW_SEC; ///< challenge window of each request uint64_t bounty = 0; ///< bounty posted on each request, paid from `feepot` uint64_t challenge_extra = 0; ///< charged to a challenger on top of the hold bond + uint64_t min_desyndicate; ///< Positive gross return minimum. SYSLIB_SERIALIZE(synd_config, (chain_code)(token_code)(synd_fee_bps)(desynd_fee_bps)(synd_burst) - (synd_refill)(desynd_burst)(desynd_refill)(window_sec)(bounty)(challenge_extra)) + (synd_refill)(desynd_burst)(desynd_refill)(window_sec)(bounty)(challenge_extra)(min_desyndicate)) }; /// Rules by outpost and token. diff --git a/contracts/sysio.synd/src/sysio.synd.cpp b/contracts/sysio.synd/src/sysio.synd.cpp index d62a4e7dc6..c45e1e5d73 100644 --- a/contracts/sysio.synd/src/sysio.synd.cpp +++ b/contracts/sysio.synd/src/sysio.synd.cpp @@ -110,6 +110,9 @@ constexpr std::string_view request_final_message = constexpr std::string_view charge_range_message = "hold bond plus challenge extra exceeds the asset range"; constexpr std::string_view desynd_unset_message = "desyndication budget is not configured"; constexpr std::string_view desynd_budget_message = "desyndication exceeds the current budget"; +constexpr std::string_view min_desynd_config_message = + "minimum desyndication must be positive and fit the burst and asset range"; +constexpr std::string_view min_desynd_quantity_message = "quantity is below the minimum desyndication"; constexpr std::string_view desynd_net_message = "desyndication net of the fee must be positive"; /// Diagnostic path names of the intake actions. @@ -1140,7 +1143,8 @@ void synd::closeenv(sysio::slug_name chain_code, uint32_t epoch_index, checksum2 void synd::setconfig(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t synd_fee_bps, uint32_t desynd_fee_bps, uint64_t synd_burst, uint64_t synd_refill, uint64_t desynd_burst, - uint64_t desynd_refill, uint32_t window_sec, uint64_t bounty, uint64_t challenge_extra) { + uint64_t desynd_refill, uint32_t window_sec, uint64_t bounty, uint64_t challenge_extra, + uint64_t min_desyndicate) { require_auth(SYSTEM_ACCOUNT); check(outpost_kind(chain_code).has_value(), not_outpost_message.data()); const auto st = liq::find_stat_by_token(LIQ_ACCOUNT, token_code); @@ -1153,6 +1157,8 @@ void synd::setconfig(sysio::slug_name chain_code, sysio::slug_name token_code, u } syndconfig_t configs(get_self()); + check(min_desyndicate > 0 && min_desyndicate <= max_asset_amount && + (desynd_burst == 0 || min_desyndicate <= desynd_burst), min_desynd_config_message.data()); configs.set(ram_payer, config_key{.chain_code = chain_code.value, .token_code = token_code.value}, synd_config{ .chain_code = chain_code, @@ -1166,6 +1172,7 @@ void synd::setconfig(sysio::slug_name chain_code, sysio::slug_name token_code, u .window_sec = window_sec, .bounty = bounty, .challenge_extra = challenge_extra, + .min_desyndicate = min_desyndicate, }); } @@ -1441,6 +1448,7 @@ void synd::desyndicate(name holder, asset quantity) { .token_code = st.token_code.value}); check(config.has_value(), desynd_unset_message.data()); const uint64_t quantity_units = static_cast(quantity.amount); + check(quantity_units >= config->min_desyndicate, min_desynd_quantity_message.data()); bucket_row bucket = tick_bucket(get_self(), st.chain_code, st.token_code, bucket_direction::DESYNDICATION, config->desynd_burst, config->desynd_refill, epoch::current_epoch_index()); check(quantity_units <= bucket.level, desynd_budget_message.data()); diff --git a/contracts/sysio.synd/sysio.synd.abi b/contracts/sysio.synd/sysio.synd.abi index 8139333a09..bfc9e324ef 100644 --- a/contracts/sysio.synd/sysio.synd.abi +++ b/contracts/sysio.synd/sysio.synd.abi @@ -777,6 +777,10 @@ { "name": "challenge_extra", "type": "uint64" + }, + { + "name": "min_desyndicate", + "type": "uint64" } ] }, @@ -879,6 +883,10 @@ { "name": "challenge_extra", "type": "uint64" + }, + { + "name": "min_desyndicate", + "type": "uint64" } ] }, diff --git a/contracts/sysio.synd/sysio.synd.wasm b/contracts/sysio.synd/sysio.synd.wasm index d7380406ee..2c0201caa7 100755 Binary files a/contracts/sysio.synd/sysio.synd.wasm and b/contracts/sysio.synd/sysio.synd.wasm differ diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index cc6a64ba3b..19b64eac6d 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -19,7 +19,7 @@ through a claim action. All amounts are `WIRE` (9-decimal subunits). The curve decays from `annual_initial_emission` toward `annual_min_emission` (clamped by `annual_max_emission`), stops at `t5_floor`, and auto-throttles as -`total_distributed` rises (capital claims count against it). +`total_distributed` rises. ## How each bucket reaches its recipient @@ -64,8 +64,7 @@ claim transfer. a claim needs `require_auth(account_name)`, `sysio.roa` forces `net_weight`/`cpu_weight` to zero for every `sysio`-prefixed account, and neither carries a contract that could emit the claim inline. They are protocol-owned -holding accounts with no code, so the notify-handler risk does not apply — the -same exception `fundclaim` makes for `sysio.dclaim`. +holding accounts with no code, so the notify-handler risk does not apply. Producer pay is per block produced: the active slice of the producer pool is divided by the period's nominal slot count, raised to the blocks actually @@ -146,7 +145,7 @@ first. The credited WIRE never leaves the treasury's token balance until it is claimed, so every gate that spends against that balance reserves it via the -`payclaimtot.outstanding` counter: `fundclaim`'s balance cap, `sysio.epoch`'s +`payclaimtot.outstanding` counter: `sysio.epoch`'s emissions readiness gate, and `claimnodedis`, which refuses a node-owner withdrawal that would eat into pay already owed. Without that reserve the treasury double-commits and a later claim fails on overdraw. @@ -160,36 +159,20 @@ owner calls `claimnodedis` (auth = their own account) to transfer the newly-vested amount, gated by `min_claimable` (memo `Node Owner distribution`). `viewnodedist` is the read-only preview. -### Staking rewards / capital -- `sysio.dclaim::claim(wire_account)` +### LIQ yield and retired capital draws -The capital bucket is the implicit remainder -(`10000 - compute_bps - capex_bps - governance_bps`). It is NOT paid at -payepoch; it stays in sysio's balance and drains lazily: +LIQ holders receive only supplied WIRE yield proceeds and donations. The T5 yield bonus and +`fundclaim` action are removed. Any unallocated emissions remainder stays in the treasury. -1. A cross-chain `STAKING_REWARD` OPP message lands on `sysio.dclaim::onreward`. -2. `onreward` credits the staker's `pclaims` (pending-claims) row (or parks it - in `unmapped` when the account is not yet AuthX-linked) and calls - `sysio.system::fundclaim` to pull matching WIRE from the pool into - `sysio.dclaim`. -3. The staker calls `dclaim::claim` (auth = their own account) to transfer the - accumulated balance out (memo `sysio.dclaim claim`). - -`sysio.liq` draws through the same action: its `addyield` requests the yield -kicker (`kicker_bps` of each intake from the swap, the intake that is yield) from -the pool, so `fundclaim` names its recipient, which must be one of those two -contracts. - -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. +The external staking-reward message and `sysio.dclaim::onreward` are removed. Pre-launch imports +and already funded DClaim balances still link through AuthX and pay through +`claim(wire_account)`. These balances never expire, including credits awaiting linking; +they remain owed until claimed, and their rows retain RAM indefinitely. ## Category split (basis points) `compute_bps + capex_bps + governance_bps <= 10000`; the remainder is the -implicit capital reserve drained through `fundclaim`. `producer_bps + +unallocated treasury remainder. `producer_bps + batch_op_bps == 10000` (sub-split of compute). All set via `setemitcfg`. ``` @@ -200,7 +183,7 @@ period_emission | '-- batch_op_bps --> batch operators (claimpay) |-- capex_bps ------> sysio.ops (pushed) |-- governance_bps -> sysio.gov (pushed) - '-- remainder ------> capital reserve -> sysio.dclaim / sysio.liq (fundclaim) (claim) + '-- remainder ------> retained in sysio treasury ``` ## Emission actions @@ -216,7 +199,6 @@ period_emission | `accrueepoch` | `sysio.epoch` | Accrue this epoch's curve share | | `rcrdbatch` | `sysio.epoch` | Record the batch-operator roster for this accrued epoch | | `payepoch` | `sysio.epoch` | Distribute the period's compute / capex / governance (credits `payclaims`; pushes only the category buckets) | -| `fundclaim` | the recipient: `sysio.dclaim` or `sysio.liq` | Lazy capital drain into the recipient (never-throw) | | `viewnodedist` | read-only | Preview a node owner's claimable amount | | `viewepoch` | read-only | Current treasury / next-emission estimate | | `viewemitcfg` | read-only | Current emission config | @@ -240,4 +222,3 @@ period_emission - Driven inline by `sysio.epoch::advance` (`accrueepoch` + `rcrdbatch` + `payepoch`). - Reads producer eligibility and operator status from `sysio.opreg`. - Reads the canonical epoch duration from `sysio.epoch::epochcfg`. -- Funds `sysio.dclaim` (staking rewards) and `sysio.liq` (the yield kicker) on demand via `fundclaim`. diff --git a/contracts/sysio.system/include/sysio.system/emissions.hpp b/contracts/sysio.system/include/sysio.system/emissions.hpp index 574c2a147e..6210fb4220 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -104,10 +104,8 @@ struct [[sysio::table("emitcfg"), sysio::contract("sysio.system")]] emission_con int64_t annual_min_emission; // per-year floor // Category splits (basis points). compute + capex + governance must - // sum to <= 10000; the remainder is the implicit capital reserve drained - // lazily by sysio.dclaim::onreward via sysio.system::fundclaim, not at - // payepoch. Sum == 10000 means no implicit reserve (capital draws eat - // future periods' headroom). + // sum to <= 10000; any unallocated remainder stays in the treasury. + // LIQ yield draws no treasury bonus. uint16_t compute_bps; uint16_t capex_bps; uint16_t governance_bps; @@ -260,8 +258,8 @@ using payclaims_t = sysio::kv::table<"payclaims"_n, payclaim_key, pay_claim>; // // The WIRE backing unclaimed rows sits in sysio's token balance but is already owed, so every // gate that spends against that balance must reserve it -- otherwise the treasury double-commits -// and a later `claimpay` fails on overdraw, stranding earned pay. Two readers depend on it: -// `sysio.system::fundclaim` (its balance cap) and `sysio.epoch`'s emissions readiness gate. +// and a later `claimpay` fails on overdraw, stranding earned pay. Treasury withdrawals +// and `sysio.epoch`'s emissions readiness gate reserve this total. // // Held as a maintained counter rather than recomputed by scanning `payclaims`, because the epoch // gate reads it on EVERY advance and an O(rows) scan there would grow with the standby set. Kept @@ -307,12 +305,6 @@ struct [[sysio::table("t5state"), sysio::contract("sysio.system")]] t5_state { // chains see length 0. std::vector batch_group_epochs; - // Cumulative shortfall (WIRE subunits) between requested and actually- - // funded capital draws. fundclaim caps each request at the remaining - // pool; any unfunded delta is added here so under-sized pools are - // visible without breaking the OPP-handler never-throw contract. - int64_t capital_shortfall_total = 0; - /// Block slots the open pay period is entitled to, accumulated as each epoch accrues. /// /// The DIVISOR has to be built the same way the POOL is. `pending_emission_amount` above adds @@ -333,7 +325,7 @@ struct [[sysio::table("t5state"), sysio::contract("sysio.system")]] t5_state { (start_time)(epoch_count)(last_epoch_index) (last_epoch_time)(last_epoch_emission)(total_distributed) (pending_emission_amount)(period_start_epoch)(batch_group_epochs) - (capital_shortfall_total)(pending_nominal_slots)) + (pending_nominal_slots)) }; using t5state_t = sysio::kv::global<"t5state"_n, t5_state>; @@ -478,9 +470,8 @@ struct [[sysio::table("epochlog"), sysio::contract("sysio.system")]] epoch_log { uint64_t epoch_count = 0; // internal counter of payepoch invocations sysio::time_point_sec timestamp; // Total budget for the period (curve output). compute + capex + governance - // are paid out of this at payepoch; the implicit capital reserve - // (= total_emission - compute - capex - governance) is drained lazily - // by fundclaim across the same period and never appears here. + // are paid out of this at payepoch; the unallocated remainder stays in + // the treasury and is not a separate payout category. int64_t total_emission = 0; int64_t compute_amount = 0; int64_t capex_amount = 0; diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index 8420c1840b..bbd6430527 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -689,8 +689,8 @@ namespace sysiosystem { * The T5 category buckets (`sysio.ops` capex, `sysio.gov` governance) are NOT credited * here — payepoch transfers to them directly. A claim needs `require_auth(account_name)` * and neither can ever produce it: `sysio.roa` forces `net_weight`/`cpu_weight` to zero - * for every `sysio`-prefixed account, so they cannot pay for a transaction, and unlike - * `sysio.dclaim` they carry no contract that could emit the claim inline. They are + * for every `sysio`-prefixed account, so they cannot pay for a transaction. They also + * carry no contract that could emit the claim inline. They are * protocol-owned holding accounts with no code, so the notify-handler threat the pull * model defends against does not exist for them. See the note at the push site in * emissions.cpp for the standing constraint that keeps it that way. @@ -721,7 +721,7 @@ namespace sysiosystem { * `period_emission` is the gate-computed sum of pending accrued * emissions plus this epoch's per-epoch share. payepoch trusts that * value (single-trx semantics make recomputation unnecessary) and - * distributes it across producer / batch / capital / capex / gov + * distributes it across producer / batch / capex / gov * pools as today, scaled to the period. * * `batch_op_groups` is retained as an ABI-compatible reserved field; @@ -779,27 +779,6 @@ namespace sysiosystem { [[sysio::action]] void rcrdbatch(uint32_t epoch_index, std::vector members); - /** - * Fund a capital draw against the T5 drainable pool for `recipient`, - * one of the two drains: sysio.dclaim, inline from its `onreward` as - * each STAKING_REWARD lands, and sysio.liq, inline from its `addyield` - * for the kicker on each yield intake. The recipient is funded the - * moment its ledger row is written, so a claim can follow at once. - * Auth: the recipient. - * - * Never throws for those two callers (OPP-handler never-throw - * contract): if the pool cannot cover `amount`, the transfer caps at - * what's available and the unfunded delta is accrued to - * t5state.capital_shortfall_total for operator visibility. Any other - * recipient is refused. - * - * Amounts actually transferred count toward t5state.total_distributed - * so the emission curve auto-throttles via its remaining-headroom - * clamp. - */ - [[sysio::action]] - void fundclaim(sysio::name recipient, int64_t amount); - /** * Read-only: current T5 treasury emission state. */ diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index a6299c2d40..9c04942a36 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -50,9 +50,6 @@ constexpr int64_t MS_PER_SECOND = 1000; // Basis-point denominator for all category / sub-split ratios. constexpr int64_t BPS_DENOMINATOR = 10000; -constexpr sysio::name CAPITAL_ACCOUNT = "sysio.dclaim"_n; -// The shadow-liq token draws the yield kicker through fundclaim beside sysio.dclaim. -constexpr sysio::name LIQ_ACCOUNT = "sysio.liq"_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; @@ -60,7 +57,6 @@ constexpr sysio::name TOKEN_CONTRACT = "sysio.token"_n; constexpr sysio::name ROA_CONTRACT = "sysio.roa"_n; namespace memo { - constexpr std::string_view capital = "T5 capital"; constexpr std::string_view capex = "T5 capex"; constexpr std::string_view governance = "T5 governance"; constexpr std::string_view batch_op_reward = "T5 batch operator reward"; @@ -152,19 +148,14 @@ int64_t get_wire_balance(name account) { // account the protocol does not control therefore hands it an abort switch over every parent // inline action. // -// THREE call sites remain, and every one targets a PROTOCOL-CONTROLLED account. What makes each -// safe differs, and the difference is the thing to preserve: +// Two automatic call sites target protocol-controlled holding accounts with no deployed code: // -// * `fundclaim` -> `sysio.dclaim` — DEPLOYED contracts, not bare accounts. Safe -// `sysio.liq` because both are protocol-controlled and neither -// has a failing `sysio.token::transfer` notify -// path; that invariant must hold as they evolve. // * `payepoch` (capex) -> `sysio.ops` — no code deployed // * `payepoch` (governance) -> `sysio.gov` — no code deployed // // The two `payepoch` pushes are on the never-throw `advance` path, so those two accounts are the // ones that could abort epoch advancement if code were ever deployed on them — see the standing -// constraint at that call site. Adding a fourth AUTOMATIC destination outside protocol control +// constraint at that call site. Adding another AUTOMATIC destination outside protocol control // reopens the vulnerability this file exists to close. // // The scope is automatic payouts on never-throw paths. A claimant-authorized action MAY transfer @@ -206,7 +197,7 @@ void credit_pay(name self, name to, int64_t amount, std::string_view /*memo_str* sysio::opp::claimable::credit(claims, self, payclaim_key{to.value}, pay_claim{.account_name = to}, amt); - // Reserve the credited WIRE so fundclaim and the epoch gate cannot re-commit it. Saturates on + // Reserve the credited WIRE so treasury withdrawals 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. payclaimtot_t tot_tbl(self); auto tot = tot_tbl.get_or_default(pay_claim_total{}); @@ -271,10 +262,8 @@ void system_contract::setemitcfg(const emissions::emission_config& cfg) { "annual_min_emission must be <= annual_max_emission"); // BPS splits. compute + capex + governance bound what payepoch transfers - // each period; the remainder (10000 - that sum) is the implicit capital - // reserve, drained lazily by sysio.dclaim::onreward via fundclaim. Sum - // exactly 10000 means no implicit reserve (capital draws come out of - // future periods' headroom). Sum > 10000 would over-commit period_emission. + // each period; any remainder stays in the treasury. Sum > 10000 + // would over-commit period_emission. const uint32_t paid_at_payepoch_bps = static_cast(cfg.compute_bps) + static_cast(cfg.capex_bps) @@ -477,7 +466,7 @@ void system_contract::claimnodedis(const sysio::name& account_name) { sysio::check(info.can_claim, "claim amount below minimum threshold"); // Node-owner vesting draws on the SAME `sysio` WIRE balance that backs unclaimed epoch pay, so - // it must respect the same reserve `fundclaim` and the epoch readiness gate hold. `payclaims` + // it must respect the same reserve the epoch readiness gate holds. `payclaims` // rows are already owed: paying them out is a promise this contract has made and cannot revoke, // whereas this withdrawal is a claim on the free remainder. // @@ -522,7 +511,7 @@ void system_contract::claimnodedis(const sysio::name& account_name) { // // The row is erased before the transfer is queued (inside pay_out), so a notify handler that // re-enters claimpay finds no row and cannot double spend. The outstanding-total counter is -// decremented in lockstep, releasing the reserve that fundclaim and the epoch gate hold against it. +// decremented in lockstep, releasing the reserve held against treasury withdrawals and the epoch gate. void system_contract::claimpay(const sysio::name& account_name) { require_auth(account_name); @@ -535,7 +524,7 @@ void system_contract::claimpay(const sysio::name& account_name) { auto tot = tot_tbl.get_or_default(pay_claim_total{}); // Defensive floor: the counter and the row sum are maintained together, so `paid` can only // exceed `outstanding` if they have already diverged. Clamping keeps the reserve from - // underflowing into a huge value that would freeze fundclaim and the epoch gate permanently. + // underflowing into a huge value that would freeze treasury withdrawals and the epoch gate permanently. tot.outstanding = (paid >= tot.outstanding) ? 0 : tot.outstanding - paid; tot_tbl.set(tot, get_self()); } @@ -695,11 +684,8 @@ void system_contract::rcrdbatch(uint32_t epoch_index, std::vector m // - per-epoch emission > 0 (treasury not at floor) // - sysio's WIRE balance >= period_emission (pending + this epoch's share) // -// Capital is NOT paid here. The implicit capital reserve -// (period_emission - compute - capex - governance) stays in sysio's balance -// and is drained lazily by fundclaim as sysio.dclaim::onreward fires, so -// dclaim has funds the moment a claim is credited rather than at the next -// pay-epoch. +// The unallocated remainder (period_emission - compute - capex - governance) +// stays in sysio's balance. LIQ yield draws no treasury bonus. // // Swap-fee rewards: when immutable roster history is complete and contains at // least one non-empty roster, the batch-operator share of collected swap fees @@ -746,10 +732,8 @@ void system_contract::payepoch(uint32_t epoch_index, // ----- Category splits ----- // payepoch transfers compute + capex + governance only. The implicit - // capital reserve (period_emission - compute - capex - governance) stays - // in sysio's balance and is drained lazily by fundclaim as - // sysio.dclaim::onreward fires. Sum-to-10000 BPS means zero reserve; - // sum < 10000 leaves the remainder available for capital coverage. + // remainder (period_emission - compute - capex - governance) stays + // in sysio's balance. Sum-to-10000 BPS means no unallocated remainder. const int64_t compute_amount = split_bps(period_emission, cfg.compute_bps); const int64_t capex_amount = split_bps(period_emission, cfg.capex_bps); const int64_t governance_amount = split_bps(period_emission, cfg.governance_bps); @@ -1082,13 +1066,10 @@ void system_contract::payepoch(uint32_t epoch_index, } // ======================================================================= - // Category buckets: fixed accounts, no opreg filter. Capital is NOT paid - // here -- it drains lazily via fundclaim per incoming OPP claim, so - // dclaim has WIRE the moment the claim is credited rather than waiting - // for the next pay-epoch. + // Category buckets: fixed accounts, no opreg filter. The unallocated + // remainder stays in the treasury; LIQ yield has no capital draw. // ======================================================================= - // PUSHED, not credited -- the deliberate exception to this contract's pull rule, and the same - // exception `fundclaim` already makes for `sysio.dclaim`. + // PUSHED, not credited -- the deliberate exception to this contract's pull rule. // // The pull model exists because a payout to an account the protocol does not control lets that // account's transfer-notify handler abort `advance`. These two are protocol-owned holding @@ -1098,9 +1079,9 @@ void system_contract::payepoch(uint32_t epoch_index, // Crediting them instead would strand the money. A claim needs `require_auth(account_name)`, // and a `sysio.*` account can neither sign nor be acted for here: `sysio.roa` forces // `net_weight`/`cpu_weight` to zero for every account whose prefix is `sysio` - // (`is_sysio_account`), so they cannot pay for a transaction, and unlike `sysio.dclaim` they + // (`is_sysio_account`), so they cannot pay for a transaction. They also // have no contract of their own to emit the claim inline. Their pay would accrue in - // `payclaims` forever while `payclaimtot` reserved the backing WIRE against `fundclaim` and the + // `payclaims` forever while `payclaimtot` reserved the backing WIRE against treasury withdrawals and the // epoch readiness gate -- a permanent, growing reservation of WIRE nobody can move. // // This is a standing constraint on the two accounts, not a reason to revisit this call: any @@ -1170,83 +1151,6 @@ void system_contract::payepoch(uint32_t epoch_index, } } -// fundclaim - transfer up to `amount` WIRE from sysio's drainable pool to -// `recipient`, one of the two capital drains: sysio.dclaim, inline from -// sysio.dclaim::onreward as each STAKING_REWARD attestation lands, and -// sysio.liq, inline from sysio.liq::addyield for the kicker on each yield -// intake. Either way the recipient is funded against the credit it just took -// on before anyone can attempt to claim it. -// -// Never throws for those two callers. STAKING_REWARD dispatch from sysio.msgch -// must not be aborted on emissions-side conditions (the never-throw contract -// for OPP inbound handlers), so a pool-too-small case caps the transfer at -// what's available and accrues the unfunded delta to -// t5state.capital_shortfall_total for operator visibility. A recipient that is -// not a drain is refused, which reaches only the stranger who asked. -// -// The transfer cap is the minimum of three caps that all must hold: -// * `amount` -- requested -// * `lifetime headroom - pending_emission_amount` -- accounting -// * `sysio WIRE balance - pending_emission_amount -// - outstanding payclaims` -- balance -// Both accounting and balance caps reserve `pending_emission_amount` for -// the next payepoch. `pending_emission_amount` is curve emission already -// accrued via accrueepoch but not yet paid; payepoch will distribute -// it across compute/capital/capex/governance. Drawing against those funds -// here would either trip the emissions readiness gate at the next epoch -// boundary (BALANCE_INSUFFICIENT) or leave payepoch's credits unbacked. -// -// The balance cap ALSO reserves outstanding `payclaims`. Those balances have -// already been credited to producers / standbys / batch operators but not yet -// pulled, so the WIRE backing them is still sitting in this account's -// token balance while being fully owed. Spending it here would leave a later -// `claimpay` unable to transfer, stranding earned pay -- the claimable-payout -// equivalent of the "overdrawn balance" abort this cap has always guarded. -// -// Negative or zero requests are silent no-ops (defensive). The amount -// actually transferred counts toward total_distributed -- the curve sees -// less remaining headroom on its next per-epoch computation and emissions -// auto-throttle to match real claim load. -void system_contract::fundclaim(name recipient, int64_t amount) { - require_auth(recipient); - check(recipient == CAPITAL_ACCOUNT || recipient == LIQ_ACCOUNT, - "fundclaim: recipient is not a capital drain"); - - if (amount <= 0) return; - - t5state_t t5s(get_self()); - if (!t5s.exists()) return; // pre-init: silently absorb - auto state = t5s.get(); - - payclaimtot_t claim_tot_tbl(get_self()); - const int64_t claims_reserve = - static_cast(claim_tot_tbl.get_or_default(pay_claim_total{}).outstanding); - - const auto cfg = get_emit_cfg(get_self()); - const int64_t lifetime_headroom = cfg.t5_distributable - cfg.t5_floor - state.total_distributed; - const int64_t pending_reserve = state.pending_emission_amount; - const int64_t sysio_balance = get_wire_balance(get_self()); - const int64_t accounting_available = lifetime_headroom - pending_reserve; - const int64_t balance_available = sysio_balance - pending_reserve - claims_reserve; - - const int64_t cap = std::min({amount, accounting_available, balance_available}); - const int64_t to_transfer = (cap > 0) ? cap : 0; - const int64_t shortfall = amount - to_transfer; - - if (to_transfer > 0) { - send_wire_transfer(get_self(), recipient, to_transfer, memo::capital); - state.total_distributed += to_transfer; - } - - if (shortfall > 0) { - state.capital_shortfall_total += shortfall; - } - - if (to_transfer > 0 || shortfall > 0) { - t5s.set(state, get_self()); - } -} - emissions::epoch_info_result system_contract::viewepoch() { const auto cfg = get_emit_cfg(get_self()); diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index 76c60e37d9..129dcb6a49 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -674,20 +674,6 @@ } ] }, - { - "name": "fundclaim", - "base": "", - "fields": [ - { - "name": "recipient", - "type": "name" - }, - { - "name": "amount", - "type": "int64" - } - ] - }, { "name": "getsnaphash", "base": "", @@ -1622,10 +1608,6 @@ "name": "batch_group_epochs", "type": "uint32[]" }, - { - "name": "capital_shortfall_total", - "type": "int64" - }, { "name": "pending_nominal_slots", "type": "uint64" @@ -1972,11 +1954,6 @@ "type": "delsnapprov", "ricardian_contract": "" }, - { - "name": "fundclaim", - "type": "fundclaim", - "ricardian_contract": "" - }, { "name": "getsnaphash", "type": "getsnaphash", diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index aef3622d03..07311b89e7 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_TESTING.md b/contracts/tests/DEPOT_FLOW_TESTING.md index 9f5a245798..087c018904 100644 --- a/contracts/tests/DEPOT_FLOW_TESTING.md +++ b/contracts/tests/DEPOT_FLOW_TESTING.md @@ -14,15 +14,16 @@ record, not a line-coverage percentage or a proof that every possible sequence i 2. **Depot integration tests** exercise inline calls between the real contracts. New generic scenarios cover backed import, held intake, bonding, partial release, challenge adjudication, freeze/repair, fee rounding, link delivery, - yield conversion, reward funding and return instructions. + yield conversion, funded import claims and return instructions. 3. **Cluster flows** retain responsibility for external contract execution, operator daemons, networking, finality, actual producer participation and soak stability. Native virtual-time tests cannot establish those properties. `sysio_dispatch_tests/generic_external_simulator_drives_production_dispatch` feeds canonical protobuf envelopes into real `sysio.msgch`, its epoch consensus -path, `sysio.synd` and `sysio.liq` for both transport families. The reward dispatch -case additionally exercises `sysio.dclaim -> sysio.system -> sysio.token`. +path, `sysio.synd` and `sysio.liq` for both transport families. The funded import +case verifies that pre-launch credits link and pay. +LIQ yield distributes only supplied WIRE. Its regression checks exact holder settlement and no treasury draw. Ethereum NFT registration remains covered by the Ethereum-specific dispatch and ROA tests: changing that source chain would hide an actual protocol constraint. diff --git a/contracts/tests/depot_flow_coverage.json b/contracts/tests/depot_flow_coverage.json index 823de4e9b7..a8d9a1af2a 100644 --- a/contracts/tests/depot_flow_coverage.json +++ b/contracts/tests/depot_flow_coverage.json @@ -40,7 +40,9 @@ "sysio_synd_tests/generated_generic_custody_bond_challenge_and_return_sequences", "sysio_synd_tests/generic_custody_shortfall_freezes_and_repair_resumes", "sysio_dispatch_tests/a_custody_shortfall_pulls_the_cord_and_the_epoch_advances", - "sysio_dispatch_tests/a_shortfall_freezes_the_same_envelopes_queue_step" + "sysio_dispatch_tests/a_shortfall_freezes_the_same_envelopes_queue_step", + "sysio_synd_tests/a_freeze_does_not_extend_the_challenge_deadline", + "sysio_synd_tests/a_frozen_request_can_be_held_or_ruled_invalid_after_its_deadline" ] }, { @@ -54,7 +56,10 @@ "t5_emissions_tests/governance_gets_remainder_no_dust_loss", "sysio_dclaim_tests/importseed_accepts_credit_batch", "sysio_authex_tests/createlink_eth_sweeps_prelink_dclaim_rewards", - "sysio_dispatch_tests/generic_staking_rewards_fund_claim_and_dedupe_through_dispatch" + "sysio_dispatch_tests/funded_import_claims_link_and_settle", + "sysio_dclaim_tests/importseed_linked_credits_pending_claims", + "sysio_dclaim_tests/importseed_unlinked_parks_unmapped_then_linkswept", + "sysio_dclaim_tests/linkswept_caps_combined_imports_without_aborting" ] }, { @@ -74,7 +79,8 @@ "tests": [ "sysio_synd_tests/generic_parked_credit_yield_conversion_and_fee_rounded_return", "sysio_liq_tests/queued_yield_sells_through_the_pool_and_pays_holders", - "sysio_synd_tests/createlink_sweeps_parked_from_synd" + "sysio_synd_tests/createlink_sweeps_parked_from_synd", + "sysio_liq_tests/addyield_distributes_only_supplied_wire" ] }, { @@ -134,7 +140,9 @@ "sysio_synd_tests/bucket_limits_a_tranche_and_refills_per_epoch", "sysio_synd_tests/oversize_item_releases_in_tranches", "sysio_synd_tests/desyndicate_refuses_over_budget", - "sysio_synd_tests/generated_generic_custody_bond_challenge_and_return_sequences" + "sysio_synd_tests/generated_generic_custody_bond_challenge_and_return_sequences", + "sysio_synd_tests/desyndicate_enforces_configured_minimum", + "sysio_synd_tests/setconfig_validates_minimum_return" ] }, { @@ -151,19 +159,6 @@ "sysio_bond_tests/approved_returns_bonds_and_splits_the_bounty_by_stake", "sysio_synd_tests/second_envelope_waits_until_the_first_is_bonded" ] - }, - { - "name": "flow-yield-distribution", - "wire_behavior": "Staking reward credits linked/unmapped WIRE claims, suppresses stale external references, funds through accounting and liquid balance caps, and records shortfall.", - "cluster_only": "Ethereum mock emitter and real STAKING_REWARD transport.", - "tests": [ - "sysio_dclaim_tests/onreward_linked_credits_pending_claims", - "sysio_dclaim_tests/onreward_unlinked_parks_unmapped_then_linkswept", - "sysio_dclaim_tests/onreward_dedupes_stale_external_ref", - "t5_emissions_tests/fundclaim_transfers_and_tracks_distributed", - "t5_emissions_tests/fundclaim_caps_to_remaining_pool_and_records_shortfall", - "sysio_dispatch_tests/generic_staking_rewards_fund_claim_and_dedupe_through_dispatch" - ] } ] } diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 72f8ffdf59..bb21f29eb2 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -215,10 +215,9 @@ static constexpr uint16_t COMPUTE_BPS = 4000; static constexpr uint16_t CAPEX_BPS = 2000; static constexpr uint16_t GOV_BPS = 1000; // Implicit capital reserve = whatever the three explicit shares leave behind. -// At the fixture defaults (4000 + 2000 + 1000), this is 3000 bps. Drained -// lazily via sysio.dclaim::onreward -> sysio.system::fundclaim, not paid at -// payepoch -- so it doesn't appear in t5state.total_distributed until the -// underlying fundclaim transfer happens. +// At the fixture defaults (4000 + 2000 + 1000), this is 3000 bps, +// held in the treasury, not paid at payepoch and excluded from +// t5state.total_distributed. // The implicit capital reserve at these defaults is // 10000 - COMPUTE_BPS - CAPEX_BPS - GOV_BPS = 3000 bps. static constexpr uint16_t PRODUCER_BPS = 7000; @@ -236,7 +235,7 @@ static constexpr uint32_t T_EPOCH_SECS = 60; // Helper: amount NOT transferred at payepoch when no producers / batch // members are paid. Equals producer_pool + batch_pool (compute share, both // undistributed) + implicit capital reserve (never paid at payepoch -- -// drained lazily via fundclaim). The implicit reserve naturally absorbs +// retained in the treasury). The implicit reserve naturally absorbs // the rounding dust from per-share split_bps floors, so the cleanest // definition is `emission - (everything payepoch actually transfers)`, // which collapses to `emission - capex_split - gov_split` when no @@ -499,29 +498,7 @@ class sysio_emissions_tester : public tester { produce_blocks(1); } - /// Deploy the real sysio.dclaim contract on the placeholder account and - /// mark it privileged. Required only by tests that push actions signed by - /// sysio.dclaim (e.g. fundclaim, which `require_auth(CAPITAL_ACCOUNT)`): - /// the sysio.* accounts get a 0-NET ROA policy, so without a deployed - /// contract + privileged flag they cannot afford the inline transaction - /// NET. Idempotent. - void deploy_dclaim_for_signing() { - const account_name DCLAIM = "sysio.dclaim"_n; - if (get_roa_policy(DCLAIM, "nodedaddy"_n).is_null()) { - auto tr = addpolicy_ram_only("nodedaddy"_n, DCLAIM, - asset::from_string("500.0000 SYS")); - BOOST_REQUIRE( tr ); - BOOST_REQUIRE( !tr->except ); - produce_blocks(1); - } - set_code( DCLAIM, contracts::dclaim_wasm() ); - set_abi ( DCLAIM, contracts::dclaim_abi().data() ); - set_privileged( DCLAIM ); - produce_blocks(1); - } - - /// The same for sysio.liq, the other capital drain: deployed and privileged - /// so a test can sign fundclaim as it. + /// Deploy and privilege sysio.liq for native shadow collateral tests. void deploy_liq_for_signing() { const account_name LIQ = "sysio.liq"_n; if (!control->db().find(LIQ)) { @@ -648,14 +625,6 @@ class sysio_emissions_tester : public tester { ); } - action_result fundclaim( account_name signer, account_name recipient, int64_t amount ) { - return push_system_action( - signer, - "fundclaim"_n, - mvo()("recipient", recipient)("amount", amount) - ); - } - /// Push a sysio.system action as a READ-ONLY transaction. /// /// Read-only transactions carry no authorization and no signature, and the chain refuses any @@ -1078,7 +1047,7 @@ class sysio_emissions_tester : public tester { /// Total epoch pay credited but not yet pulled, across every recipient (the `payclaimtot` /// singleton the contract maintains). This WIRE still sits in the treasury's token balance - /// while being fully owed, which is why `fundclaim` and the epoch readiness gate both reserve + /// while being fully owed, which is why treasury withdrawals and the epoch readiness gate both reserve /// it -- and why treasury-balance assertions must account for it. int64_t pay_outstanding_total() { auto data = get_row_by_account(config::system_account_name, config::system_account_name, @@ -2123,7 +2092,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_requires_sysio_auth, sysio_emissions_tester BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_bad_category_bps, sysio_emissions_tester ) try { // compute + capex + governance must be <= 10000 (the remainder is the - // implicit capital reserve drained lazily by fundclaim). + // unallocated remainder retained in the treasury). auto cfg = mvo() ("t1_allocation", int64_t(1)) ("t2_allocation", int64_t(1)) ("t3_allocation", int64_t(1)) ("t1_duration", uint32_t(1)) ("t2_duration", uint32_t(1)) ("t3_duration", uint32_t(1)) @@ -3213,7 +3182,7 @@ BOOST_FIXTURE_TEST_CASE( category_split_matches_basis_points, sysio_emissions_te BOOST_REQUIRE_EQUAL( compute, test_split_bps(total, COMPUTE_BPS) ); // capex gets only its base split (no producer dust redirect) BOOST_REQUIRE_EQUAL( capex, test_split_bps(total, CAPEX_BPS) ); - // capital is NOT in the epoch_log -- drained lazily via fundclaim, not at payepoch. + // the unallocated remainder is not an epoch_log payout category. BOOST_REQUIRE( !log.get_object().contains("capital_amount") ); } FC_LOG_AND_RETHROW() @@ -3228,7 +3197,7 @@ BOOST_FIXTURE_TEST_CASE( governance_gets_remainder_no_dust_loss, sysio_emissions int64_t gov = log["governance_amount"].as(); // governance is its own BPS share of total (independent of compute/capex): - // the implicit capital reserve (drained lazily via fundclaim) is what + // the unallocated treasury remainder is what // absorbs any unallocated remainder, not governance. BOOST_REQUIRE_EQUAL( gov, test_split_bps(total, uint16_t(1000)) ); } FC_LOG_AND_RETHROW() @@ -3559,14 +3528,13 @@ BOOST_FIXTURE_TEST_CASE( holding_accounts_receive_correct_amounts, sysio_emissio // so what they "received" is a real balance delta. They are protocol-owned holding accounts // with no code (no notify handler to abort `advance`) and, being `sysio.*`, no net/cpu to sign // a claim with and no contract to emit one inline: crediting them would strand the pay in - // `payclaims` permanently. `sysio.dclaim` is pushed for the same reason, via fundclaim. + // `payclaims` permanently. 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(); - // dclaim is not funded at payepoch anymore -- capital draws are lazy - // via sysio.dclaim::onreward -> sysio.system::fundclaim. + // DClaim receives no emissions allocation. BOOST_REQUIRE_EQUAL( dclaim_received, 0 ); BOOST_REQUIRE_EQUAL( gov_received, gov ); // sysio.batch is not an emissions recipient anymore -- batch pay goes to the @@ -4087,7 +4055,7 @@ BOOST_FIXTURE_TEST_CASE( multi_epoch_cumulative_accounting, sysio_emissions_test BOOST_FIXTURE_TEST_CASE( category_splits_sum_to_emission, sysio_emissions_tester ) try { // Verify compute + capex + governance + implicit_capital_reserve == total_emission. // The implicit capital reserve isn't recorded in epoch_log (drained lazily - // by fundclaim); back it out from the curve total to confirm the math. + // in the treasury); back it out from the curve total to confirm the math. create_t5_holding_accounts(); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); @@ -4142,7 +4110,7 @@ BOOST_FIXTURE_TEST_CASE( epoch_log_records_all_fields, sysio_emissions_tester ) test_scale_annual_to_epoch(ANNUAL_INITIAL_EMISSION, 60) ); // All recorded category amounts are positive (capital_amount is no - // longer in epoch_log -- drained lazily via fundclaim). + // longer in epoch_log -- retained in the treasury). BOOST_REQUIRE( log["compute_amount"].as() > 0 ); BOOST_REQUIRE( log["capex_amount"].as() > 0 ); BOOST_REQUIRE( log["governance_amount"].as() > 0 ); @@ -4684,7 +4652,7 @@ BOOST_FIXTURE_TEST_CASE( opreg_unregistered_account_cannot_become_producer, sysi } FC_LOG_AND_RETHROW() // Node-owner vesting draws on the same `sysio` WIRE balance that backs unclaimed epoch pay, so it -// has to respect the same reserve `fundclaim` and the epoch gate hold. Without that, a vested owner +// has to respect the same reserve the epoch gate holds. Without that, a vested owner // can withdraw WIRE already promised to a `payclaims` row, leaving the later `claimpay` unpayable // and `payepoch` balance-blocked from then on. That exposure is new: before payouts became // claimable, epoch pay had already left the treasury by the time a node claim ran. @@ -5211,165 +5179,6 @@ BOOST_FIXTURE_TEST_CASE( pay_cadence_change_via_setemitcfg_takes_effect, sysio_e } } FC_LOG_AND_RETHROW() -// --------------------------------------------------------------------------- -// fundclaim: per-onreward immediate funding for sysio.dclaim -// --------------------------------------------------------------------------- - -BOOST_FIXTURE_TEST_CASE( fundclaim_requires_the_recipients_auth, sysio_emissions_tester ) try { - create_t5_holding_accounts(); - deploy_dclaim_for_signing(); - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - // alice has no claim to sysio.dclaim's authority. - create_user_accounts({ "alice"_n }); - auto r = fundclaim( "alice"_n, "sysio.dclaim"_n, int64_t(1'000'000) ); - BOOST_REQUIRE( r != success() ); - require_substr( r, "missing authority of sysio.dclaim" ); -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( fundclaim_refuses_a_recipient_that_is_not_a_drain, sysio_emissions_tester ) try { - create_t5_holding_accounts(); - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - // alice signs for herself; only the two drains may be funded. - create_user_accounts({ "alice"_n }); - auto r = fundclaim( "alice"_n, "alice"_n, int64_t(1'000'000) ); - BOOST_REQUIRE( r != success() ); - require_substr( r, "recipient is not a capital drain" ); -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( fundclaim_transfers_and_tracks_distributed, sysio_emissions_tester ) try { - create_t5_holding_accounts(); - deploy_dclaim_for_signing(); - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - const asset sysio_before = get_wire_balance_paid( config::system_account_name ); - const asset dclaim_before = get_wire_balance_paid( "sysio.dclaim"_n ); - const int64_t distributed_before = get_t5_state()["total_distributed"].as(); - - const int64_t amt = int64_t(50'000'000'000); // 50 WIRE - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, amt ) ); - - const asset sysio_after = get_wire_balance_paid( config::system_account_name ); - const asset dclaim_after = get_wire_balance_paid( "sysio.dclaim"_n ); - const auto state = get_t5_state(); - - BOOST_REQUIRE_EQUAL( sysio_before.get_amount() - sysio_after.get_amount(), amt ); - BOOST_REQUIRE_EQUAL( dclaim_after.get_amount() - dclaim_before.get_amount(), amt ); - BOOST_REQUIRE_EQUAL( state["total_distributed"].as(), distributed_before + amt ); - BOOST_REQUIRE_EQUAL( state["capital_shortfall_total"].as(), 0 ); -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( fundclaim_no_op_for_zero_or_negative, sysio_emissions_tester ) try { - create_t5_holding_accounts(); - deploy_dclaim_for_signing(); - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - const asset dclaim_before = get_wire_balance_paid( "sysio.dclaim"_n ); - const int64_t distributed_before = get_t5_state()["total_distributed"].as(); - - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(0) ) ); - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(-100) ) ); - - BOOST_REQUIRE_EQUAL( get_wire_balance_paid( "sysio.dclaim"_n ).get_amount(), - dclaim_before.get_amount() ); - BOOST_REQUIRE_EQUAL( get_t5_state()["total_distributed"].as(), - distributed_before ); - BOOST_REQUIRE_EQUAL( get_t5_state()["capital_shortfall_total"].as(), 0 ); -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( fundclaim_caps_to_remaining_pool_and_records_shortfall, sysio_emissions_tester ) try { - // Squeeze t5_distributable down to a small headroom over t5_floor so the - // next fundclaim has a hard cap. Verify the partial transfer and the - // shortfall accumulator. - create_t5_holding_accounts(); - deploy_dclaim_for_signing(); - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - // Shrink drainable headroom to exactly 1000 subunits via a fresh emitcfg. - // The post-init guard requires t5_distributable >= t5_floor + total_distributed, - // so set t5_floor to 0 and t5_distributable just above current total_distributed. - const int64_t already = get_t5_state()["total_distributed"].as(); - const int64_t headroom = 1000; - auto cfg = mvo() - ("t1_allocation", T1_ALLOCATION.get_amount()) - ("t2_allocation", T2_ALLOCATION.get_amount()) - ("t3_allocation", T3_ALLOCATION.get_amount()) - ("t1_duration", T1_DURATION) ("t2_duration", T2_DURATION) ("t3_duration", T3_DURATION) - ("min_claimable", MIN_CLAIMABLE_AMOUNT) - ("t5_distributable", already + headroom) - ("t5_floor", int64_t(0)) - ("target_annual_decay_bps", TARGET_ANNUAL_DECAY_BPS) - ("annual_initial_emission", ANNUAL_INITIAL_EMISSION) - ("annual_max_emission", ANNUAL_MAX_EMISSION) - // annual_min_emission=0 so the post-init guard (per_epoch_min <= remaining) - // doesn't reject the deliberately-tiny pool used by this test. - ("annual_min_emission", int64_t(0)) - ("compute_bps", COMPUTE_BPS) - ("capex_bps", CAPEX_BPS) ("governance_bps", GOV_BPS) - ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) - ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); - BOOST_REQUIRE_EQUAL( success(), setemitcfg( config::system_account_name, cfg ) ); - - const asset dclaim_before = get_wire_balance_paid( "sysio.dclaim"_n ); - - // Request 3x the headroom; expect partial transfer of `headroom`, shortfall = 2x. - const int64_t request = headroom * 3; - const int64_t shortfall = request - headroom; - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, request ) ); - - const asset dclaim_after = get_wire_balance_paid( "sysio.dclaim"_n ); - const auto state = get_t5_state(); - BOOST_REQUIRE_EQUAL( dclaim_after.get_amount() - dclaim_before.get_amount(), headroom ); - BOOST_REQUIRE_EQUAL( state["total_distributed"].as(), already + headroom ); - BOOST_REQUIRE_EQUAL( state["capital_shortfall_total"].as(), shortfall ); - - // A further request after pool is exhausted is a full shortfall. - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(500) ) ); - const auto state2 = get_t5_state(); - BOOST_REQUIRE_EQUAL( get_wire_balance_paid( "sysio.dclaim"_n ).get_amount(), - dclaim_after.get_amount() ); - BOOST_REQUIRE_EQUAL( state2["total_distributed"].as(), already + headroom ); - BOOST_REQUIRE_EQUAL( state2["capital_shortfall_total"].as(), shortfall + 500 ); -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( fundclaim_silent_when_t5state_missing, sysio_emissions_tester ) try { - // initt5 NOT called, so t5state.exists() is false. fundclaim must absorb - // the call without throwing or mutating any state. - create_t5_holding_accounts(); - deploy_dclaim_for_signing(); - const asset dclaim_before = get_wire_balance_paid( "sysio.dclaim"_n ); - - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(1'000'000) ) ); - - BOOST_REQUIRE_EQUAL( get_wire_balance_paid( "sysio.dclaim"_n ).get_amount(), - dclaim_before.get_amount() ); -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( fundclaim_funds_the_liq_kicker_drain, sysio_emissions_tester ) try { - // sysio.liq draws the yield kicker through the same drain as sysio.dclaim. - create_t5_holding_accounts(); - deploy_liq_for_signing(); - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - const asset liq_before = get_wire_balance( "sysio.liq"_n ); - const int64_t distributed_before = get_t5_state()["total_distributed"].as(); - - const int64_t amt = int64_t(2'000'000'000); // 2 WIRE - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.liq"_n, "sysio.liq"_n, amt ) ); - - BOOST_REQUIRE_EQUAL( get_wire_balance( "sysio.liq"_n ).get_amount() - liq_before.get_amount(), amt ); - BOOST_REQUIRE_EQUAL( get_t5_state()["total_distributed"].as(), distributed_before + amt ); - BOOST_REQUIRE_EQUAL( get_t5_state()["capital_shortfall_total"].as(), 0 ); -} FC_LOG_AND_RETHROW() - BOOST_AUTO_TEST_SUITE_END() // t5_emissions_tests // =========================================================================== diff --git a/contracts/tests/sysio.authex_tests.cpp b/contracts/tests/sysio.authex_tests.cpp index 35c7c23666..212673e097 100644 --- a/contracts/tests/sysio.authex_tests.cpp +++ b/contracts/tests/sysio.authex_tests.cpp @@ -128,18 +128,13 @@ class sysio_authex_tester : public tester { ("native_address", native_address)); } - action_result onreward(const std::vector& native_address, uint64_t amount, - ChainKind chain_kind = ChainKind::CHAIN_KIND_EVM) { + /// Import a pre-launch WIRE credit before testing authenticated link settlement. + action_result import_credit(const std::vector& native_address, uint64_t amount, + ChainKind chain_kind = ChainKind::CHAIN_KIND_EVM) { return sysio_system::test_support::push_contract_action_and_produce_block( - *this, DCLAIM, dclaim_abi_ser, MSGCH, "onreward"_n, mvo() - ("chain_code", uint64_t{1}) - ("staker_wire_account", std::string{}) - ("reward_chain", chain_kind) - ("staker_native_addr", native_address) - ("reward_amount", amount) - ("reward_epoch_index", uint32_t{7}) - ("external_epoch_ref", uint64_t{100}) - ("share_bps", uint32_t{10000})); + *this, DCLAIM, dclaim_abi_ser, DCLAIM, "importseed"_n, mvo() + ("chain", chain_kind)("credits", fc::variants{ + mvo()("native_address", native_address)("wire_atomic", static_cast(amount))})); } fc::variant get_dclaim_row(name table, const char* type, uint64_t id) { @@ -324,7 +319,7 @@ BOOST_FIXTURE_TEST_CASE( createlink_eth_sweeps_prelink_dclaim_rewards, sysio_aut 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_EQUAL(success(), import_credit(native_address, 5000)); BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); BOOST_REQUIRE_EQUAL(success(), createlink( @@ -343,7 +338,7 @@ BOOST_FIXTURE_TEST_CASE( createlink_preserves_old_prelink_dclaim_rewards, 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_EQUAL(success(), import_credit(native_address, 5000)); BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); constexpr uint32_t years_without_link_sec = 3u * 365u * 24u * 60u * 60u; produce_block(fc::seconds(years_without_link_sec)); @@ -402,7 +397,7 @@ BOOST_FIXTURE_TEST_CASE( recordlink_records_link_when_dclaim_is_not_privileged, const auto address_bytes = fc::crypto::ethereum::address_to_bytes(public_key); const std::vector native_address(address_bytes.begin(), address_bytes.end()); - BOOST_REQUIRE_EQUAL(success(), onreward(native_address, 6500)); + BOOST_REQUIRE_EQUAL(success(), import_credit(native_address, 6500)); BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); set_dclaim_privileged(false); @@ -424,7 +419,7 @@ BOOST_FIXTURE_TEST_CASE( recordlink_eth_sweeps_prelink_dclaim_rewards, sysio_aut const auto address_bytes = fc::crypto::ethereum::address_to_bytes(public_key); const std::vector native_address(address_bytes.begin(), address_bytes.end()); - BOOST_REQUIRE_EQUAL(success(), onreward(native_address, 7000)); + BOOST_REQUIRE_EQUAL(success(), import_credit(native_address, 7000)); BOOST_REQUIRE_EQUAL(success(), recordlink(AUTHEX, "bob", public_key, native_address)); produce_blocks(); @@ -441,7 +436,7 @@ BOOST_FIXTURE_TEST_CASE( recordlink_svm_sweeps_prelink_dclaim_rewards, sysio_aut const auto raw_key = public_key.get().serialize(); const std::vector native_address(raw_key.begin(), raw_key.end()); - BOOST_REQUIRE_EQUAL(success(), onreward( + BOOST_REQUIRE_EQUAL(success(), import_credit( native_address, 8000, ChainKind::CHAIN_KIND_SVM)); BOOST_REQUIRE_EQUAL(success(), recordlink( AUTHEX, "carol", public_key, native_address, ChainKind::CHAIN_KIND_SVM)); @@ -461,7 +456,7 @@ BOOST_FIXTURE_TEST_CASE( recordlink_svm_mismatched_address_skips_sweep, sysio_au std::vector mismatched_address(raw_key.begin(), raw_key.end()); mismatched_address.front() ^= char{0x01}; - BOOST_REQUIRE_EQUAL(success(), onreward( + BOOST_REQUIRE_EQUAL(success(), import_credit( mismatched_address, 8500, ChainKind::CHAIN_KIND_SVM)); BOOST_REQUIRE_EQUAL(success(), recordlink( AUTHEX, "carol", public_key, mismatched_address, ChainKind::CHAIN_KIND_SVM)); @@ -483,7 +478,7 @@ BOOST_FIXTURE_TEST_CASE( recordlink_identical_retry_resweeps_dclaim_rewards, sys const std::vector native_address(address_bytes.begin(), address_bytes.end()); BOOST_REQUIRE_EQUAL(success(), recordlink(AUTHEX, "bob", public_key, native_address)); - BOOST_REQUIRE_EQUAL(success(), onreward(native_address, 9000)); + BOOST_REQUIRE_EQUAL(success(), import_credit(native_address, 9000)); BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); BOOST_REQUIRE_EQUAL(success(), recordlink(AUTHEX, "bob", public_key, native_address)); diff --git a/contracts/tests/sysio.dclaim_tests.cpp b/contracts/tests/sysio.dclaim_tests.cpp index 3dab0dd6d3..1ebdb049bd 100644 --- a/contracts/tests/sysio.dclaim_tests.cpp +++ b/contracts/tests/sysio.dclaim_tests.cpp @@ -16,14 +16,10 @@ 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 staking-reward path -/// credits a WIRE-denominated amount directly (native -> WIRE conversion is -/// outpost-side), so no sysio.reserv deployment is needed. +/// Test fixture for funded, non-expiring DClaim imports and AuthX linking. 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; static constexpr uint32_t YEARS_WITHOUT_CLAIM_SEC = 3u * 365u * 24u * 60u * 60u; @@ -35,7 +31,7 @@ class sysio_dclaim_tester : public tester { // 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, + DCLAIM_ACCOUNT, TOKEN_ACCOUNT, "alice"_n, "bob"_n, }); produce_blocks(2); @@ -69,22 +65,14 @@ class sysio_dclaim_tester : public tester { 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 chain_code, - 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() - ("chain_code", chain_code) - ("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)); + /// Seed a pre-launch credit, optionally sweeping it into the linked account. + action_result seed_credit(const std::string& wire_account, const std::vector& address, int64_t amount) { + auto result = push_dclaim(DCLAIM_ACCOUNT, "importseed"_n, mvo() + ("chain", ChainKind::CHAIN_KIND_EVM) + ("credits", fc::variants{mvo()("native_address", address)("wire_atomic", amount)})); + if (result != success() || wire_account.empty()) return result; + return push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() + ("wire_account", wire_account)("chain", ChainKind::CHAIN_KIND_EVM)("native_pubkey", address)); } fc::variant get_kv(name table, const char* type, uint64_t id) { @@ -95,7 +83,6 @@ class sysio_dclaim_tester : public tester { } 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); } /// Fund DClaim with real WIRE so delayed claims exercise transfers and row erasure. void fund_claims() { @@ -169,28 +156,23 @@ BOOST_FIXTURE_TEST_CASE(importdone_locks_subsequent_importseed, sysio_dclaim_tes success()); } FC_LOG_AND_RETHROW() } -// -- onreward auth + routing -- +// -- pre-launch import 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_FIXTURE_TEST_CASE(importseed_linked_credits_pending_claims, sysio_dclaim_tester) { try { BOOST_REQUIRE_EQUAL( - onreward(MSGCH_ACCOUNT, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 7, 100), + seed_credit("alice", addr20, 1000), success()); - // reward_amount is already WIRE-denominated -> credited verbatim. + // Imported WIRE is 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 { +BOOST_FIXTURE_TEST_CASE(importseed_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), + seed_credit("", addr20, 5000), success()); BOOST_REQUIRE(pending_of("bob"_n).is_null()); auto u = unmapped_row(1); @@ -208,10 +190,9 @@ 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_old_unmapped_rewards, sysio_dclaim_tester) { try { +BOOST_FIXTURE_TEST_CASE(linkswept_preserves_old_unmapped_imports, sysio_dclaim_tester) { try { fund_claims(); - BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "", ChainKind::CHAIN_KIND_EVM, - addr20, 5000, 7, 100), success()); + BOOST_REQUIRE_EQUAL(seed_credit("", addr20, 5000), success()); produce_block(fc::seconds(YEARS_WITHOUT_CLAIM_SEC)); BOOST_REQUIRE_EQUAL(push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() ("wire_account", "bob") @@ -226,15 +207,13 @@ BOOST_FIXTURE_TEST_CASE(linkswept_preserves_old_unmapped_rewards, sysio_dclaim_t check_claim("bob"_n, 5000); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(linkswept_adds_old_rewards_to_newer_pending_balance, sysio_dclaim_tester) { try { +BOOST_FIXTURE_TEST_CASE(linkswept_adds_old_imports_to_newer_pending_balance, sysio_dclaim_tester) { try { fund_claims(); const std::vector newer_addr(20, char(0xB2)); - BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "", ChainKind::CHAIN_KIND_EVM, - addr20, 5000, 7, 100), success()); + BOOST_REQUIRE_EQUAL(seed_credit("", addr20, 5000), 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()); + BOOST_REQUIRE_EQUAL(seed_credit("bob", newer_addr, 2000), success()); BOOST_REQUIRE_EQUAL(push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() ("wire_account", "bob") @@ -247,15 +226,13 @@ BOOST_FIXTURE_TEST_CASE(linkswept_adds_old_rewards_to_newer_pending_balance, sys check_claim("bob"_n, 7000); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(linkswept_adds_newer_rewards_to_old_pending_balance, sysio_dclaim_tester) { try { +BOOST_FIXTURE_TEST_CASE(linkswept_adds_newer_imports_to_old_pending_balance, sysio_dclaim_tester) { try { fund_claims(); const std::vector newer_addr(20, char(0xB2)); - BOOST_REQUIRE_EQUAL(onreward(MSGCH_ACCOUNT, 1, "bob", ChainKind::CHAIN_KIND_EVM, - addr20, 2000, 7, 100), success()); + BOOST_REQUIRE_EQUAL(seed_credit("bob", addr20, 2000), 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()); + BOOST_REQUIRE_EQUAL(seed_credit("", newer_addr, 5000), success()); BOOST_REQUIRE_EQUAL(push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() ("wire_account", "bob") @@ -264,10 +241,22 @@ BOOST_FIXTURE_TEST_CASE(linkswept_adds_newer_rewards_to_old_pending_balance, sys const auto pending = pending_of("bob"_n); BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 7000); - BOOST_REQUIRE(unmapped_row(1).is_null()); + BOOST_REQUIRE(unmapped_row(2).is_null()); check_claim("bob"_n, 7000); } FC_LOG_AND_RETHROW() } +/// Sweeping multiple imported identities into one account saturates without aborting its OPP parent. +BOOST_FIXTURE_TEST_CASE(linkswept_caps_combined_imports_without_aborting, sysio_dclaim_tester) { try { + constexpr int64_t headroom = 5; + const std::vector second_address(20, char(0xB2)); + BOOST_REQUIRE_EQUAL(success(), seed_credit("bob", addr20, asset::max_amount - headroom)); + BOOST_REQUIRE_EQUAL(success(), seed_credit("", second_address, headroom + 1)); + BOOST_REQUIRE_EQUAL(success(), push_dclaim(AUTHEX_ACCOUNT, "linkswept"_n, mvo() + ("wire_account", "bob")("chain", ChainKind::CHAIN_KIND_EVM)("native_pubkey", second_address))); + BOOST_REQUIRE_EQUAL(asset::max_amount, pending_of("bob"_n)["balance"].as().get_amount()); + BOOST_REQUIRE(unmapped_row(2).is_null()); +} FC_LOG_AND_RETHROW() } + 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() @@ -284,33 +273,14 @@ BOOST_FIXTURE_TEST_CASE(imported_balances_remain_claimable_after_years, sysio_dc check_claim("bob"_n, 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() } // -- indefinitely claimable balances -- -BOOST_FIXTURE_TEST_CASE(pending_rewards_remain_claimable_after_years, sysio_dclaim_tester) { try { +BOOST_FIXTURE_TEST_CASE(pending_imports_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), + seed_credit("alice", addr20, 1000), success()); BOOST_REQUIRE(!pending_of("alice"_n).is_null()); @@ -320,59 +290,5 @@ BOOST_FIXTURE_TEST_CASE(pending_rewards_remain_claimable_after_years, sysio_dcla check_claim("alice"_n, 1000); } FC_LOG_AND_RETHROW() } -// onreward runs inside the OPP inbound dispatch chain (msgch::evalcons), where an abort rolls back -// the consensus-tipping deliver and stalls epoch advancement. A cross-chain-supplied -// staker_wire_account that name() would reject must NOT abort: onreward treats it as unlinked and -// parks the credit in unmapped_tokens by native address. name() has three distinct reject classes, -// and the guard absorbs all three: (1) overlong at > 13 chars, (2) a character outside the -// ".12345a-z" name alphabet such as uppercase or '-', and (3) a length-valid 13-character name whose -// final symbol exceeds 'j' (value 15): the 13th position encodes only 4 bits. Each malformed -// spelling is exercised on a distinct native address so it lands as its own row (unmapped + dedupe -// cursor are keyed by native address), covering the full reject domain the dispatch name guard absorbs. -BOOST_FIXTURE_TEST_CASE(onreward_invalid_wire_account_parks_unmapped, sysio_dclaim_tester) { try { - const struct { const char* wire_account; char addr_byte; } cases[] = { - { "thisnameistoolong", char(0xB1) }, // 17 chars: length > 13 - { "BADNAME", char(0xB2) }, // uppercase: outside the name alphabet - { "bad-name", char(0xB3) }, // '-': outside the name alphabet - { "aaaaaaaaaaaak", char(0xB4) }, // 13 chars, 13th symbol 'k' (16) > 'j' (15): 4-bit slot overflow - }; - - uint64_t expected_id = 1; // next_unmapped_id defaults to 1; one new row per distinct address - for (const auto& c : cases) { - const std::vector addr(20, c.addr_byte); - BOOST_REQUIRE_EQUAL(success(), - onreward(MSGCH_ACCOUNT, 1, c.wire_account, ChainKind::CHAIN_KIND_EVM, addr, - 1000, 7, 100)); - // Soft-handled: credit parked as unmapped (unlinked), never aborted. - auto u = unmapped_row(expected_id); - BOOST_REQUIRE(!u.is_null()); - BOOST_REQUIRE_EQUAL(u["balance"].as().get_amount(), 1000); - ++expected_id; - } -} FC_LOG_AND_RETHROW() } - -// A reward_amount above asset::max_amount (2^62-1) would abort the WIRE asset constructor. onreward -// must soft-skip it (return early) rather than abort the inbound dispatch — no credit is created. -BOOST_FIXTURE_TEST_CASE(onreward_oversized_amount_soft_skips, sysio_dclaim_tester) { try { - const uint64_t oversized = (uint64_t(1) << 62); // asset::max_amount + 1 - BOOST_REQUIRE_EQUAL(success(), - onreward(MSGCH_ACCOUNT, 1, "alice", ChainKind::CHAIN_KIND_EVM, addr20, - oversized, 7, 100)); - // Soft-skipped: neither a pending claim (valid name "alice") nor an unmapped row was created. - BOOST_REQUIRE(pending_of("alice"_n).is_null()); - BOOST_REQUIRE(unmapped_row(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// The positive side of the dispatch name guard: a valid account name that is not plain lowercase -// (here one carrying both a dot and a digit) must still resolve to a linked staker and credit -// pending_claims, never fall through to the unmapped native-address parking path. -BOOST_FIXTURE_TEST_CASE(onreward_dotted_digit_name_credits_pending, sysio_dclaim_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - onreward(MSGCH_ACCOUNT, 1, "stak.er1", ChainKind::CHAIN_KIND_EVM, addr20, 1000, 7, 100)); - auto p = pending_of("stak.er1"_n); - BOOST_REQUIRE(!p.is_null()); - BOOST_REQUIRE_EQUAL(p["balance"].as().get_amount(), 1000); - BOOST_REQUIRE(unmapped_row(1).is_null()); // linked directly, not parked as unmapped -} FC_LOG_AND_RETHROW() } BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 7abdfb2c19..3a52be73fa 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -61,10 +61,6 @@ using sysio_system::test_support::sign_createlink; namespace { -constexpr uint64_t PROTOBUF_VARINT_PAYLOAD_MASK = 0x7fu; -constexpr uint32_t PROTOBUF_VARINT_PAYLOAD_BITS = 7u; -constexpr uint8_t PROTOBUF_VARINT_CONTINUATION_BIT = 0x80u; -constexpr uint32_t PROTOBUF_FIELD_TAG_SHIFT = 3u; /// The outpost custody a SyndicateLIQ or LIQYield fixture carries by default: the whole asset range, at /// or above any outstanding shadow a dispatch test can reach. @@ -95,7 +91,8 @@ std::vector encode_envelope_with_mixed_attestations( uint32_t epoch_index, const std::vector& attestations, const std::string& previous_envelope_hash = {}, - const std::string& previous_message_id = {}) + const std::string& previous_message_id = {}, + const std::vector& unknown_types = {}) { sysio::opp::Envelope env; env.set_epoch_index(epoch_index); @@ -105,6 +102,14 @@ std::vector encode_envelope_with_mixed_attestations( auto* msg = env.add_messages(); auto* payload = msg->mutable_payload(); + // Open protobuf enums retain raw wire discriminants, including undeclared values. + constexpr auto type_field = "type"; + for (const auto raw_type : unknown_types) { + auto* att = payload->add_attestations(); + att->GetReflection()->SetEnumValue(att, att->GetDescriptor()->FindFieldByName(type_field), raw_type); + att->set_data(std::string(1, '\x0a')); + att->set_data_size(1); + } for (const auto& [att_type, att_data] : attestations) { auto* att = payload->add_attestations(); att->set_type(att_type); @@ -1000,14 +1005,12 @@ BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_te auto eth_bytes = em_uncompressed_pubkey_bytes(eth_pub); auto eth_address = fc::crypto::ethereum::address_to_bytes(eth_pub); - // Seed a pre-link reward at the key-derived address, then deliberately put a conflicting address + // Seed a pre-link import at the key-derived address, then deliberately put a conflicting address // in the redundant actor field. Dispatch must derive from actor_pub_key and sweep the real row. const std::vector native_address(eth_address.begin(), eth_address.end()); - BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, MSGCH_ACCOUNT, "onreward"_n, mvo() - ("chain_code", eth_code)("staker_wire_account", std::string{}) - ("reward_chain", ChainKind::CHAIN_KIND_EVM)("staker_native_addr", native_address) - ("reward_amount", uint64_t{4321})("reward_epoch_index", uint32_t{7}) - ("external_epoch_ref", uint64_t{100})("share_bps", uint32_t{10000}))); + BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, DCLAIM_ACCOUNT, "importseed"_n, mvo() + ("chain", ChainKind::CHAIN_KIND_EVM)("credits", fc::variants{ + mvo()("native_address", native_address)("wire_atomic", int64_t{4321})}))); BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); std::fill(eth_address.begin(), eth_address.end(), uint8_t{0xA5}); @@ -1656,18 +1659,24 @@ BOOST_FIXTURE_TEST_CASE(retired_attestations_do_not_mutate_collateral_or_block_l std::vector entries; for (auto type : {ATTESTATION_TYPE_OPERATOR_ACTION, ATTESTATION_TYPE_SWAP_REQUEST, ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, - ATTESTATION_TYPE_RESERVE_CREATE, ATTESTATION_TYPE_RESERVE_CREATE_CANCEL}) { + ATTESTATION_TYPE_RESERVE_CREATE_CANCEL}) { entries.emplace_back(type, std::string(1, '\x0a')); } entries.emplace_back(ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, ChainKind::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, liqeth, LIQ_UNIT, 1)); + constexpr int32_t removed_reward_type = 60950; + constexpr int32_t removed_reserve_create_type = 60958; + constexpr int32_t undeclared_type = 65000; const auto trace = deliver_trace(eth, - encode_envelope_with_mixed_attestations(current_epoch(), entries)); + encode_envelope_with_mixed_attestations(current_epoch(), entries, {}, {}, + {removed_reward_type, removed_reserve_create_type, undeclared_type})); BOOST_REQUIRE(trace != nullptr); BOOST_REQUIRE(!trace->except); BOOST_CHECK(get_operator(UWRIT_OP)["balances"].get_array().empty()); BOOST_CHECK(get_wtdw(0).is_null()); + BOOST_CHECK(get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); + BOOST_CHECK(get_dclaim_row("pclaims"_n, "pending_claim", UWRIT_OP.to_uint64_t()).is_null()); BOOST_REQUIRE_EQUAL(1u, synd_items().size()); BOOST_CHECK_EQUAL(LIQ_UNIT, liq_supply()); BOOST_CHECK_EQUAL(LIQ_UNIT, liq_balance(SYND_ACCOUNT)); @@ -2085,7 +2094,8 @@ BOOST_FIXTURE_TEST_CASE(a_shortfall_freezes_the_same_envelopes_queue_step, sysio BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi, config::system_account_name, "setconfig"_n, mvo() ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("LIQETH"))("synd_fee_bps", 0) ("desynd_fee_bps", 0)("synd_burst", 1'000'000 * LIQ_UNIT)("synd_refill", 1'000'000 * LIQ_UNIT) - ("desynd_burst", 0)("desynd_refill", 0)("window_sec", 10800)("bounty", 0)("challenge_extra", 0))); + ("desynd_burst", 0)("desynd_refill", 0)("window_sec", 10800)("bounty", 0)("challenge_extra", 0) + ("min_desyndicate", 1))); setup_wire_token(); enable_epoch_advancement(); @@ -2227,46 +2237,18 @@ BOOST_AUTO_TEST_CASE(generic_external_simulator_drives_production_dispatch) { } } -// A real staking-reward envelope crosses msgch -> dclaim -> system -> token. -// Small treasury liquidity deliberately reaches the second fundclaim cap. -BOOST_FIXTURE_TEST_CASE(generic_staking_rewards_fund_claim_and_dedupe_through_dispatch, sysio_dispatch_tester) { +// Funded pre-launch imports link and settle the exact token balance. +BOOST_FIXTURE_TEST_CASE(funded_import_claims_link_and_settle, sysio_dispatch_tester) { using namespace sysio::testing::external; bootstrap_for_dispatch(First.chain, First.kind); deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, TOKEN_ACCOUNT, "create"_n, mvo()("issuer", "sysio")("maximum_supply", "100.000000000 WIRE"))); BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, "issue"_n, - mvo()("to", "sysio")("quantity", "100.000000000 WIRE")("memo", "reward funding"))); + mvo()("to", "sysio")("quantity", "100.000000000 WIRE")("memo", "import funding"))); enable_epoch_advancement(); - external::chain outpost(First); - constexpr uint64_t Reward = 10 * Unit; - sysio::opp::attestations::StakingReward reward; - reward.set_chain_code(fc::slug_name{First.chain}.value); - reward.mutable_staker_wire_account()->set_name(UWRIT_OP.to_string()); - reward.set_share_bps(10'000); - reward.set_reward_epoch_index(current_epoch()); - reward.set_external_epoch_ref(1); - reward.mutable_reward_amount()->set_token_code(fc::slug_name{"WIRE"}.value); - reward.mutable_reward_amount()->set_amount(Reward); - reward.mutable_staker_native_address()->set_kind(First.kind); - const std::vector address(20, '\x31'); - reward.mutable_staker_native_address()->set_address(address.data(), address.size()); - const auto body = reward.SerializeAsString(); - const auto bytes = outpost.envelope(current_epoch(), { - {ATTESTATION_TYPE_STAKING_REWARD, body}, {ATTESTATION_TYPE_STAKING_REWARD, body}}); - const auto trace = deliver_trace(fc::slug_name{First.chain}.value, bytes); - BOOST_REQUIRE(trace && !trace->except); const auto wire = symbol::from_string("9,WIRE"); const auto balance = [&](name account) { return get_currency_balance(TOKEN_ACCOUNT, wire, account).get_amount(); }; - BOOST_REQUIRE_EQUAL(Reward, get_dclaim_row("pclaims"_n, "pending_claim", UWRIT_OP.to_uint64_t()) - ["balance"].as().get_amount()); - BOOST_REQUIRE_EQUAL(Reward, balance(DCLAIM_ACCOUNT)); - BOOST_REQUIRE_EQUAL(90 * Unit, balance(config::system_account_name)); - BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, UWRIT_OP, "claim"_n, - mvo()("wire_account", UWRIT_OP))); - BOOST_REQUIRE_EQUAL(Reward, balance(UWRIT_OP)); - BOOST_REQUIRE_EQUAL(0, balance(DCLAIM_ACCOUNT)); - BOOST_REQUIRE(get_dclaim_row("pclaims"_n, "pending_claim", UWRIT_OP.to_uint64_t()).is_null()); // The soak's launch import has its own funding path: pre-fund, import an // unlinked identity, authenticate the link, then claim the exact WIRE credit. constexpr uint64_t Imported = 5 * Unit; @@ -2287,16 +2269,5 @@ BOOST_FIXTURE_TEST_CASE(generic_staking_rewards_fund_claim_and_dedupe_through_di mvo()("wire_account", CLAIM_ACCOUNT))); BOOST_REQUIRE_EQUAL(Imported, balance(CLAIM_ACCOUNT)); BOOST_REQUIRE_EQUAL(0, balance(DCLAIM_ACCOUNT)); - const auto funding_left = balance(config::system_account_name); - // A new reward at the authenticated downstream boundary exceeds liquid - // treasury funds. Credit is retained, but funding cannot overspend custody. - BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, MSGCH_ACCOUNT, "onreward"_n, - mvo()("chain_code", fc::slug_name{First.chain}.value)("staker_wire_account", UWRIT_OP.to_string()) - ("reward_chain", First.kind)("staker_native_addr", address)("reward_amount", 100 * Unit) - ("reward_epoch_index", current_epoch())("external_epoch_ref", 2)("share_bps", 10'000))); - BOOST_REQUIRE_EQUAL(funding_left, balance(DCLAIM_ACCOUNT)); - BOOST_REQUIRE_EQUAL(0, balance(config::system_account_name)); - BOOST_REQUIRE_EQUAL(100 * Unit, get_dclaim_row("pclaims"_n, "pending_claim", UWRIT_OP.to_uint64_t()) - ["balance"].as().get_amount()); } BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.liq_tests.cpp b/contracts/tests/sysio.liq_tests.cpp index 8719902f44..4ead4d78a9 100644 --- a/contracts/tests/sysio.liq_tests.cpp +++ b/contracts/tests/sysio.liq_tests.cpp @@ -23,18 +23,6 @@ using namespace fc; using mvo = fc::mutable_variant_object; using sysio_system::test_support::codename_mvo; -namespace { - -/// The action data of `sysio.system::fundclaim(name recipient, int64_t amount)`, for -/// reading the kicker request out of an addyield trace. -struct fundclaim_args { - name recipient; - int64_t amount; -}; - -} // anonymous namespace -FC_REFLECT( fundclaim_args, (recipient)(amount) ) - /// sysio.liq end to end on the depot: the registries it validates against, the /// swap it feeds, and sysio.synd as the signer of every mint and burn (an account /// only; sysio.msgch is an account too, a signer that is not sysio.synd). The @@ -182,13 +170,6 @@ class sysio_liq_tester : public tester { return push_liq(from, "addyield"_n, mvo()("from", from)("quantity", asset(wire_amount, WIRE_SYM)) ("target", target.to_symbol_code())); } - action_result addkicker(name signer, symbol sym, int64_t base_balance, uint64_t requested) { - return push_liq(signer, "addkicker"_n, mvo()("sym", sym.to_symbol_code())("base_balance", base_balance) - ("requested", requested)); - } - action_result setkicker(name signer, uint32_t bps) { - return push_liq(signer, "setkicker"_n, mvo()("bps", bps)); - } action_result recredit(name holder, int64_t amount, name signer = LIQ_ACCOUNT) { return push_liq(signer, "recredit"_n, mvo()("holder", holder)("quantity", asset(amount, LIQSOL_SYM))); } @@ -252,9 +233,6 @@ class sysio_liq_tester : public tester { fc::variant pending_row(symbol sym = LIQSOL_SYM) { return liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, sym.to_symbol_code().value); } - fc::variant config_row() { - return decode(liq_abi_ser, "liq_config", get_row_by_account(LIQ_ACCOUNT, LIQ_ACCOUNT, "liqconfig"_n, "liqconfig"_n)); - } int64_t wire_balance(name holder) { const auto row = decode(token_abi_ser, "account", get_row_by_id(TOKEN_ACCOUNT, holder, "accounts"_n, WIRE_SYM.to_symbol_code().value)); @@ -482,76 +460,33 @@ BOOST_FIXTURE_TEST_CASE(the_index_grows_past_64_bits, sysio_liq_tester) try { BOOST_REQUIRE_EQUAL(donation, owed("alice"_n)); } FC_LOG_AND_RETHROW() -// --------------------------------------------------------------------------- -// The kicker -// --------------------------------------------------------------------------- - -BOOST_FIXTURE_TEST_CASE(addyield_requests_the_kicker_from_t5, sysio_liq_tester) try { +/// Swap proceeds and donations distribute exactly their funding without drawing from T5. +BOOST_FIXTURE_TEST_CASE(addyield_distributes_only_supplied_wire, sysio_liq_tester) try { + constexpr int64_t intake = 100 * UNIT; BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); - BOOST_REQUIRE_EQUAL(uint32_t(200), config_row().is_null() ? 200u : config_row()["kicker_bps"].as()); - - // The swap's intake is the one that is yield, so it is the one that earns the - // kicker. Stand in for the pool's proceeds: WIRE deposited on the swap. - BOOST_REQUIRE_EQUAL(success(), openext("alice"_n, "alice"_n, extended_symbol{ WIRE_SYM, TOKEN_ACCOUNT })); - BOOST_REQUIRE_EQUAL(success(), transfer_wire("alice"_n, SWAP_ACCOUNT, 300 * UNIT)); - // 100 WIRE in from `from`; whether the trace carries the 2% fundclaim for - // this contract and the fold that follows it. - const auto intake = [&](name from) { - auto trace = base_tester::push_action(LIQ_ACCOUNT, "addyield"_n, from, mvo() - ("from", from)("quantity", asset(100 * UNIT, WIRE_SYM))("target", LIQSOL_SYM.to_symbol_code())); + BOOST_REQUIRE_EQUAL(success(), openext("alice"_n, "alice"_n, extended_symbol{WIRE_SYM, TOKEN_ACCOUNT})); + BOOST_REQUIRE_EQUAL(success(), transfer_wire("alice"_n, SWAP_ACCOUNT, intake)); + const auto treasury_before = wire_balance(SYSIO_ACCOUNT); + const auto holder_before = wire_balance("alice"_n); + for (const auto from : {SWAP_ACCOUNT, "carol"_n}) { + const auto source_before = wire_balance(from); + const auto trace = base_tester::push_action(LIQ_ACCOUNT, "addyield"_n, from, mvo() + ("from", from)("quantity", asset(intake, WIRE_SYM))("target", LIQSOL_SYM.to_symbol_code())); + BOOST_REQUIRE(trace && !trace->except); produce_block(); - bool requested = false, folded = false; - for (const auto& at : trace->action_traces) { - if (at.act.account == SYSIO_ACCOUNT && at.act.name == "fundclaim"_n) { - const auto args = fc::raw::unpack(at.act.data); - BOOST_REQUIRE_EQUAL(LIQ_ACCOUNT, args.recipient); - BOOST_REQUIRE_EQUAL(2 * UNIT, args.amount); - requested = true; - } - if (at.act.account == LIQ_ACCOUNT && at.act.name == "addkicker"_n) folded = true; + for (const auto& action : trace->action_traces) { + BOOST_CHECK(action.act.account != SYSIO_ACCOUNT); + BOOST_CHECK(action.act.name != "addkicker"_n); } - return std::make_pair(requested, folded); - }; - - // sysio runs no emissions here, so the request lands nothing and the fold is - // a no-op: holders get the base yield and nothing else. - BOOST_REQUIRE(intake(SWAP_ACCOUNT) == std::make_pair(true, true)); - BOOST_REQUIRE_EQUAL(100 * UNIT, owed("alice"_n)); - BOOST_REQUIRE_EQUAL(uint64_t(100 * UNIT), pot()); - - // A donation from anyone else distributes only itself: no request, no fold. - // Otherwise a near-sole holder could donate, claim it back with the kicker on - // top, and repeat against the treasury. - BOOST_REQUIRE(intake("carol"_n) == std::make_pair(false, false)); - BOOST_REQUIRE_EQUAL(200 * UNIT, owed("alice"_n)); - BOOST_REQUIRE_EQUAL(uint64_t(200 * UNIT), pot()); - - // With the kicker off, the swap's intake makes no request either. - BOOST_REQUIRE(mentions(setkicker("alice"_n, 0), "missing authority of sysio")); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("kicker_bps out of range"), setkicker(SYSIO_ACCOUNT, 10'001)); - BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); - BOOST_REQUIRE_EQUAL(0u, config_row()["kicker_bps"].as()); - BOOST_REQUIRE(intake(SWAP_ACCOUNT) == std::make_pair(false, false)); -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE(addkicker_folds_only_what_landed, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); - // Stand in for T5: 5 WIRE arrive at the contract. - BOOST_REQUIRE_EQUAL(success(), transfer_wire("carol"_n, LIQ_ACCOUNT, 5 * UNIT)); - const int64_t balance = wire_balance(LIQ_ACCOUNT); - - BOOST_REQUIRE(mentions(addkicker("alice"_n, LIQSOL_SYM, balance - 2 * UNIT, 3 * UNIT), "missing authority of sysio.liq")); - // 2 WIRE over the base, 3 requested: 2 fold in. - BOOST_REQUIRE_EQUAL(success(), addkicker(LIQ_ACCOUNT, LIQSOL_SYM, balance - 2 * UNIT, 3 * UNIT)); - BOOST_REQUIRE_EQUAL(uint64_t(2 * UNIT), pot()); - BOOST_REQUIRE_EQUAL(2 * UNIT, owed("alice"_n)); - // 2 over, 1 requested: only the request folds in. - BOOST_REQUIRE_EQUAL(success(), addkicker(LIQ_ACCOUNT, LIQSOL_SYM, balance - 2 * UNIT, UNIT)); - BOOST_REQUIRE_EQUAL(uint64_t(3 * UNIT), pot()); - // Nothing over the base: nothing folds. - BOOST_REQUIRE_EQUAL(success(), addkicker(LIQ_ACCOUNT, LIQSOL_SYM, balance, UNIT)); - BOOST_REQUIRE_EQUAL(uint64_t(3 * UNIT), pot()); - BOOST_REQUIRE_EQUAL(3 * UNIT, owed("alice"_n)); + BOOST_CHECK_EQUAL(source_before - intake, wire_balance(from)); + BOOST_CHECK_EQUAL(treasury_before, wire_balance(SYSIO_ACCOUNT)); + } + BOOST_REQUIRE_EQUAL(2 * intake, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(2 * intake), pot()); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_CHECK_EQUAL(holder_before + 2 * intake, wire_balance("alice"_n)); + BOOST_CHECK_EQUAL(0, pot()); + BOOST_CHECK_EQUAL(0, owed("alice"_n)); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- @@ -559,7 +494,6 @@ BOOST_FIXTURE_TEST_CASE(addkicker_folds_only_what_landed, sysio_liq_tester) try // --------------------------------------------------------------------------- BOOST_FIXTURE_TEST_CASE(queued_yield_sells_through_the_pool_and_pays_holders, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); // Nothing pending: queueing is a no-op, and the pool does not exist yet. @@ -623,7 +557,6 @@ BOOST_FIXTURE_TEST_CASE(queued_yield_sells_through_the_pool_and_pays_holders, sy // measures its headroom net of what is parked in liqpending, so queueyield can always // mint it. A report past that headroom is dropped whole, so no value is clipped. BOOST_FIXTURE_TEST_CASE(pending_yield_is_reserved_against_the_asset_range, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); // The pool first, while the range is open: regliqpool mints the LCO seed. Then one // syndication takes supply to 100 base units under the range. BOOST_REQUIRE_EQUAL(success(), regliqpool(SOLANA, LIQSOL, POOL_SYM, 1000 * UNIT, 1000 * UNIT)); @@ -710,9 +643,8 @@ BOOST_FIXTURE_TEST_CASE(regliqpool_refusals, sysio_liq_tester) try { // Governance reconciliation // --------------------------------------------------------------------------- -// An outpost that refused a de-syndication is reconciled by governance: `recredit` mints the burned -// amount back to the holder, growing the supply by exactly that, and only this contract may sign it. -BOOST_FIXTURE_TEST_CASE(recredit_mints_a_refused_desyndication_back, sysio_liq_tester) try { +/// Exceptional supply repair preserves existing yield and requires the LIQ contract authority. +BOOST_FIXTURE_TEST_CASE(privileged_supply_repair_preserves_existing_yield, sysio_liq_tester) try { BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 60 * UNIT)); BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 6 * UNIT)); diff --git a/contracts/tests/sysio.synd_tests.cpp b/contracts/tests/sysio.synd_tests.cpp index 3705f145ea..0432011017 100644 --- a/contracts/tests/sysio.synd_tests.cpp +++ b/contracts/tests/sysio.synd_tests.cpp @@ -56,6 +56,7 @@ constexpr std::string_view REQUEST_OPEN = "OPEN"; constexpr std::string_view REQUEST_BONDED = "BONDED"; constexpr std::string_view REQUEST_APPROVED = "APPROVED"; constexpr std::string_view REQUEST_HELD = "HELD"; +constexpr std::string_view REQUEST_INVALID = "INVALID"; /// The `total_syndicated` an intake helper carries by default: the whole asset range, at or above any /// outstanding shadow (supply plus parked yield) a test can reach, so an outpost custody of this size /// always covers the depot. @@ -455,14 +456,16 @@ class sysio_synd_tester : public tester { uint32_t window_sec = DEFAULT_WINDOW_SEC; uint64_t bounty = 0; uint64_t challenge_extra = 0; + uint64_t min_desyndicate = 1; ///< Permissive fixture floor for small-value edge cases. }; action_result setconfig(std::string_view chain_code, std::string_view token_code, const pair_config& cfg, name signer = SYSIO_ACCOUNT) { - return push(SYND_ACCOUNT, synd_abi_ser, signer, "setconfig"_n, mvo() + auto data = mvo() ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) ("synd_fee_bps", cfg.synd_fee_bps)("desynd_fee_bps", cfg.desynd_fee_bps)("synd_burst", cfg.synd_burst) ("synd_refill", cfg.synd_refill)("desynd_burst", cfg.desynd_burst)("desynd_refill", cfg.desynd_refill) - ("window_sec", cfg.window_sec)("bounty", cfg.bounty)("challenge_extra", cfg.challenge_extra)); + ("window_sec", cfg.window_sec)("bounty", cfg.bounty)("challenge_extra", cfg.challenge_extra)("min_desyndicate", cfg.min_desyndicate); + return push(SYND_ACCOUNT, synd_abi_ser, signer, "setconfig"_n, data); } action_result dropenv(std::string_view chain_code, std::string_view token_code, uint32_t epoch, name signer = SYSIO_ACCOUNT) { @@ -1255,12 +1258,13 @@ BOOST_FIXTURE_TEST_CASE(liq_has_none_of_the_moved_actions, sysio_synd_tester) tr BOOST_REQUIRE(has_table(synd_abi, "feepot"_n)); for (auto action : { "setconfig"_n, "crank"_n, "dropenv"_n, "sweepyield"_n }) BOOST_TEST_CONTEXT(action.to_string()) { BOOST_REQUIRE(has_action(synd_abi, action)); } - // The import flag left sysio.liq's config with the import. + // Import state moved to syndication, and the bonus config is retired. const auto liq_config = std::find_if(liq_abi.structs.begin(), liq_abi.structs.end(), [](const auto& st) { return st.name == "liq_config"; }); - BOOST_REQUIRE(liq_config != liq_abi.structs.end()); - BOOST_REQUIRE(std::none_of(liq_config->fields.begin(), liq_config->fields.end(), - [](const auto& f) { return f.name == "import_complete"; })); + BOOST_REQUIRE(liq_config == liq_abi.structs.end()); + BOOST_REQUIRE(!has_table(liq_abi, "liqconfig"_n)); + BOOST_REQUIRE(!has_action(liq_abi, "setkicker"_n)); + BOOST_REQUIRE(!has_action(liq_abi, "addkicker"_n)); // What stays in the ledger stays. for (auto action : { "mint"_n, "burn"_n, "mintyield"_n, "creditowed"_n, "transfer"_n, "claim"_n, "recredit"_n, "regliqpool"_n }) @@ -1592,6 +1596,43 @@ BOOST_FIXTURE_TEST_CASE(desyndicate_carries_the_outstanding_shadow_after_its_bur BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), liq_mint("alice"_n, LIQSOL, 1)); } FC_LOG_AND_RETHROW() +/// The gross return minimum rejects dust before custody, budgets or request ids change. +BOOST_FIXTURE_TEST_CASE(desyndicate_enforces_configured_minimum, + sysio_synd_tester) try { + constexpr uint64_t minimum = 10 * UNIT; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, + {.desynd_fee_bps = 1000, .desynd_burst = 100 * UNIT, .min_desyndicate = minimum})); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("quantity is below the minimum desyndication"), + desyndicate("alice"_n, minimum - 1)); + BOOST_CHECK_EQUAL(100 * UNIT, liq_balance("alice"_n)); + BOOST_CHECK_EQUAL(100 * UNIT, supply()); + BOOST_CHECK(desynd_bucket_row(SOLANA, LIQSOL).is_null()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 1000, .min_desyndicate = minimum})); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("quantity is below the minimum desyndication"), + desyndicate("alice"_n, minimum - 1)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, minimum)); + BOOST_CHECK_EQUAL(9 * UNIT, queued_desyndication(*this, 1).amount().amount()); + BOOST_CHECK_EQUAL(90 * UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.min_desyndicate = 1})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 1)); +} FC_LOG_AND_RETHROW() + +/// Governance cannot configure zero, an unrepresentable minimum, or one above an enabled burst. +BOOST_FIXTURE_TEST_CASE(setconfig_validates_minimum_return, sysio_synd_tester) try { + constexpr auto message = "minimum desyndication must be positive and fit the burst and asset range"; + BOOST_REQUIRE_EQUAL(wasm_assert_msg(message), setconfig(SOLANA, LIQSOL, {.min_desyndicate = 0})); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(message), setconfig(SOLANA, LIQSOL, + {.desynd_burst = 0, .min_desyndicate = static_cast(asset::max_amount) + 1})); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(message), setconfig(SOLANA, LIQSOL, + {.desynd_burst = UNIT, .min_desyndicate = UNIT + 1})); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_burst = 0, .min_desyndicate = UNIT})); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_burst = UNIT, .min_desyndicate = UNIT})); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, + {.desynd_burst = 0, .min_desyndicate = static_cast(asset::max_amount)})); +} FC_LOG_AND_RETHROW() + // The fee, `quantity * desynd_fee_bps / 10000` floored, stays in `feepot` in sysio.synd's own row; only // the net rest is burned, queued, logged and summed, and the bucket drops by the whole quantity. A fee of // the whole quantity leaves nothing to release on the outpost and is refused. @@ -2925,6 +2966,44 @@ BOOST_FIXTURE_TEST_CASE(challenge_refused_on_an_approved_request, sysio_synd_tes BOOST_REQUIRE_EQUAL(REQUEST_APPROVED, bond_request(id)["state"].as_string()); } FC_LOG_AND_RETHROW() +/// Approval eligibility does not itself close interventions while the request remains BONDED. +BOOST_FIXTURE_TEST_CASE(a_frozen_request_can_be_held_or_ruled_invalid_after_its_deadline, sysio_synd_tester) try { + constexpr uint32_t window = 60; + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.window_sec = window})); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + syndicate_and_close(1, 1, ed_bytes(ed_key()), LIQSOL, 10 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + produce_block(fc::seconds(window + 1)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(REQUEST_HELD, bond_request(1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, SYSIO_ACCOUNT, "rslvinvalid"_n, + mvo()("request_id", 1))); + BOOST_REQUIRE_EQUAL(REQUEST_INVALID, bond_request(1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +/// The cord pauses releases, but approval after the deadline permanently closes challenges. +BOOST_FIXTURE_TEST_CASE(a_freeze_does_not_extend_the_challenge_deadline, sysio_synd_tester) try { + constexpr uint32_t window = 60; + const auto key = ed_key(); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.window_sec = window})); + syndicate_and_close(1, 1, ed_bytes(key), LIQSOL, 10 * UNIT); + const auto id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + produce_block(fc::seconds(window + 1)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "carol"_n, "approve"_n, mvo()("request_id", id))); + BOOST_REQUIRE_EQUAL(REQUEST_APPROVED, bond_request(id)["state"].as_string()); + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQSOL, 1), CHALLENGE_FINAL)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_CHECK_EQUAL(0, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_CHECK_EQUAL(10 * UNIT, liq_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + // Review Focus 4: an envelope with no request issued yet (WAITING behind an unbonded one), or with no // row at all, cannot be challenged; nor can sysio.synd, sysio.bond or an account with contract code // challenge. @@ -4016,7 +4095,7 @@ BOOST_FIXTURE_TEST_CASE(a_released_yield_counts_before_it_is_queued, sysio_synd_ // `sysio.liq::recredit`, which grows the supply directly, and the outpost still holds what it never paid. // The next comparison sees the recredit with nothing recorded in `sysio.synd`: custody 20 matches // 16 + 4 exactly, where a check that missed the recredit would read an excess of 4. -BOOST_FIXTURE_TEST_CASE(a_recredit_is_seen_by_the_next_comparison, sysio_synd_tester) try { +BOOST_FIXTURE_TEST_CASE(a_return_refund_is_seen_by_the_next_comparison, sysio_synd_tester) try { const auto pubkey = ed_bytes(ed_key()); constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 20 * UNIT)); // released: custody 20 @@ -4035,7 +4114,7 @@ BOOST_FIXTURE_TEST_CASE(a_recredit_is_seen_by_the_next_comparison, sysio_synd_te MSGCH_ACCOUNT, 21 * UNIT)); BOOST_REQUIRE(!console_has(EXCESS)); require_no_shortfall(); - // The recredit wrote nothing to the ledger's running sums: the check does not read them. + // The return refund wrote nothing to the ledger's running sums: the check does not read them. } FC_LOG_AND_RETHROW() // Review focus 2: only an admitted message is compared. A replayed sequence, or a message dropped for any @@ -4303,7 +4382,6 @@ BOOST_FIXTURE_TEST_CASE(generic_parked_credit_yield_conversion_and_fee_rounded_r constexpr uint64_t PoolSeed = 1'000 * UNIT; constexpr uint32_t Horizon = 86'400, DepthBps = 300; constexpr int64_t ClipFloor = 1000; - BOOST_REQUIRE_EQUAL(success(), push(LIQ_ACCOUNT, liq_abi_ser, SYSIO_ACCOUNT, "setkicker"_n, mvo()("bps", 0))); for (size_t i = 0; i < outposts.size(); ++i) { BOOST_TEST_CONTEXT("chain=" << outposts[i].asset().chain) { auto& external = outposts[i]; diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 8a0b74c41c..3e5555a818 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -148,7 +148,7 @@ WIRE-352 adds separate inline edges whose failure behavior depends on the caller | `sysio.synd::challenge` | `sysio.liq::transfer`, `sysio.bond::hold` | Deploy `sysio.bond` before or with `sysio.synd`. | A signed action: a missing or refusing callee fails only the challenge, which the challenger can retry. | | Andon custody readers: swap, liq, bond, synd | Three-field `cord` row | Deploy all five from the same revision on a fresh deployment; configure native pull/clear permissions. | Existing cord rows use an incompatible layout; no in-place migration is supplied. | | Syndication custody shortfall | Andon `pull(reason)` with `sysio.andon@active` | Deploy Andon before syndication carries traffic. Privileged syndication needs no custom pull permission. | Missing account/permission or incompatible code fails the transaction atomically. | -| `sysio.synd` reading its own `mismatch` rows | its own table: `mismatch` is new | Deploy this `sysio.synd` fresh, as the row below requires. | Rows accumulate with no prune; nothing else reads them. | +| `sysio.synd` reading its own `mismatch` rows | its own table: `mismatch` is new | Deploy this `sysio.synd` fresh, as the row below requires. | One latest incident per chain/token; a new shortfall overwrites it. Snapshot evidence externally before governance reconciliation erases it. | | Syndication envelope and bucket rows | Current ABI layouts | Deploy fresh without rows from earlier layouts. | Buckets now store level and last epoch only; no frozen-epoch marker or migration is included. | Production deployment through `sysio.roa::setsyscode` privileges `sysio.dclaim` @@ -321,3 +321,17 @@ The safe procedure is therefore: code order alone. If a rollback has to happen anyway, the outstanding balances are a liability to settle deliberately, not a detail the deploy sequence absorbs. + +## PR 662 follow-on pre-launch deployment + +Deploy this revision on fresh state and regenerate the OPP models and SDK contract types together. +There is no migration or compatibility path for earlier development deployments. Recreate development +clusters rather than decoding their former configuration and emissions rows. + +The external staking-reward message, dispatch, DClaim reward cursor/counter, T5 bonus funding action +and shortfall counter, and LIQ bonus actions/configuration are removed. Funded pre-launch imports, +LIQ earned-yield distribution and their normal claim actions remain. + +`sysio.synd::setconfig` requires `min_desyndicate` as an ordinary uint64, and `syndconfig` stores it +as an ordinary field. Bootstrap schema version 1 contains chains, tokens, LIQ pools and syndications; +it has no external-reserve or underwriting configuration. diff --git a/docs/emergency-stop-playbook.md b/docs/emergency-stop-playbook.md index a510901aad..b1fa566467 100644 --- a/docs/emergency-stop-playbook.md +++ b/docs/emergency-stop-playbook.md @@ -42,16 +42,17 @@ and `OLD_ETH_PANIC` (when the panic account is replaced), `PAUSE_BLOCK` (the Eth first `pause()`), `REQUEST_ID` (a deferred desyndication being paid), `RELAY_SIGNER` (a Solana relay signer's public key), and, for an envelope in doubt, `CHALLENGER`, `CHAIN_CODE`, `TOKEN_CODE`, `EPOCH_INDEX` and `BOND_REQUEST_ID` (see [Holding and ruling](#holding-and-ruling-steps-5-and-6)), and, -for a skipped desyndication being recredited, `HOLDER` and `QUANTITY` (see -[Reconciling](#reconciling-a-desyndication-the-outpost-did-not-pay)). +for return resolution, `REQUEST_ID` alone (see +[Reconciling](#reconciling-a-desyndication-the-outpost-did-not-pay)). Pair-level custody reconciliation +uses `CHAIN_CODE`, `TOKEN_CODE` and `REPORTED` (verified live custody in base units). Ethereum transactions below are sent with Foundry's `cast` and a keystore named with `--account` (`--ledger` works the same way). `SyndicationPool` is not deployed in any environment yet (wire-ethereum `docs/deploy.md`, Rollout); until it is, skip its lines. Every `clio get table` is written in the v6 form `clio get table -S `. The tables -read here (`sysio.andon` `cord` and `andonconfig`, `sysio.synd` `envelopes`, `ledger`, `syndcursors` and -`desyndlog`, `sysio.epoch` `epochstate`, `sysio.msgch` `envlog`) are unscoped KV tables stored under +read here (`sysio.andon` `cord`, `sysio.synd` `envelopes`, `ledger`, `syndcursors` and +`returns`, `sysio.bond` `requests`, `sysio.epoch` `epochstate`, `sysio.msgch` `envlog`) are unscoped KV tables stored under their contract's own account, so the scope is the contract account; rows come back as `{key, value}`. Depot actions signed by `sysio` are shown as `-p sysio@active`. Where `sysio` is held by a multisig, @@ -59,6 +60,11 @@ propose the same action and data with `clio multisig propose` and execute it onc ## Before an incident +Fund each designated challenger with the relevant shadow LIQ token before a freeze, enough for +the request hold bond plus the pair's `challenge_extra`. While frozen, `sysio.liq::transfer` +refuses funding an ordinary challenger account; transfers from an already funded challenger +into protocol custody remain allowed. + Each item is a deploy or upgrade step. An item not done is a freeze that cannot be pulled. 1. **Depot.** `sysio.andon` is deployed privileged before `sysio.swap`, `sysio.liq`, `sysio.bond` and @@ -227,15 +233,19 @@ Pausing a paused contract reverts (`EnforcedPause`), which is harmless: it was a | 2 | Replace the panic account in case its key is the fault ([Replacing the panic account](#replacing-the-panic-account)). | Skip. | | 3 | Record the block, the epoch and the last accepted envelope digest on each chain ([Recording](#recording-the-state-of-each-chain)). | Same. | | 4 | Read the depot's `mismatch` table and each outpost's shortfall records, and compare each outpost's custody with the depot's outstanding shadow ([Comparing totals](#comparing-totals), [A shortfall-triggered pull](#a-shortfall-triggered-pull)). | Same. No new `mismatch` row; custody covers the outstanding. | -| 5 | For each envelope in doubt, place a hold (`sysio.synd::challenge`), or rule it invalid (`sysio.bond::rslvinvalid`) if it is unbonded. | None. | +| 5 | Before each request's challenge window ends, hold every doubtful OPEN/BONDED request (`sysio.synd::challenge`) or obtain a governance invalid ruling (`sysio.bond::rslvinvalid`). | None. | | 6 | `sysio` rules each held request (`rslvvalid` / `rslvinvalid`). Invalid envelopes burn as in §4.7 once the cord clears. | None. | | 7 | Fix and redeploy the faulty contract while still frozen, in the order of [`contract-upgrade-order.md`](contract-upgrade-order.md). | None. | -| 8 | Repair custody and clear the outposts first. Have each repaired outpost emit a post-repair message (or wait for its next one); wait for depot admission, verify its sequence in `syndcursors` and no `mismatch` row for it, then clear the depot cord and do the work that waited ([Clearing](#clearing-outposts-first-then-the-depot)). | Same admission check before clearing the depot. | +| 8 | Repair custody and clear the outposts first. Have each repaired outpost emit a post-repair message (or wait for its next one); wait for depot admission, verify its admission and healthy custody report, run governance `reconcile` for every affected pair, verify its incident is erased, then clear the depot cord and do the work that waited ([Clearing](#clearing-outposts-first-then-the-depot)). | Same admission check before clearing the depot. | | 9 | Continue monitoring admitted messages from each outpost for `mismatch` rows and `SHORTFALL` ([Comparing totals](#comparing-totals)). Record earlier pre-repair sequences reporting the known shortfall as part of this incident. | Same. | | 10 | Write up the cause and what was burned or returned ([What to record](#what-to-record)). | Write up what triggered the pull. | -Challenge windows keep counting during a freeze and challenges stay open, so step 5 can be done while -frozen. A frozen queue step still issues requests, so `crank` during the freeze gives every WAITING +Challenge windows keep counting during a freeze. Read `sysio.bond::requests`: a BONDED request +becomes eligible for permissionless approval at `bonded_at + window_sec`; an OPEN request has +no running challenge deadline yet. `approve` ignores the cord. A challenge or `rslvinvalid` +still works after the deadline while the request remains OPEN/BONDED, but approval can win +that race at any time. Once APPROVED, neither intervention is allowed and held items release +after clear. Act before approval eligibility whenever possible. A frozen queue step still issues requests, so `crank` during the freeze gives every WAITING envelope a request to hold or rule. ### Holding and ruling (steps 5 and 6) @@ -243,6 +253,7 @@ envelope a request to hold or rule. ```bash clio -u "$DEPOT_URL" get table sysio.synd envelopes -S sysio.synd -r -l 50 # chain_code, token_code, epoch_index, state, request_id clio -u "$DEPOT_URL" push action sysio.synd crank '[256]' -p "$PANIC@active" # issues the requests of WAITING envelopes +clio -u "$DEPOT_URL" get table sysio.bond requests -S sysio.bond -l 100 # match request_id; state, bonded_at, window_sec; read every result page clio -u "$DEPOT_URL" push action sysio.synd challenge \ "{\"challenger\": \"$CHALLENGER\", \"chain_code\": $CHAIN_CODE, \"token_code\": $TOKEN_CODE, \"epoch_index\": $EPOCH_INDEX}" \ -p "$CHALLENGER@active" @@ -253,8 +264,7 @@ clio -u "$DEPOT_URL" push action sysio.bond rslvvalid "[$BOND_REQUEST_ID]" -p `CHAIN_CODE` and `TOKEN_CODE` are the `chain_code` and `token_code` objects exactly as the `envelopes` row prints them, `EPOCH_INDEX` its `epoch_index`, and `BOND_REQUEST_ID` its `request_id`. `challenge` accepts only an envelope in state REQUESTED, RELEASABLE or DONE whose request is OPEN or BONDED: a -WAITING envelope has no request yet, so run `crank` first. `CHALLENGER` must be an account with no -contract code, and not `sysio.synd` or `sysio.bond`. The challenger pays the request's hold bond plus the pair's `challenge_extra`; both transfers go into +WAITING envelope has no request yet, so run `crank` first. `CHALLENGER` must not be `sysio.synd` or `sysio.bond`; contract accounts are supported through ledger settlement. The challenger pays the request's hold bond plus the pair's `challenge_extra`; both transfers go into custody, so the challenge runs while the depot is frozen. `rslvinvalid` rules a request bonded or not, so an unbonded envelope in doubt is ruled invalid directly. The queue records each ruling on its next step; while the cord is pulled the burn of an INVALID envelope and the forward of a VALID ruling's hold @@ -317,11 +327,11 @@ of the token (the `sysio.liq` supply plus the yield in `liqpending`) as it admit it is written to `mismatch` and pulls the cord. Custody above it is normal and only printed as `EXCESS`. ```bash -clio -u "$DEPOT_URL" get table sysio.synd mismatch -S sysio.synd -l 100 # every shortfall: chain_code, token_code, epoch_index, sequence, kind, reported, expected, at +clio -u "$DEPOT_URL" get table sysio.synd mismatch -S sysio.synd -l 100 # latest unresolved incident per pair: chain_code, token_code, epoch_index, sequence, kind, reported, expected, at clio -u "$DEPOT_URL" get table sysio.liq stat -S sysio.liq # supply per shadow symbol clio -u "$DEPOT_URL" get table sysio.liq liqpending -S sysio.liq # released yield not yet queued clio -u "$DEPOT_URL" get table sysio.synd syndcursors -S sysio.synd # last_sequence admitted per outpost -clio -u "$DEPOT_URL" get table sysio.synd desyndlog -S sysio.synd -r -l 20 # total_syndicated each desyndication carried +clio -u "$DEPOT_URL" get table sysio.synd returns -S sysio.synd -r -l 20 # outstanding unpaid return obligations cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'poolBalanceDepot()(uint64)' cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'liqSequence()(uint64)' ``` @@ -331,23 +341,25 @@ and `status` on Solana (`liq_sequence`, the pool ATA balance). What to check: - Each `mismatch` row names the outpost, token and sequence of the message that reported the shortfall, the custody it reported and the outstanding it was compared with (`expected`). Match the sequence to the outpost's `synd` call (Solana) or `Syndicated` / yield event (Ethereum) to find the envelope. - Rows are never pruned; note the last one read, so the next reading shows only new rows. + There is one row per `(chain_code, token_code)`: each new shortfall overwrites it. Snapshot + every observed row and its admission trace externally. Only governance `reconcile` erases it; + a later healthy report does not erase the incident. - Now: each outpost's custody (the Solana pool ATA, `poolBalanceDepot`) is at least the depot's outstanding for its token (`supply` plus `liqpending`). Messages in flight only widen the margin: an - unpaid or pending desyndication, an INVALID burn and a `recredit` all leave custody above the + unpaid or pending desyndication, an INVALID burn and a request-keyed `refundreturn` all leave custody above the outstanding. - `syndcursors.last_sequence` for an outpost is at most the outpost's sequence (`liqSequence` / `liq_sequence`); the gap is messages the depot has not yet admitted, and so not yet compared. -- Every `desyndlog` request id the outpost has not paid is still in flight on the depot, stored as a +- Every `returns` request id the outpost has not paid is still in flight on the depot, stored as a pending payout on the outpost ([Deferred desyndications](#deferred-desyndications)), or skipped by the - outpost; a skipped one is reconciled by the recredit rule + outpost; a permanently cancelled one is reconciled by the return recovery rule ([Reconciling](#reconciling-a-desyndication-the-outpost-did-not-pay)). - Between two readings during a freeze, no outpost custody balance falls. ### Reconciling a desyndication the outpost did not pay -Match every `desyndlog` request id with the outpost's record of it. Each one is paid, stored or skipped +Match every `returns` request id with the outpost's record of it. Each one is paid, stored or skipped ([`sysio-synd.md`](sysio-synd.md), Desyndication): - **Paid**: Solana logs `opp_outpost: desyndicate_liq: paid ... request_id=` (wire-solana @@ -357,37 +369,51 @@ Match every `desyndlog` request id with the outpost's record of it. Each one is `desyndicationsPaid(requestId)` is true. - **Stored**: a `PendingPayout` exists at `["pending_desyndication", request_id_le8]` (`npx ts-node scripts/wire-config/emergencyStop.ts pending`), or `pendingDesyndications(requestId)` has a non-zero `depotAmount`. Pay it - ([Deferred desyndications](#deferred-desyndications)). Never recredit it. + ([Deferred desyndications](#deferred-desyndications)). Keep the depot return row; never refund a pending or still-payable payout. - **Skipped**: Solana logged `opp_outpost: DesyndicateLIQError ... request_id= err_data=` (`inbound.rs:4986-4994`) for a content or configuration refusal; Ethereum emitted `DesyndicationDropped(requestId, depotAmount, reason)` with `wrong chain`, `wrong token`, `amount out of range` or `unresolvable recipient` (wire-ethereum `contracts/outpost/SyndicationPool.sol:594-618` at d4fbb8f8). -**The recredit rule.** Governance returns the shadow of a skipped desyndication with -`sysio.liq::recredit`, and only after confirming the outpost holds no record of the request and never -paid it: +**The return recovery rule.** Read the request in `sysio.synd::returns`, which retains its original +holder, token and net burned amount. Establish the external outcome with transaction finality and +the outpost's paid/pending records. A timeout, absence of a pending record, a duplicate event or a +rejected submission alone does not prove cancellation: a queued or replayable payout may still pay. + +- **Paid and final:** governance calls `finishreturn(request_id)` to erase the depot obligation; + it mints nothing. +- **Pending, stored or still payable:** keep the depot row and pay/retry on the outpost. Never refund. +- **Irreversibly rejected or cancelled:** only after proving nothing was paid and no payable external + obligation remains, governance calls `refundreturn(request_id)`. It restores exactly the net burned + amount to the original holder and erases the row atomically. A duplicate refund fails. The fee + stays collected. Governance can resolve returns while the depot is frozen: restoring shadow + mints into custody accounting without paying out external assets. ```bash -# Solana: the PendingPayout PDA of REQUEST_ID must not exist -ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_CRANK_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts pending # REQUEST_ID not listed -# Ethereum: nothing stored (depotAmount 0) and never paid (false) -cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'pendingDesyndications(uint64)(address,uint64,uint8)' "$REQUEST_ID" +clio -u "$DEPOT_URL" get table sysio.synd returns -S sysio.synd -r -l 50 cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'desyndicationsPaid(uint64)(bool)' "$REQUEST_ID" -# The depot: the net amount of the request, then the recredit to the holder who signed the desyndicate -clio -u "$DEPOT_URL" get table sysio.synd desyndlog -S sysio.synd -r -l 50 # the row whose request_id is REQUEST_ID -clio -u "$DEPOT_URL" push action sysio.liq recredit "[\"$HOLDER\", \"$QUANTITY\"]" -p sysio.liq@active +cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'pendingDesyndications(uint64)(address,uint64,uint8)' "$REQUEST_ID" +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_ADMIN_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts pending +``` + +After external payment is final, acknowledge only that outcome: + +```bash +clio -u "$DEPOT_URL" push action sysio.synd finishreturn "[$REQUEST_ID]" -p sysio@active +``` + +After irreversible cancellation is independently proven, with nothing paid or still payable, refund instead: + +```bash +clio -u "$DEPOT_URL" push action sysio.synd refundreturn "[$REQUEST_ID]" -p sysio@active ``` -`recredit` is signed by `sysio.liq` itself (`contracts/sysio.liq/src/sysio.liq.cpp:86`); `QUANTITY` is the -`desyndlog` row's `amount` in the shadow symbol. A stored release is paid by the crank: recrediting it as -well pays the holder twice, and the crank's payment then takes custody down by the amount the recredit -added to the outstanding. The Ethereum drops `already paid` and `already pending` -(`SyndicationPool.sol:609-612`, `:638-641`, `:687-690`) and Solana's `DesyndicateLIQAlreadyPending` -(`inbound.rs:5361-5378`) are -re-emits of an id already paid or stored: never recredit them either. A recredit writes nothing in -`sysio.synd`; the next comparison reads the grown supply, and the custody the outpost never paid out -covers it. +`sysio.liq::recredit` is a separate privileged repair and must not recover a tracked return: it leaves +the `returns` row open, allowing a later refund to mint the same amount again. The Ethereum drops +`already paid` / `already pending` and Solana's `DesyndicateLIQAlreadyPending` are duplicate notices, +never evidence of irreversible cancellation. Retain the incident's paid/cancelled transaction evidence +externally; completed depot return rows are erased rather than archived. ### A shortfall-triggered pull @@ -405,17 +431,18 @@ may have crossed. **Depot.** A cord pulled by `sysio.synd` is identifiable in the action trace and has a reason of the form `custody shortfall seq `. Start at step 3 of [the playbook](#the-playbook): the -`mismatch` row for `(, )` has the figures (`reported`, `expected`). The message that reported -it was still held and minted, and every later message is compared too: each further shortfall adds its -own row while the cord stays pulled (`CORD ALREADY PULLED -- the shortfall is recorded` on the console). - -Repairing custody does not change a `SyndicateLIQ` or `LIQYield` already built: it still carries the -pre-repair custody. If admitted after the depot cord is cleared, that message can write a `mismatch` -row and pull the cord again. Repair custody, have the repaired outpost emit a message after the repair -(or wait for its next one), then wait for the depot to admit that message and confirm no `mismatch` -row for its sequence before clearing the depot cord. Use the `syndcursors` check under -[Clearing](#clearing-outposts-first-then-the-depot). Mismatch rows for earlier pre-repair sequences -reporting the known shortfall are expected: record them under this incident, not as a new incident. +`mismatch` row for `(chain_code, token_code)` has the figures (`reported`, `expected`). +The message that reported it was still held and minted. Each later shortfall overwrites that pair's +row, even while the cord remains pulled. Snapshot every observed overwrite and its admission trace +externally; the table is not an incident history. + +Repairing custody does not change reports already built with pre-repair custody. Such a report can +write another incident and pull the cord again. Verify admission of a post-repair report, snapshot the +latest incident, then call governance `reconcile(chain_code, token_code, reported)` with verified live +custody covering current outstanding. A healthy report does not erase the incident. Verify every +affected pair is reconciled before clearing the depot cord; a later stale shortfall requires the same +verification and reconciliation again. Use the admission checks under +[Clearing](#clearing-outposts-first-then-the-depot). Andon must be deployed before processing envelopes. A failed inline pull aborts the transaction atomically when the account or declared permission is missing. Bootstrap must @@ -472,7 +499,7 @@ raised on both sides. Recovery at v1 is a top-up. Send liqETH to the pool until covers the depot's outstanding plus every stored release (a plain transfer: a donation only raises custody), then unpause the pool and pay the stored releases. Have the repaired outpost emit a post-repair message (or wait for its next one), wait for depot admission and confirm no `mismatch` row -for it as below; only then clear the depot cord. A governance +for the pair after governance reconciliation below; only then clear the depot cord. A governance write-down of the depot's outstanding instead is pending the owner's ruling. ## Clearing: outposts first, then the depot @@ -517,15 +544,30 @@ clio -u "$DEPOT_URL" get table sysio.synd mismatch -S sysio.synd ``` For the repaired outpost's `chain_code`, wait until `syndcursors.last_sequence` reaches the post-repair -message's `sequence`, and confirm there is no `mismatch` row with that `(chain_code, sequence)`. +message's `sequence`. Snapshot the current incident for each `(chain_code, token_code)`; +a healthy report does not erase its earlier mismatch. Confirm the post-repair admission trace has no +new shortfall. + Read all result pages when checking these tables. Confirm the message was actually admitted: if the cursor has already advanced past it, a lower-sequence message can be dropped as a replay, so the cursor alone plus an absent mismatch is not proof that this message passed the custody check. Use its admission trace, or observe a later post-repair message's admission and check that sequence. A mismatch on the post-repair message means recovery is not complete; investigate before clearing. -Earlier pre-repair sequences can add mismatch rows while waiting: retain and record them as evidence -of this incident, not as a new incident. Once the post-repair admission check passes for every repaired -outpost, clear the depot cord: +Earlier pre-repair sequences can overwrite the pair's mismatch while waiting: snapshot each one +externally. After a confirmed healthy post-repair admission, set `CHAIN_CODE` and `TOKEN_CODE` to the +objects printed by the row and `REPORTED` to independently verified live custody in base units: + +```bash +clio -u "$DEPOT_URL" push action sysio.synd reconcile \ + "{\"chain_code\": $CHAIN_CODE, \"token_code\": $TOKEN_CODE, \"reported\": $REPORTED}" -p sysio@active +clio -u "$DEPOT_URL" get table sysio.synd mismatch -S sysio.synd +``` + +`reconcile` requires reported custody to cover current outstanding shadow and erases only this +pair's incident. It is a trusted governance attestation, not an on-chain external measurement, and +does not clear the cord. Repeat for every affected pair. Any newly admitted shortfall requires another +repair; read all table pages and ensure every affected pair's incident is absent before clearing. +Once these checks pass for every repaired outpost, clear the depot cord: ```bash clio -u "$DEPOT_URL" push action sysio.andon clear "[\"$NOTE\"]" -p sysio.andon@clear @@ -557,7 +599,7 @@ A desyndication an outpost stored instead of paying (while it was frozen, on a c on a refused settlement) was burned on the depot and is held on the outpost. Neither relay pays it: paying it is this step, once its cause clears (for a freeze or a shortfall, after the outpost clears). Each record is paid exactly once; a second payment fails. Match each one against the depot's -`desyndlog` by request id. +`returns` by request id. ### Paying on Solana @@ -700,14 +742,16 @@ Keep one record per incident, real or false alarm: - the `mismatch` rows and the custody and outstanding readings of [Comparing totals](#comparing-totals), at the pull and after the first envelope from each outpost following the clear; for a custody repair, also the post-repair message's chain and sequence, emission and admission evidence, the observed `syndcursors.last_sequence`, - the absence of its `mismatch` row before clearing, and earlier pre-repair mismatch rows; + the governance reconciliation transaction, absence of each affected pair's incident before clearing, + and external snapshots of earlier pre-repair mismatch rows; - for a real incident: each envelope held or ruled, its request id and outcome, what was burned (`envelopes.burned`) and what was returned; each redeploy; - every deferred desyndication: request id, amount, reason, the transaction that paid it, and any still unpaid with its refusal; for a `CustodyShortfall` / `CUSTODY_SHORTFALL` record, the two balances it carries; -- every skipped desyndication recredited: request id, holder, quantity, and the reads that showed no - record and no payment; +- every return finished or refunded: request id, original holder, net amount, and final external + evidence proving final payment or irreversible cancellation with nothing paid and no payable + external obligation remaining; - whether the panic account was replaced, and the new one; - for a false alarm: what triggered the pull and what, if anything, should change so it does not recur. diff --git a/docs/platform-bootstrap-config.md b/docs/platform-bootstrap-config.md index 95444d6ed4..6472bda4a6 100644 --- a/docs/platform-bootstrap-config.md +++ b/docs/platform-bootstrap-config.md @@ -80,14 +80,12 @@ the 32-byte Ed25519 key on SVM, 0x-hex of the 33-byte **compressed** secp256k1 point on EVM (never the 20-byte address). Each credit mints to the account that pubkey has linked through `sysio.authex`, or parks it until the link is made. -### Defined but not consumed +### Launch schema -`bootstrap.proto` still defines `ReserveSpec`, `UwritConfig`, -`BootstrapPlatformConfig.reserves`, `BootstrapPlatformConfig.uwrit` and -`t5_reserve_allocation`, and both JSON files still carry values for them. The -contracts they targeted (`sysio.reserv`, `sysio.uwrit`) have been removed, so no -bootstrap action consumes them; only `test_bootstrap_platform_config.cpp` still -parses and validates them (V6–V9). +`ReserveSpec`, `UwritConfig`, `reserves`, `uwrit` and `t5_reserve_allocation` are removed from the +pre-launch schema. Authored JSON uses schema version 1; strict parsing rejects removed keys. +The DEX earmark alone is subtracted from the T5 allotment. Regenerate matching OPP models and +bootstrap clients for a fresh deployment. ## T5 DEX earmark @@ -127,7 +125,7 @@ validator in `test_bootstrap_platform_config.cpp` enforces: | V3 | chain codes unique; exactly one `CHAIN_KIND_WIRE` chain, code `WIRE` | | V4 | token codes unique; `chain_code` declared; `precision` ∈ 1..9 (the depot frame); native ⇔ kind `NATIVE` + empty address; non-native address well-formed for the chain kind (EVM `0x`+40 hex; SVM base58 → 32 bytes) | | V5 | exactly one native token per non-depot chain | -| V6–V9 | checks on the unconsumed `reserves` / `uwrit` / `t5_reserve_allocation` fields (see [Defined but not consumed](#defined-but-not-consumed)) | +| V6–V9 | retired with the external-reserve and underwriting configuration | | V10 | `t5_dex_allocation > 0` when any liq pool is seeded; Σ `liq_pools[].initial_wire_amount` ≤ `t5_dex_allocation` | | V11 | each liq pool references a declared `TOKEN_KIND_LIQ` token on its chain and is unique; `pair_symbol` 1..7 characters `[A-Z]`; seeds > 0; `fee ≤ 9999`; `conversion_horizon_sec > 0`; `depth_cap_bps` ∈ 1..10000; `clip_floor > 0` | | V12 | each syndication references a declared liq token on its chain; `pubkey` fits the chain family; `amount > 0` | diff --git a/docs/sysio-synd.md b/docs/sysio-synd.md index 0cbbd4e735..b9f4109858 100644 --- a/docs/sysio-synd.md +++ b/docs/sysio-synd.md @@ -261,8 +261,8 @@ outstanding down, so none of them raises a false alarm: - yield is claimed into custody before it is reported, and reported before it is released; - an envelope ruled INVALID or dropped with `dropenv` is burned on the depot (`sysio.synd.cpp:819`, `:1260`) while the outpost keeps the custody; -- a `recredit` of a skipped desyndication returns shadow the outpost never paid out (see - [The recredit rule](#desyndication)); +- a request-keyed `refundreturn` restores shadow only after definitive external cancellation (see + [Desyndication](#desyndication)); - anyone can send tokens to a custody account. Every writer of the depot outstanding is matched by custody, or is a launch or governance act: @@ -273,7 +273,8 @@ Every writer of the depot outstanding is matched by custody, or is a launch or g | `mintyield` (pending) | `:138-150` | the yield the outpost claimed into custody before it reported it | | `queueyield` | `:205` | moves pending into supply; the outstanding is unchanged | | `burn` | `:118` | lowers the outstanding: a desyndication, an INVALID ruling, `dropenv` | -| `recredit` | `:91` | governance: custody the outpost never paid out (the recredit rule) | +| `mint` (from `refundreturn`) | governance | restores a tracked return only after irreversible external cancellation; erases the return atomically | +| `recredit` | privileged repair | exceptional repair only; never recover a tracked return through this action | | `regliqpool` | `:355` | launch: the LCO liquidity, "already in outpost custody" (`:353-354`) | ### What happens on each side @@ -320,10 +321,12 @@ and the latest `(chain_code, token_code)` incident must be reconciled explicitly message's emission after repair and its actual admission. `admit_sequence` drops any sequence at or below the cursor, so a cursor already past the message plus no incident does not alone prove that message was admitted: verify its admission trace or observe a later post-repair message being admitted. -Only after this check passes for every repaired outpost may the depot cord clear. Mismatch rows for -earlier pre-repair sequences reporting the known shortfall are expected and must be recorded as part -of the existing incident, not treated as a new incident. A mismatch on the post-repair message still -requires investigation before clearing. +Governance then calls `reconcile(chain_code, token_code, reported)` for each affected pair with +verified live custody covering current outstanding shadow; snapshot the incident before this erases it. +`reconcile` leaves the cord pulled. Only after all affected incidents are erased and these admission +checks pass may the depot cord clear. A late pre-repair report can overwrite the same pair incident +and pull the cord again; snapshot its evidence and reconcile the pair again after verifying healthy +live custody. A shortfall on a post-repair report requires investigation before clearing. ### What counts as slack @@ -492,7 +495,7 @@ Envelope states: OPEN, WAITING, REQUESTED, RELEASABLE, HELD, INVALID, DONE. ## Configuration `setconfig(chain_code, token_code, synd_fee_bps, desynd_fee_bps, synd_burst, synd_refill, -desynd_burst, desynd_refill, window_sec, bounty, challenge_extra)`, signed by `sysio`. It replaces the +desynd_burst, desynd_refill, window_sec, bounty, challenge_extra, min_desyndicate)`, signed by `sysio`. It replaces the pair's whole row. | Knob | Default (no row) | Meaning | @@ -500,6 +503,7 @@ pair's whole row. | `synd_fee_bps` | 0 | fee on each released syndication tranche, at most 10000 | | `desynd_fee_bps` | 0 | fee on each desyndication, at most 10000 | | `synd_burst`, `synd_refill` | unset: nothing releases | syndication bucket size, and refill per depot epoch, in base units | +| `min_desyndicate` | explicit bootstrap value: 1,000,000 | shared gross return floor (0.001 SOL at nine decimals), intentionally arbitrary; at most a nonzero desyndication burst; rejected before moving custody | | `desynd_burst`, `desynd_refill` | unset: nothing desyndicates | desyndication bucket size and refill | | `window_sec` | 10800 | challenge window of each request; above 0 | | `bounty` | 0 | bounty posted on each request, paid from the fee pot | diff --git a/etc/config/dex/dex-config.dev.json b/etc/config/dex/dex-config.dev.json index b105b87873..13aef70947 100644 --- a/etc/config/dex/dex-config.dev.json +++ b/etc/config/dex/dex-config.dev.json @@ -1,7 +1,6 @@ { "schema_version": 1, "network": "dev-cluster", - "t5_reserve_allocation": "100000000000", "t5_dex_allocation": "20000000000", "chains": [ { "kind": "CHAIN_KIND_WIRE", "code": "WIRE", "external_chain_id": 0, @@ -52,38 +51,6 @@ "contract_address": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "is_native": false } ], - "reserves": [ - { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "ETH", - "name": "ETHEREUM-ETH/WIRE primary reserve", - "description": "Bootstrap-seeded native ETH ↔ WIRE reserve", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "USDC", - "name": "ETHEREUM-USDC/WIRE primary reserve", - "description": "Bootstrap-seeded USDC ↔ WIRE reserve (mock ERC-20)", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "USDT", - "name": "ETHEREUM-USDT/WIRE primary reserve", - "description": "Bootstrap-seeded USDT ↔ WIRE reserve (mock ERC-20)", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "SOLANA", "token_code": "SOL", - "name": "SOLANA-SOL/WIRE primary reserve", - "description": "Bootstrap-seeded native SOL ↔ WIRE reserve", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "SOLANA", "token_code": "USDCSOL", - "name": "SOLANA-USDCSOL/WIRE primary reserve", - "description": "Bootstrap-seeded USDC ↔ WIRE reserve on Solana (mock SPL)", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "SOLANA", "token_code": "USDTSOL", - "name": "SOLANA-USDTSOL/WIRE primary reserve", - "description": "Bootstrap-seeded USDT ↔ WIRE reserve on Solana (mock SPL)", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" } - ], "liq_pools": [ { "chain_code": "ETHEREUM", "token_code": "LIQETH", "pair_symbol": "LIQETHP", "name": "LIQETH/WIRE yield pool", @@ -108,9 +75,5 @@ { "chain_code": "SOLANA", "token_code": "LIQSOL", "pubkey": "11111111111111111111111111111111", "amount": "3000000000" } - ], - "uwrit": { - "fee_bps": 10, - "collateral_lock_duration_ms": "43200000" - } + ] } diff --git a/etc/config/dex/dex-config.launch.example.json b/etc/config/dex/dex-config.launch.example.json index e487aacc3e..27dbab51d3 100644 --- a/etc/config/dex/dex-config.launch.example.json +++ b/etc/config/dex/dex-config.launch.example.json @@ -1,7 +1,6 @@ { "schema_version": 1, "network": "mainnet-launch", - "t5_reserve_allocation": "10000000000000000", "chains": [ { "kind": "CHAIN_KIND_WIRE", "code": "WIRE", "external_chain_id": 0, "name": "Wire Network", "description": "WIRE depot chain" }, @@ -30,35 +29,5 @@ "precision": 9, "chain_code": "ETHEREUM", "contract_address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", "is_native": false } - ], - "reserves": [ - { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "ETH", - "name": "ETH primary reserve", - "description": "TBD-BY-ECONOMICS: placeholder amounts", - "initial_chain_amount": "1000000000000", - "initial_wire_amount": "2000000000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "USDC", - "name": "USDC primary reserve", - "description": "TBD-BY-ECONOMICS: placeholder amounts", - "initial_chain_amount": "2000000000000000", - "initial_wire_amount": "2000000000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "USDT", - "name": "USDT primary reserve", - "description": "TBD-BY-ECONOMICS: placeholder amounts", - "initial_chain_amount": "2000000000000000", - "initial_wire_amount": "2000000000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "SOLANA", "token_code": "SOL", - "name": "SOL primary reserve", - "description": "TBD-BY-ECONOMICS: placeholder amounts", - "initial_chain_amount": "10000000000000", - "initial_wire_amount": "2000000000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" } - ], - "uwrit": { - "fee_bps": 10, - "collateral_lock_duration_ms": "43200000" - } + ] } diff --git a/libraries/opp/include/sysio/opp/opp.hpp b/libraries/opp/include/sysio/opp/opp.hpp index 4533dbcbbe..6bf650614a 100644 --- a/libraries/opp/include/sysio/opp/opp.hpp +++ b/libraries/opp/include/sysio/opp/opp.hpp @@ -96,13 +96,11 @@ FC_REFLECT_ENUM(sysio::opp::types::AttestationType, (ATTESTATION_TYPE_OPERATORS) (ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) (ATTESTATION_TYPE_NODE_OWNER_REG) - (ATTESTATION_TYPE_STAKING_REWARD) (ATTESTATION_TYPE_STAKE_RESULT) (ATTESTATION_TYPE_ATTESTATION_PROCESSING_ERROR) (ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT) (ATTESTATION_TYPE_SWAP_REVERT) (ATTESTATION_TYPE_DEPOSIT_REVERT) - (ATTESTATION_TYPE_RESERVE_CREATE) (ATTESTATION_TYPE_RESERVE_CREATE_CANCEL) (ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED) (ATTESTATION_TYPE_RESERVE_READY) diff --git a/libraries/opp/proto/sysio/opp/attestations/attestations.proto b/libraries/opp/proto/sysio/opp/attestations/attestations.proto index ab0e071780..756c0656af 100644 --- a/libraries/opp/proto/sysio/opp/attestations/attestations.proto +++ b/libraries/opp/proto/sysio/opp/attestations/attestations.proto @@ -371,46 +371,6 @@ message NodeOwnerReg { sysio.opp.types.ChainAddress nft_address = 3; } -// 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. The amount arrives already -// WIRE-denominated; the native -> WIRE conversion and the per-staker -// proration are done outpost-side. `sysio.msgch` dispatches the per-staker -// body to `sysio.dclaim::onreward`, which credits the staker's claim ledger -// directly without re-splitting (`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 { - // Chain code emitting the reward — identifies the (chain, validator) context. - uint64 chain_code = 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; - // 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 being credited, already WIRE-denominated: the outpost - // does the native -> WIRE conversion and source-chain precision scaling, so the - // depot credits reward_amount.amount as WIRE directly. - 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 { sysio.opp.types.ChainAddress owner_address = 1; sysio.opp.types.TokenAmount amount = 2; @@ -425,67 +385,7 @@ message AttestationProcessingError { string error_message = 4; } -// --------------------------------------------------------------------------- -// Reserve creation flow (outpost-initiated, depot-matched) -// -// Lifecycle: -// 1. User calls outpost `create_reserve(...)`. Outpost takes external_token_amount -// custody, inserts a local PENDING reserve record, queues RESERVE_CREATE. -// 2. Depot receives RESERVE_CREATE → `sysio.reserv::oncrtreserve(...)` inserts -// a row with status=PENDING (creator_addr remembered for cancel refund). -// 3. Any WIRE account calls `sysio.reserv::matchreserve(...)` putting up -// requested_wire_amount WIRE → status flips to ACTIVE → RESERVE_READY queued. -// 4. Outpost dispatches RESERVE_READY → marks local reserve ACTIVE; reserve -// is now usable for swap routing. -// -// Cancel path (anywhere before match wins): -// a. Creator calls outpost `cancel_create_reserve(...)`. Outpost queues -// RESERVE_CREATE_CANCEL. DOES NOT mutate local state. -// b. Depot receives RESERVE_CREATE_CANCEL: -// - If status == PENDING: set to CANCELLED, queue RESERVE_CREATE_CANCELLED. -// - Else (race lost to match): silent no-op per -// feedback_opp_handlers_never_throw.md — no attestation emitted. -// c. Outpost dispatches RESERVE_CREATE_CANCELLED → refunds the original -// external_token_amount to creator_addr; marks local reserve CANCELLED. -// --------------------------------------------------------------------------- - -// Outpost -> Depot. Open a new PENDING reserve. -message ReserveCreate { - // (fields 1-3 + 6 retired — the reserve identity and the custodied - // amount now travel together in `external_amount`, see below.) - reserved 1, 2, 3, 6; - reserved "chain_code", "token_code", "reserve_code", "external_token_amount"; - string name = 4; - string description = 5; - uint64 requested_wire_amount = 7; - uint32 connector_weight_bps = 8; // Bancor WIRE-side weight in bps (1..9999); 10000 is rejected by oncrtreserve (zero token-side weight = dead reserve). - sysio.opp.types.ChainAddress creator_addr = 9; // refund target on cancel - // Private reserves restrict swap counterparts to reserve_codes owned by the - // same WIRE account (ownership = the authex-linked account that matches). - // Immutable after create. - bool is_private = 10; - // The creator's chain public key, used by the depot to resolve the creator's - // authex link (`sysio.authex::links.bypubkey`). EVM chains: REQUIRED — the - // 33-byte compressed secp256k1 key, verified on-outpost to derive to - // msg.sender (a 20-byte EVM address cannot be reverse-mapped to a key). - // SVM chains: may be empty — `creator_addr.address` already IS the signer's - // 32-byte ed25519 public key and the depot falls back to it. - bytes creator_pub_key = 11; - // Reserve identity — (chain_code = the outpost's own chain, reserve_code, - // amount.token_code) — plus the custodied external-token amount, in the - // depot's canonical 9-decimal frame. The outpost converts its chain-native - // escrow units at the boundary (EVM `PrecisionLib.toDepot`, SVM - // `precision::to_depot`) exactly like the swap paths; the outpost's LOCAL - // reserve record keeps the raw chain-native amount for refunds. - sysio.opp.types.ReserveAmount external_amount = 12; - // Depot-frame decimal precision of the reserve's source token side = min(native, 9): - // the outpost downscales anything above the 9-dec frame cap at its boundary - // and reports the frame precision it used here, so the depot's reserve row is - // self-describing and never assumes 9. The WIRE/target side is always the WIRE - // token's 9 (WIRE_PRECISION), so it is not carried. - uint32 source_token_precision = 13; -} - +// Retained carriers for the retired reserve cancellation/matching lifecycle. // Outpost -> Depot. Creator cancels their PENDING reserve. message ReserveCreateCancel { uint64 chain_code = 1; diff --git a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto index b46e5df1af..81e4624147 100644 --- a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto +++ b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto @@ -80,10 +80,7 @@ message TokenSpec { // anything above MAX_TOKEN_PRECISION (9), so a higher value would pass config // validation and then abort the irreversible bootstrap. A token whose native // precision exceeds the frame (ETH at 18) declares the frame value here and is - // downscaled at the outpost boundary; the cap is what keeps every uint64/int64 - // amount field in range. This value also supplies `regreserve`'s - // `source_token_precision` for any reserve pairing this token — see - // `ReserveSpec`. + // downscaled at the outpost boundary; the cap keeps amount fields in range. uint32 precision = 5; // Chain this token binds to — must reference a declared `ChainSpec.code`. string chain_code = 6; @@ -94,56 +91,6 @@ message TokenSpec { bool is_native = 8; } -// A launch reserve to seed via -// `sysio.reserv::regreserve(chain_code, token_code, reserve_code, name, -// description, initial_chain_amount, initial_wire_amount, -// source_token_precision, connector_weight_bps, is_private, owner)`. -// -// ELEVEN arguments. `source_token_precision` — the 8th — has no field in this -// message; it is the paired `TokenSpec.precision` (see `token_code` below). -// -// The WIRE side (`initial_wire_amount`) is drained from the `sysio` emissions -// treasury into reserve custody at registration time (real WIRE transfer; see -// `BootstrapPlatformConfig.t5_reserve_allocation`). -message ReserveSpec { - // Reserve codename slug, conventionally "PRIMARY". - string code = 1; - // Outpost chain this reserve lives on — references a declared non-WIRE - // `ChainSpec.code`. - string chain_code = 2; - // Token paired against WIRE — must reference a `TokenSpec` bound to - // `chain_code`. - // - // That referenced spec ALSO supplies `regreserve`'s `source_token_precision` - // argument, which has no field of its own here: it is the paired token's - // depot-frame `TokenSpec.precision` (`min(native, 9)`). Resolve - // `(chain_code, token_code)` to its `TokenSpec` and pass that precision; - // `regreserve` rejects anything above 9 (`WIRE_PRECISION`). - string token_code = 3; - // Human-readable display name. - string name = 4; - // Free-form description / provenance note. - string description = 5; - // Outpost-side token seed, in that token's subunits (quote in JSON). - uint64 initial_chain_amount = 6; - // WIRE-side seed, in WIRE subunits, drained from the earmark (quote in JSON). - uint64 initial_wire_amount = 7; - // Bancor connector weight (WIRE-side) in basis points: 1..9999; 5000 = - // 50/50 pure constant product. Honored by the weighted swap curve. 10000 is - // rejected by regreserve: it zeroes the token-side weight, so every quote is 0 - // (a dead, unpriceable reserve). - uint32 connector_weight_bps = 8; - - // Private-reserve support (live as of the reserve-and-swap beta). A private - // reserve pairs same-owner only and is excluded from WIRE-endpoint swaps. - // Post-bootstrap creation derives ownership from the authex-linked matcher; - // the bootstrap `regreserve` path seeds it directly via these two fields. - bool is_private = 9; - // Owning WIRE account name (regreserve's `owner`, a sysio::name). EMPTY for - // public reserves; REQUIRED (and a valid account name) when `is_private`. - string owner = 10; -} - // A shadow-liq yield pool to seed via // `sysio.liq::regliqpool(chain_code, token_code, pair_symbol, // initial_chain_amount, initial_wire_amount, fee, locked_shares, @@ -214,74 +161,19 @@ message SyndicationSpec { uint64 amount = 4; } -// Global swap settings applied once via `sysio.uwrit::setconfig`, which takes -// SIX arguments: `(fee_bps, collateral_lock_duration_ms, min_fromwire_amount, -// fromwire_revert_fee_bps, uwreq_pending_timeout_epochs, -// uwreq_retention_epochs)`. This message pins only the first two; a bootstrap -// caller supplies the remaining four itself, using the contract's `uw_config` -// in-struct defaults (5 WIRE floor, 500 bps revert fee, and the two uwreq -// lifecycle windows) unless it has a reason to override them. -// -// NO fee-DISTRIBUTION shares are configured here. The routing is a two-stage -// split of the network fee plus an independent per-reserve owner fee: -// -// * `fee_bps` is the NETWORK fee, taken out of the WIRE leg of every swap. -// `sysio.reserv` splits that component 50/50 between the swap's WINNING -// UNDERWRITER (claimable via `claimuwfee`) and a REWARDS POOL — the fixed -// protocol constant `sysio.reserv::FEE_UNDERWRITER_SHARE_BPS`. -// * The rewards pool then splits again by the OPTIONAL governance dial -// `sysio.reserv::reserve_config.fee_emissions_share_bps` (`reservcfg`): that -// share is transferred to the `sysio` emissions treasury, the remainder -// accrues to the rewards bucket for batch operators. Nothing seeds -// `reservcfg`, so the dial reads ZERO until `sysio.reserv::setconfig` first -// persists a row — at which default the whole pool is allocated to batch -// operators and no fee leaves `sysio.reserv`'s custody at settlement. -// * Each participating non-WIRE leg's reserve ALSO charges its own -// `owner_fee_bps` off the same WIRE leg, INDEPENDENTLY of `fee_bps` -// (`ReserveSpec` does not carry it; it is set post-bootstrap via -// `sysio.reserv::setrsvfee`). So `fee_bps` is not the whole effective swap -// fee: a chain-to-chain swap pays two owner fees plus the network fee. -message UwritConfig { - // Per-spoke depot swap fee in basis points (<= 9999; a 100% fee is rejected), - // taken out of the WIRE leg of every swap. - uint32 fee_bps = 1; - // Wall-clock collateral lock window in MILLISECONDS (> 0). Contract default - // is 43_200_000 (12h). - uint64 collateral_lock_duration_ms = 2; - - // Slots 3-5 were fee_split_winner_pct / fee_split_other_uw_pct / - // fee_split_batch_op_pct (whole-percent distribution shares) — removed. Field - // numbers stay reserved so the wire format cannot reuse them. Distribution is - // no longer configured here at all: see the two-stage split described above. - reserved 3, 4, 5; - reserved "fee_split_winner_pct", "fee_split_other_uw_pct", "fee_split_batch_op_pct"; -} - -// Top-level launch platform bootstrap configuration. One JSON instance per -// deployment. (This is the WIRE *platform* bootstrap — chains, tokens, reserves -// and global swap settings — not a DEX: it stands up the launch-day registry -// state, of which the swap reserves are one part.) +// Top-level launch chains, tokens, LIQ yield pools and syndicated positions. message BootstrapPlatformConfig { - // Schema version; bump on a breaking shape change. Must be 1. + // Launch schema version; must be 1. uint32 schema_version = 1; // Deployment label ("mainnet-launch" / "dev-cluster"); the bootstrap tool // sanity-checks it against its target. string network = 2; - // WIRE subunits earmarked from the T5 allotment to back the WIRE side of - // launch reserves. Sits OUTSIDE `emission_config.t5_distributable`: at - // genesis, t5_distributable = T5_allotment - t5_reserve_allocation - - // t5_dex_allocation, so the sysio.epoch emissions readiness gate never sees a - // short treasury balance after `regreserve` drains the reserve backing. - // Invariant: sum(reserves[].initial_wire_amount) <= t5_reserve_allocation. - uint64 t5_reserve_allocation = 3; - // WIRE subunits earmarked from the T5 allotment to back the WIRE side of the // launch shadow-liq yield pools (`liq_pools`), drained by - // `sysio.liq::regliqpool`. Like `t5_reserve_allocation`, this sits OUTSIDE - // `t5_distributable`, so the total T5 carve-out is - // `t5_reserve_allocation + t5_dex_allocation`; the figure is set + // `sysio.liq::regliqpool`. This sits OUTSIDE `t5_distributable`; + // the figure is set // per-deployment (Kyle / Ken own the launch number). // Invariant: sum(liq_pools[].initial_wire_amount) <= t5_dex_allocation. uint64 t5_dex_allocation = 8; @@ -290,10 +182,6 @@ message BootstrapPlatformConfig { repeated ChainSpec chains = 4; // Tokens to register, each with its single chain binding. repeated TokenSpec tokens = 5; - // Reserves to seed (WIRE side drained from the earmark). - repeated ReserveSpec reserves = 6; - // Global swap / underwriting settings. - UwritConfig uwrit = 7; // Shadow-liq yield pools to seed, one per liq token (WIRE side drained from // `t5_dex_allocation`). repeated LiqPoolSpec liq_pools = 9; diff --git a/libraries/opp/proto/sysio/opp/types/types.proto b/libraries/opp/proto/sysio/opp/types/types.proto index 6b6402a4dc..c181e2f4fc 100644 --- a/libraries/opp/proto/sysio/opp/types/types.proto +++ b/libraries/opp/proto/sysio/opp/types/types.proto @@ -215,8 +215,7 @@ message Reserve { // canonical 9-decimal frame: outpost-side producers convert chain-native // units at the boundary (EVM `PrecisionLib.toDepot`, SVM // `precision::to_depot`) before shipping, exactly like the swap-request -// paths. Carried by RESERVE_CREATE (custodied external amount), -// RESERVE_BALANCE_SHEET (per-reserve balance entries), and SLASH routing +// paths. Carried by RESERVE_BALANCE_SHEET (per-reserve balance entries) and SLASH routing // (seized-funds destination). message ReserveAmount { uint64 chain_code = 1; @@ -256,7 +255,6 @@ enum AttestationType { // 60948 was ATTESTATION_TYPE_REMIT_CONFIRM — removed; do not reuse. ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS = 60943; ATTESTATION_TYPE_NODE_OWNER_REG = 60949; - ATTESTATION_TYPE_STAKING_REWARD = 60950; ATTESTATION_TYPE_STAKE_RESULT = 60951; ATTESTATION_TYPE_ATTESTATION_PROCESSING_ERROR = 60952; @@ -270,9 +268,8 @@ enum AttestationType { // --------------------------------------------------------------------------- // Reserve-flow attestations (data-model refactor, 2026-05-19) - // Outpost-initiated reserve creation with depot-side matching handshake. + // Retained cancellation and matching carriers for the retired reserve flow. // --------------------------------------------------------------------------- - ATTESTATION_TYPE_RESERVE_CREATE = 60958; // 0xEE1E outpost → depot ATTESTATION_TYPE_RESERVE_CREATE_CANCEL = 60959; // 0xEE1F outpost → depot ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED = 60960; // 0xEE20 depot → outpost ATTESTATION_TYPE_RESERVE_READY = 60961; // 0xEE21 depot → outpost diff --git a/libraries/opp/test/test_bootstrap_platform_config.cpp b/libraries/opp/test/test_bootstrap_platform_config.cpp index 9a3348a6d5..cbd19e75d7 100644 --- a/libraries/opp/test/test_bootstrap_platform_config.cpp +++ b/libraries/opp/test/test_bootstrap_platform_config.cpp @@ -29,7 +29,6 @@ #include #include #include -#include #include namespace gpb = google::protobuf; @@ -108,18 +107,6 @@ bool pair_symbol_ok(const std::string& s) { return true; } -/// Lightweight Antelope account-name check for the private-reserve `owner`. -/// The contract is the authority; this catches gross authoring mistakes -/// (charset `.a-z1-5`, non-empty, <= 13 chars). -bool account_name_ok(const std::string& s) { - if (s.empty() || s.size() > 13) return false; - for (char c : s) { - const bool ok = (c >= 'a' && c <= 'z') || (c >= '1' && c <= '5') || c == '.'; - if (!ok) return false; - } - return true; -} - /// Validate a parsed config against the launch invariants V1..V13. Returns a /// list of human-readable failures (empty == valid). std::vector validate(const BootstrapPlatformConfig& c) { @@ -147,7 +134,6 @@ std::vector validate(const BootstrapPlatformConfig& c) { // tokens: V2 slug, V4 binding + precision + address well-formedness std::set token_codes; std::map native_per_chain; - std::set> bindings; std::set> liq_bindings; // the TOKEN_KIND_LIQ subset for (const auto& t : c.tokens()) { if (!slug_ok(t.code())) e.push_back("V2 token code: " + t.code()); @@ -165,7 +151,6 @@ std::vector validate(const BootstrapPlatformConfig& c) { // precision exceeds the frame (ETH at 18) declares min(native, 9) and is // downscaled at the outpost boundary. if (t.precision() < 1 || t.precision() > 9) e.push_back("V4 token precision: " + t.code()); - bindings.insert({t.chain_code(), t.code()}); if (t.is_native()) { ++native_per_chain[t.chain_code()]; if (t.kind() != TokenKind::TOKEN_KIND_NATIVE || !t.contract_address().empty()) @@ -183,48 +168,6 @@ std::vector validate(const BootstrapPlatformConfig& c) { if (ch.kind() != ChainKind::CHAIN_KIND_WIRE && native_per_chain[ch.code()] != 1) e.push_back("V5 expected exactly one native token on chain " + ch.code()); - // reserves: V6 uniqueness/binding/weight/amount, V7 earmark, V8 owner - std::set> triples; - unsigned __int128 sum_wire = 0; - for (const auto& r : c.reserves()) { - if (!slug_ok(r.code())) e.push_back("V2 reserve code: " + r.code()); - const auto key = std::make_tuple(r.chain_code(), r.token_code(), r.code()); - if (!triples.insert(key).second) - e.push_back("V6 duplicate reserve: " + r.chain_code() + "/" + r.token_code() + "/" + r.code()); - if (!bindings.count({r.chain_code(), r.token_code()})) - e.push_back("V6 reserve references undeclared binding: " + r.chain_code() + "/" + r.token_code()); - const auto it = chain_kind.find(r.chain_code()); - if (it != chain_kind.end() && it->second == ChainKind::CHAIN_KIND_WIRE) - e.push_back("V6 reserve cannot live on the depot chain"); - // 1..9999 only: 10000 zeroes the token-side weight (dead reserve) and is - // rejected on-chain by regreserve/oncrtreserve (MAX_CONNECTOR_WEIGHT_BPS). - if (!(r.connector_weight_bps() > 0 && r.connector_weight_bps() <= 9999)) - e.push_back("V6 connector_weight_bps out of range"); - if (r.initial_chain_amount() == 0 || r.initial_wire_amount() == 0) - e.push_back("V6 reserve amounts must be > 0"); - sum_wire += r.initial_wire_amount(); - if (r.is_private() && !account_name_ok(r.owner())) - e.push_back("V8 private reserve needs a valid owner account name"); - if (!r.is_private() && !r.owner().empty()) - e.push_back("V8 public reserve must not name an owner"); - } - - // V7 — WIRE earmark covers the reserve WIRE sides - if (c.t5_reserve_allocation() == 0) e.push_back("V7 t5_reserve_allocation must be > 0"); - if (sum_wire > static_cast(c.t5_reserve_allocation())) - e.push_back("V7 sum(initial_wire_amount) exceeds t5_reserve_allocation"); - - // V9 — uwrit config - if (!c.has_uwrit()) { - e.push_back("V9 uwrit config missing"); - } else { - const auto& u = c.uwrit(); - // fee_bps <= 9999: a 10000 (100%) fee zeroes the post-fee WIRE leg and is - // rejected on-chain by sysio.uwrit::setconfig (MAX_FEE_BPS). - if (u.fee_bps() > 9999) e.push_back("V9 fee_bps > 9999 (100%)"); - if (u.collateral_lock_duration_ms() == 0) e.push_back("V9 collateral_lock_duration_ms must be > 0"); - } - // liq pools: V11 binding / uniqueness / parameters, V10 earmark std::map, unsigned __int128> pool_seed; unsigned __int128 sum_pool_wire = 0; @@ -296,7 +239,6 @@ BOOST_AUTO_TEST_CASE(launch_example_parses_and_validates) { BOOST_REQUIRE_MESSAGE(parse_strict(slurp(CONFIG_DIR + "/dex-config.launch.example.json"), cfg, err), err); for (const auto& v : validate(cfg)) BOOST_ERROR(v); BOOST_CHECK_EQUAL(cfg.chains_size(), 3); - BOOST_CHECK_EQUAL(cfg.reserves_size(), 4); } /// The dev-cluster mirror parses strictly and satisfies every invariant. @@ -306,7 +248,6 @@ BOOST_AUTO_TEST_CASE(dev_config_parses_and_validates) { BOOST_REQUIRE_MESSAGE(parse_strict(slurp(CONFIG_DIR + "/dex-config.dev.json"), cfg, err), err); for (const auto& v : validate(cfg)) BOOST_ERROR(v); BOOST_CHECK_EQUAL(cfg.tokens_size(), 9); - BOOST_CHECK_EQUAL(cfg.reserves_size(), 6); // the two liq tokens are pools, not reserves BOOST_CHECK_EQUAL(cfg.liq_pools_size(), 2); BOOST_CHECK_EQUAL(cfg.syndications_size(), 3); } @@ -337,6 +278,20 @@ BOOST_AUTO_TEST_CASE(strict_parse_rejects_unknown_field) { BOOST_CHECK(!parse_strict(bad, cfg, err)); } +/// Retired configuration keys must fail strict parsing instead of being silently ignored. +BOOST_AUTO_TEST_CASE(strict_parse_rejects_removed_fields_and_unsupported_version) { + for (const char* field : {R"("reserves": [])", R"("uwrit": {})", R"("t5_reserve_allocation": "1")"}) { + BootstrapPlatformConfig cfg; + std::string err; + BOOST_CHECK(!parse_strict(std::string{R"({"schema_version":1,"network":"x",)"} + field + "}", cfg, err)); + } + BootstrapPlatformConfig cfg; + std::string err; + BOOST_REQUIRE(parse_strict(slurp(CONFIG_DIR + "/dex-config.dev.json"), cfg, err)); + cfg.set_schema_version(2); + BOOST_CHECK(!validate(cfg).empty()); +} + /// Each single-field mutation of the valid dev config trips at least one /// invariant (one mutation per targeted check). BOOST_AUTO_TEST_CASE(validator_rejects_mutations) { @@ -347,15 +302,6 @@ BOOST_AUTO_TEST_CASE(validator_rejects_mutations) { { auto c = base; c.mutable_chains(1)->set_code("TOOLONG99"); // 9 chars > 8 BOOST_CHECK(!validate(c).empty()); } // V2 over-length slug - // Reserve codes get the same V2 grammar as chain and token codes; regreserve - // refuses the digit- and underscore-leading ones outright. - for (const char* bad : {"1BAD", "_BAD", ""}) { - auto c = base; c.mutable_reserves(0)->set_code(bad); - const auto errs = validate(c); - BOOST_CHECK_MESSAGE(std::any_of(errs.begin(), errs.end(), - [](const std::string& s) { return s.starts_with("V2 reserve code"); }), - std::string("reserve code should be rejected: '") + bad + "'"); - } { auto c = base; c.mutable_chains(2)->set_kind(ChainKind::CHAIN_KIND_WIRE); BOOST_CHECK(!validate(c).empty()); } // V3 two depots { auto c = base; // V5 second native on ETHEREUM @@ -375,14 +321,6 @@ BOOST_AUTO_TEST_CASE(validator_rejects_mutations) { BOOST_CHECK(!validate(c).empty()); } // V4 native ETH precision, not downscaled { auto c = base; c.mutable_tokens(0)->set_precision(9); BOOST_CHECK(validate(c).empty()); } // V4 the frame itself stays valid - { auto c = base; c.mutable_reserves(0)->set_connector_weight_bps(10000); - BOOST_CHECK(!validate(c).empty()); } // V6 weight 10000 rejected (zero token-side weight) - { auto c = base; c.set_t5_reserve_allocation(1); - BOOST_CHECK(!validate(c).empty()); } // V7 earmark too small - { auto c = base; c.mutable_reserves(0)->set_is_private(true); // V8 private without owner - BOOST_CHECK(!validate(c).empty()); } - { auto c = base; c.mutable_uwrit()->set_fee_bps(10000); - BOOST_CHECK(!validate(c).empty()); } // V9 fee_bps 10000 rejected (100% zeroes post-fee WIRE) { auto c = base; c.set_t5_dex_allocation(1); BOOST_CHECK(!validate(c).empty()); } // V10 dex earmark too small { auto c = base; c.mutable_liq_pools(0)->set_token_code("USDC"); // V11 an ERC-20 is not a liq token