diff --git a/contracts/sysio.authex/sysio.authex.wasm b/contracts/sysio.authex/sysio.authex.wasm index df1e85879b..75315b17b3 100755 Binary files a/contracts/sysio.authex/sysio.authex.wasm and b/contracts/sysio.authex/sysio.authex.wasm differ diff --git a/contracts/sysio.chalg/sysio.chalg.wasm b/contracts/sysio.chalg/sysio.chalg.wasm index 35b049021e..f68b985dc6 100755 Binary files a/contracts/sysio.chalg/sysio.chalg.wasm and b/contracts/sysio.chalg/sysio.chalg.wasm differ diff --git a/contracts/sysio.councl/sysio.councl.wasm b/contracts/sysio.councl/sysio.councl.wasm index d62880a065..d74fd7d85a 100755 Binary files a/contracts/sysio.councl/sysio.councl.wasm and b/contracts/sysio.councl/sysio.councl.wasm differ diff --git a/contracts/sysio.msig/sysio.msig.wasm b/contracts/sysio.msig/sysio.msig.wasm index 6e6aaa1366..3f7ac76512 100755 Binary files a/contracts/sysio.msig/sysio.msig.wasm and b/contracts/sysio.msig/sysio.msig.wasm differ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp index f7cfb36d71..2116ba91e2 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp @@ -172,38 +172,126 @@ inline uint64_t wire_to_token(uint64_t reserve_wire_amount, /// Basis-points denominator (10000 = 100%). inline constexpr uint32_t BPS_TOTAL = 10000; -/// Decomposition of a swap fee taken out of the WIRE leg. +/// Decomposition of EVERY fee taken out of a swap's WIRE leg. +/// +/// Two independent fees ride the same leg (WIRE-281): the NETWORK fee +/// (`sysio.uwrit::fee_bps`, split between the winning underwriter, batch +/// operators and optionally the emissions treasury) and each participating +/// RESERVE's own owner fee. A chain-to-chain swap therefore pays three fees — +/// two reserve owners plus the network — while a WIRE-endpoint swap touches one +/// reserve and pays two. struct wire_fee { - uint64_t fee = 0; ///< total fee charged (WIRE) - uint64_t reward_share = 0; ///< portion routed to the rewards bucket - uint64_t emissions_share = 0; ///< portion returned to the emissions treasury - uint64_t net = 0; ///< wire_amount - fee (continues through the swap) + uint64_t fee = 0; ///< TOTAL of every fee charged on the leg + uint64_t underwriter_share = 0; ///< network fee: the swap's winning underwriter + uint64_t reward_share = 0; ///< network fee: rewards bucket (batch operators) + uint64_t emissions_share = 0; ///< network fee: the `sysio` emissions treasury + uint64_t src_reserve_share = 0; ///< the SOURCE reserve owner's fee + uint64_t dst_reserve_share = 0; ///< the DESTINATION reserve owner's fee + uint64_t net = 0; ///< wire_amount - fee (continues through the swap) }; -/// Split `wire_amount` into fee (`fee_bps`) + remainder, then split the fee into -/// a rewards share (`reward_share_bps`) and an emissions share (the rest). All -/// integer; `reward_share + emissions_share == fee` and `net + fee == -/// wire_amount` exactly (no rounding leak). Computed in `u128` to avoid overflow. -inline wire_fee split_wire_fee(uint64_t wire_amount, uint32_t fee_bps, uint32_t reward_share_bps) { +/// The ONE fee decomposition for a swap's WIRE leg — used by BOTH the read-only +/// quote and settlement, so the two can never drift by a rounding subunit. +/// +/// Every rate is applied to the SAME gross `wire_amount`, so the fees are purely +/// additive and order-independent — a user pays `fee_bps + src + dst` of the +/// leg, and no party's cut depends on who is computed first: +/// +/// * **Reserve fees** — `src_reserve_fee_bps` / `dst_reserve_fee_bps`, each +/// the owner fee of the reserve supplying that leg (0 for a WIRE endpoint, +/// which has no reserve). Accrue to those reserves' owners. +/// * **Network fee** — `fee_bps`, itself split in two stages: +/// 1. `underwriter_share_bps` of it goes to the winning underwriter; the +/// remainder is the **rewards pool**. +/// 2. `emissions_share_bps` of that POOL (not of the whole fee) goes to the +/// `sysio` emissions treasury; the rest is the batch-operator share. +/// +/// All integer, exact, because every stage takes a REMAINDER rather than a +/// second floored product. `net + fee == wire_amount` holds UNCONDITIONALLY. +/// Five-share conservation — the shares summing to `fee` — holds only on the +/// non-clamped path, i.e. when `total_fee < wire_amount`; once the total reaches +/// the leg `fee` is clamped to `wire_amount` and the shares sum to MORE than it +/// (see the clamp note below). Computed in `u128` to avoid overflow. +/// +/// **Callers must check `net > 0`.** Stacked rates can reach or exceed the leg, +/// and that is REACHABLE UNDER VALID CONFIGURATION rather than merely +/// theoretical: the caps bound each rate INDEPENDENTLY, so a network fee at +/// `sysio.uwrit::MAX_FEE_BPS` (9999) plus a single reserve owner fee at +/// `sysio.reserv::MIN_OWNER_FEE_BPS` (1) already totals exactly 100%. A +/// zero-`net` result is therefore an intentionally REJECTED CONFIGURATION that +/// every caller must handle — not unreachable defense-in-depth. +/// +/// When the total reaches the leg, `fee` is CLAMPED to `wire_amount` and `net` +/// is 0 — so in THAT case alone the per-share fields sum to more than `fee`, +/// which is harmless because no share is ever accrued: the swap is refused +/// before any accrual. `quote_swap` returns 0 (no quote), and `applyswap` / +/// `applyfromwire` / `refundwire` assert `net > 0` directly. `paywire` is the +/// exception worth knowing: it pays the caller's `wire_out` target rather than +/// `net`, so it never reads the field — it fails closed upstream on the zero +/// quote, and locally on its `reserve_wire_amount >= wire_out + fee` +/// sufficiency check. +/// +/// Paths with no winning underwriter (a revert refund) pass 0 for +/// `underwriter_share_bps`, sending the whole network fee into the rewards pool. +/// The trailing rates default to 0, so a caller that charges only the network +/// fee — a revert, or a quote against a fee-free reserve — omits them. +inline wire_fee split_wire_fee(uint64_t wire_amount, + uint32_t fee_bps, + uint32_t underwriter_share_bps, + uint32_t emissions_share_bps = 0, + uint32_t src_reserve_fee_bps = 0, + uint32_t dst_reserve_fee_bps = 0) { wire_fee r; if (fee_bps > BPS_TOTAL) fee_bps = BPS_TOTAL; - if (reward_share_bps > BPS_TOTAL) reward_share_bps = BPS_TOTAL; - r.fee = static_cast((static_cast(wire_amount) * fee_bps) / BPS_TOTAL); - r.reward_share = static_cast((static_cast(r.fee) * reward_share_bps) / BPS_TOTAL); - r.emissions_share = r.fee - r.reward_share; - r.net = wire_amount - r.fee; + if (underwriter_share_bps > BPS_TOTAL) underwriter_share_bps = BPS_TOTAL; + if (emissions_share_bps > BPS_TOTAL) emissions_share_bps = BPS_TOTAL; + if (src_reserve_fee_bps > BPS_TOTAL) src_reserve_fee_bps = BPS_TOTAL; + if (dst_reserve_fee_bps > BPS_TOTAL) dst_reserve_fee_bps = BPS_TOTAL; + + // Each reserve owner's cut, off the gross leg. + r.src_reserve_share = static_cast((static_cast(wire_amount) * src_reserve_fee_bps) / BPS_TOTAL); + r.dst_reserve_share = static_cast((static_cast(wire_amount) * dst_reserve_fee_bps) / BPS_TOTAL); + + // The network fee, off the same gross leg, then its own two-stage split. + const uint64_t network_fee = static_cast((static_cast(wire_amount) * fee_bps) / BPS_TOTAL); + r.underwriter_share = static_cast((static_cast(network_fee) * underwriter_share_bps) / BPS_TOTAL); + const uint64_t rewards_pool = network_fee - r.underwriter_share; + r.emissions_share = static_cast((static_cast(rewards_pool) * emissions_share_bps) / BPS_TOTAL); + r.reward_share = rewards_pool - r.emissions_share; + + // Sum in u128. Three stacked rates carry past uint64 on an extreme leg, and a + // WRAPPED total is worse than a saturated one: it reports a small positive + // `net` for a swap that consumed the whole leg, so the caller's `net > 0` + // gate waves it through. Compare in u128 and narrow only when the total + // genuinely fits; otherwise clamp to the leg so a fully-consumed leg is + // reported as exactly that. + const u128 total_fee = static_cast(network_fee) + + static_cast(r.src_reserve_share) + + static_cast(r.dst_reserve_share); + if (total_fee >= static_cast(wire_amount)) { + r.fee = wire_amount; + r.net = 0; + } else { + r.fee = static_cast(total_fee); + r.net = wire_amount - r.fee; + } return r; } /// Post-fee swap quote along the depot curve. A WIRE endpoint (`src_is_wire` / /// `dst_is_wire`) skips that side's reserve — the depot IS the WIRE side. For a -/// non-WIRE side pass that reserve's `(chain_amount, wire_amount, cw)`. The fee -/// (`fee_bps`) is charged on the WIRE leg. Returns the post-fee output the -/// recipient could receive, or 0 on degenerate input. Mirrors settlement -/// exactly so quotes and books agree. +/// non-WIRE side pass that reserve's `(chain_amount, wire_amount, cw)` AND its +/// `owner_fee_bps`; a WIRE endpoint has no reserve, so pass 0 for its fee. +/// +/// Every fee — the network `fee_bps` plus each participating reserve's owner fee +/// — is charged on the WIRE leg through the SAME `split_wire_fee` the settlement +/// paths use, so quotes and books agree by construction. Returns the post-fee +/// output the recipient could receive, or 0 on degenerate input (including a +/// stacked-fee combination that leaves nothing). inline uint64_t quote_swap(bool src_is_wire, uint64_t src_chain, uint64_t src_wire, uint32_t src_cw, bool dst_is_wire, uint64_t dst_chain, uint64_t dst_wire, uint32_t dst_cw, - uint64_t amount_in, uint32_t fee_bps) { + uint64_t amount_in, uint32_t fee_bps, + uint32_t src_reserve_fee_bps = 0, uint32_t dst_reserve_fee_bps = 0) { if (amount_in == 0) return 0; if (src_is_wire && dst_is_wire) return amount_in; // WIRE->WIRE is a plain transfer @@ -212,7 +300,13 @@ inline uint64_t quote_swap(bool src_is_wire, uint64_t src_chain, uint64_t src_wi : token_to_wire(src_chain, src_wire, src_cw, amount_in); if (wire_leg == 0) return 0; - const uint64_t wire_net = split_wire_fee(wire_leg, fee_bps, /*reward_share_bps*/0).net; + // A WIRE endpoint has no reserve on that side and therefore charges no + // reserve fee, whatever the caller passed. + const uint64_t wire_net = split_wire_fee(wire_leg, fee_bps, /*underwriter_share_bps*/0, + /*emissions_share_bps*/0, + src_is_wire ? 0 : src_reserve_fee_bps, + dst_is_wire ? 0 : dst_reserve_fee_bps).net; + if (wire_net == 0) return 0; if (dst_is_wire) return wire_net; // user receives WIRE directly return wire_to_token(dst_wire, dst_chain, dst_cw, wire_net); } diff --git a/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp b/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp index 3adba3cda5..caa25eef30 100644 --- a/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp +++ b/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp @@ -81,13 +81,43 @@ namespace sysio { // this constant is the write/validation precision for every reserve. static constexpr uint32_t WIRE_PRECISION = 9; - // Swap-fee split. Every swap charges sysio.uwrit's `fee_bps` out of the - // WIRE leg; this contract routes the collected fee 50/50 — half accrues to - // the on-chain `rewards_bucket` (kept in this contract's WIRE custody for a - // later distribution action), half is transferred back to the `sysio` - // emissions treasury. The fee RATE (`fee_bps`) is owned by sysio.uwrit. - static constexpr uint32_t FEE_REWARD_SHARE_BPS = 5000; // 50% rewards / 50% emissions - static constexpr uint32_t FEE_SPLIT_TOTAL_BPS = 10000; + // Swap-fee split, stage 1. Every swap charges sysio.uwrit's `fee_bps` out + // of the WIRE leg; this contract gives half of the collected fee to the + // swap's WINNING UNDERWRITER (the party whose collateral lock let the swap + // settle) and puts the other half into a REWARDS POOL. The fee RATE + // (`fee_bps`) is owned by sysio.uwrit. + static constexpr uint32_t FEE_UNDERWRITER_SHARE_BPS = 5000; // 50% underwriter / 50% rewards pool + static constexpr uint32_t FEE_SPLIT_TOTAL_BPS = 10000; + + // Swap-fee split, stage 2 — the rewards pool's own division, GOVERNANCE + // CONFIGURABLE (`reserve_config.fee_emissions_share_bps`). This share of + // the pool is transferred to the `sysio` emissions treasury; the remainder + // accrues to `rewards_bucket` for batch operators via + // `sysio.system::payepoch`. + // + // Nothing seeds the `reservcfg` row: bootstrap does not write it, and every + // read goes through `get_or_default(reserve_config{})`, so the dial reads + // as zero until the self-authorized `setconfig` first persists a row. + // + // The DEFAULT IS ZERO: the whole pool is allocated to the batch-operator + // distribution and no part of a swap fee leaves this contract's custody at + // settlement. (`payepoch` then pays only eligible shares — see + // `drainrewards`.) A non-zero share re-opens a treasury inflow without + // touching the underwriter half. + static constexpr uint32_t DEFAULT_FEE_EMISSIONS_SHARE_BPS = 0; + + // Reserve OWNER fee bounds (WIRE-281). A reserve's owner fee is a second, + // independent fee on the WIRE leg — it is NOT a share of the network fee. + // + // 0 means "this reserve charges nothing" and is always legal (every + // reserve starts there, and an owner-less bootstrap reserve can never be + // anything else — `setrsvfee` requires the owner's authority). A reserve + // that DOES charge must land in [MIN_OWNER_FEE_BPS, MAX_OWNER_FEE_BPS]: + // below the floor the fee floors to zero on ordinary amounts and is just a + // misconfiguration, and the 99% ceiling keeps a positive remainder on the + // leg (Jonathan, 2026-08-04). + static constexpr uint32_t MIN_OWNER_FEE_BPS = 1; // 0.01% + static constexpr uint32_t MAX_OWNER_FEE_BPS = 9900; // 99% // ----------------------------------------------------------------------- // Actions @@ -180,8 +210,16 @@ namespace sysio { /// Read-only swap quote. Prices `from_amount` of the source reserve into /// the destination reserve along the depot's live curve — the SAME /// weighted-Bancor math (each reserve's `connector_weight_bps`) and the - /// SAME post-fee reduction (`sysio.uwrit::fee_bps` out of the WIRE leg) - /// that settlement uses, so the quote equals what a swap would deliver. + /// SAME post-fee reduction that settlement uses, so the quote equals what a + /// swap would deliver. + /// + /// The fee priced here is the network fee (`sysio.uwrit::fee_bps`) PLUS + /// every participating non-WIRE leg's reserve `owner_fee_bps` — both sides + /// on a chain-to-chain swap, one side against a WIRE endpoint (a WIRE + /// endpoint has no reserve and charges no owner fee). Callers drive + /// ingestion and race-time variance checks off this value, so it must + /// account for every rate settlement will charge; a quote that priced only + /// `fee_bps` would drift from the books by each owner fee. /// Handles WIRE endpoints: a WIRE source/destination skips that leg's /// reserve (the depot IS the WIRE side). Returns 0 when a required reserve /// is missing or not ACTIVE (callers treat 0 as "no quote"). @@ -194,23 +232,32 @@ namespace sysio { sysio::slug_name to_token_code, sysio::slug_name to_reserve_code); - /// Read-only: current rewards-bucket WIRE balance (the rewards half of - /// collected swap fees, held in this contract's custody until `drainrewards` - /// sweeps it to the emissions treasury for distribution). + /// Read-only: current rewards-bucket WIRE balance (the batch-operator share + /// of collected swap fees, held in this contract's custody until + /// `drainrewards` sweeps it to the emissions treasury for distribution to + /// batch operators). [[sysio::action, sysio::read_only]] uint64_t rewardbal(); /// Auth = `sysio` (the emissions treasury / system account). Sweep `amount` /// WIRE of accrued swap-fee rewards out of this contract's custody to the - /// `sysio` treasury, where `sysio.system::payepoch` folds it into the - /// per-epoch compute distribution to producers + batch operators. Called + /// `sysio` treasury, where `sysio.system::payepoch` allocates it EXCLUSIVELY + /// to the batch-operator distribution, on top of their emission share. + /// Producers are not paid out of swap fees — the fee compensates the parties + /// that carry an individual swap (the winning underwriter, whose share never + /// enters this bucket, and the batch operators that relay it). Allocated is + /// not the same as paid: payepoch pays only ELIGIBLE shares, and whatever it + /// skips (no groups at all, an EMPTY group holding positive epochs, + /// non-ACTIVE members, or the remainders of its two integer divisions) stays + /// in the treasury. Called /// inline by payepoch with the amount it read from `rewardbal()`, so the /// swept WIRE lands in the treasury before payepoch's payout transfers /// execute (inline actions run depth-first, drain queued before payouts). /// /// Decrements `rewards_bucket.balance` by `amount`; `lifetime_accrued` - /// (an audit total) is left untouched. `amount <= 0` is a defensive no-op; - /// `amount` exceeding the live balance throws (a bug in the caller). + /// (an audit total) is left untouched. A non-positive `amount` is REJECTED + /// (throws — an internal sweep asking for <= 0 is a caller bug, not a + /// no-op), as is an `amount` exceeding the live balance. [[sysio::action]] void drainrewards(int64_t amount); @@ -239,10 +286,20 @@ namespace sysio { /// — takes the swap fee out of that WIRE leg, then: /// src: chain += src_amount, wire -= w_gross /// dst: wire += w_net, chain -= dst_amount (w_net = w_gross - fee) - /// The fee is routed 50/50 to the rewards bucket / `sysio` emissions. - /// Balances are checked BEFORE any mutation; a failed check aborts the - /// surrounding race-resolution transaction (no half-state). `Σ - /// reserve_wire_amount` drops by the fee (which leaves the reserve pair). + /// `fee` is the TOTAL taken off that leg: BOTH reserves' `owner_fee_bps` + /// (each accrued to its own reserve row's `owner_fee_accrued`) PLUS the + /// network fee. Only the NETWORK component is split 50/50 to + /// `underwriter`'s claimable accrual / the rewards pool — and any + /// configured `fee_emissions_share_bps` of that pool is TRANSFERRED to the + /// `sysio` treasury rather than accruing to `rewards_bucket`. + /// Balances are checked BEFORE any mutation; a failed check + /// aborts the surrounding race-resolution transaction (no half-state). `Σ + /// reserve_wire_amount` drops by the whole fee; all of it stays in this + /// contract's custody as the three accruals EXCEPT that emissions share, + /// which is the only part that leaves. + /// + /// `underwriter` is the uwreq's winning underwriter, forwarded by + /// `sysio.uwrit::try_select_winner`. [[sysio::action]] void applyswap(sysio::slug_name src_chain_code, sysio::slug_name src_token_code, @@ -251,7 +308,8 @@ namespace sysio { sysio::slug_name dst_chain_code, sysio::slug_name dst_token_code, sysio::slug_name dst_reserve_code, - uint64_t dst_amount); + uint64_t dst_amount, + sysio::name underwriter); /// Auth=sysio.uwrit. Emit-time apply for a swap-FROM-WIRE (the depot /// is the source; only the target outpost leg exists). The user's @@ -259,15 +317,24 @@ namespace sysio { /// the swap fee taken out of it; only the post-fee remainder becomes the /// target reserve's WIRE-side liquidity: /// dst: wire += w_net, chain -= dst_amount (w_net = wire_in - fee) - /// The fee is routed 50/50 to the rewards bucket / `sysio` emissions. The - /// escrowed `wire_in` splits into that liquidity plus the routed fee, so - /// custody stays balanced. + /// `fee` is the TOTAL taken off the leg: the DESTINATION reserve's + /// `owner_fee_bps` (accrued to that row; there is no source reserve) PLUS + /// the network fee. Only the NETWORK component is split 50/50 to + /// `underwriter`'s claimable accrual / the rewards pool, and any configured + /// `fee_emissions_share_bps` of that pool is TRANSFERRED to the `sysio` + /// treasury. The escrowed `wire_in` splits into the new liquidity plus the + /// routed fee; every part stays in custody except that emissions share, so + /// custody balances once it is accounted for. + /// + /// `underwriter` is the uwreq's winning underwriter, forwarded by + /// `sysio.uwrit::try_select_winner`. [[sysio::action]] void applyfromwire(sysio::slug_name dst_chain_code, sysio::slug_name dst_token_code, sysio::slug_name dst_reserve_code, uint64_t wire_in, - uint64_t dst_amount); + uint64_t dst_amount, + sysio::name underwriter); /// Auth=sysio.uwrit. Settlement for a swap-TO-WIRE (the depot is the /// target; only the source outpost leg exists). Pays the recipient exactly @@ -275,16 +342,26 @@ namespace sysio { /// weighted WIRE leg the source produces: /// src: chain += src_amount, wire -= (wire_out + fee) /// inline sysio.token::transfer(sysio.reserv → recipient, wire_out) - /// The fee is routed 50/50 to the rewards bucket / `sysio` emissions; the - /// source reserve keeps any surplus when the user targeted below the - /// post-fee quote. `Σ reserve_wire_amount` drops by `wire_out + fee`. + /// `fee` is the TOTAL taken off the leg: the SOURCE reserve's + /// `owner_fee_bps` (accrued to that row; the recipient is paid in WIRE, so + /// there is no destination reserve) PLUS the network fee. Only the NETWORK + /// component is split 50/50 to `underwriter`'s claimable accrual / the + /// rewards pool, and any configured `fee_emissions_share_bps` of that pool + /// is TRANSFERRED to the `sysio` treasury. The source reserve keeps any + /// surplus when the user targeted below the post-fee quote. `Σ + /// reserve_wire_amount` drops by `wire_out + fee`; what leaves custody is + /// `wire_out` plus that emissions share, nothing else. + /// + /// `underwriter` is the uwreq's winning underwriter, forwarded by + /// `sysio.uwrit::try_select_winner`. [[sysio::action]] void paywire(sysio::slug_name src_chain_code, sysio::slug_name src_token_code, sysio::slug_name src_reserve_code, uint64_t src_amount, sysio::name recipient, - uint64_t wire_out); + uint64_t wire_out, + sysio::name underwriter); /// Auth=sysio.uwrit. Refund escrowed WIRE to a swap-FROM-WIRE user /// whose queued request failed drain-time validation (reserve @@ -293,16 +370,89 @@ namespace sysio { /// any `reserve_wire_amount`. /// /// `revert_fee_bps` is the caller-fault revert fee: the recipient gets - /// `wire_amount` minus the fee and the fee routes through the standard - /// rewards/emissions split (`route_wire_fee`), exactly like settlement - /// fees. Pass 0 for no-fault refunds (whole fee path no-ops). Callers - /// keep it below 100% (`sysio.uwrit::MAX_FEE_BPS`) so the post-fee - /// refund transfer stays positive. + /// `wire_amount` minus the fee, and the fee routes through the standard + /// `route_wire_fee` path. A revert has no winning underwriter — nobody + /// locked collateral for a swap that never settled — so the underwriter + /// share is zero and the WHOLE revert fee becomes the rewards POOL. That + /// pool is then split by `fee_emissions_share_bps` exactly like a + /// settlement fee's: under the default zero dial the entire revert fee + /// accrues to `rewards_bucket`, and with a configured dial that share is + /// transferred to the `sysio` emissions treasury instead. Pass 0 for + /// no-fault refunds (whole fee path no-ops). Callers keep it below 100% + /// (`sysio.uwrit::MAX_FEE_BPS`) so the post-fee refund transfer stays + /// positive. [[sysio::action]] void refundwire(sysio::name recipient, uint64_t wire_amount, uint32_t revert_fee_bps); + /// Auth = self (`sysio.reserv`). Set the contract's fee-routing config. + /// + /// * `fee_emissions_share_bps` — the share of each fee's REWARDS POOL + /// (the half left after the winning underwriter's cut) transferred to + /// the `sysio` emissions treasury; the remainder accrues to + /// `rewards_bucket` for batch operators. 0 (the default) allocates the + /// whole pool to the batch-operator distribution and keeps every fee + /// inside this contract's custody at settlement. Capped at + /// `FEE_SPLIT_TOTAL_BPS`. + [[sysio::action]] + void setconfig(uint32_t fee_emissions_share_bps); + + /// Auth = the reserve's `owner`. Set this reserve's owner fee — the + /// independent, per-reserve fee its liquidity earns on every swap that + /// draws from it (WIRE-281). `owner_fee_bps` is either 0 (charge nothing) + /// or in `[MIN_OWNER_FEE_BPS, MAX_OWNER_FEE_BPS]`. + /// + /// Requires an ACTIVE reserve with a resolved `owner`: a bootstrap-seeded + /// public reserve has no owner, so no account can authorize a fee on it + /// and none can be stranded with no claimant. Re-callable — the fee is a + /// live parameter, not a create-time constant. + [[sysio::action]] + void setrsvfee(sysio::slug_name chain_code, + sysio::slug_name token_code, + sysio::slug_name reserve_code, + uint32_t owner_fee_bps); + + /// Read-only: one reserve's unclaimed owner-fee WIRE balance, held in this + /// contract's custody until `claimrsvfee`. Zero for a reserve that has + /// never charged. + [[sysio::action, sysio::read_only]] + uint64_t rsvfeebal(sysio::slug_name chain_code, + sysio::slug_name token_code, + sysio::slug_name reserve_code); + + /// Auth = the reserve's `owner`. Pay out this reserve's entire accrued + /// owner fee as REAL WIRE from custody and zero the accrual; + /// `owner_fee_lifetime` (an audit total) is untouched. + /// + /// Owner-authenticated and self-serve, exactly like `claimuwfee` — the + /// depot never pushes these payouts. Throws when the reserve is missing, + /// has no owner, or has nothing accrued: a claim with nothing to pay is a + /// caller mistake, not a silent no-op. + [[sysio::action]] + void claimrsvfee(sysio::slug_name chain_code, + sysio::slug_name token_code, + sysio::slug_name reserve_code); + + /// Read-only: `underwriter`'s unclaimed swap-fee WIRE balance (the + /// underwriter half of every fee their winning commits settled), held in + /// this contract's custody until `claimuwfee`. Zero for an underwriter + /// with no accrual row. + [[sysio::action, sysio::read_only]] + uint64_t uwfeebal(sysio::name underwriter); + + /// Auth = `underwriter` (the earner). Pay out the caller's entire accrued + /// swap-fee balance as REAL WIRE from this contract's custody and zero the + /// accrual. `lifetime_claimed` (an audit total) accumulates instead. + /// + /// Owner-authenticated and self-serve — the depot never pushes these + /// payouts, so an underwriter claims on their own schedule and a dormant + /// underwriter costs the chain no per-epoch inline transfers. Throws when + /// the caller has no accrual row or a zero balance: a claim with nothing to + /// pay is a caller mistake, not a silent no-op. + [[sysio::action]] + void claimuwfee(sysio::name underwriter); + // ----------------------------------------------------------------------- // Tables // ----------------------------------------------------------------------- @@ -362,6 +512,30 @@ namespace sysio { /// key against this. std::vector creator_pub_key; + /// The owner's fee on this reserve's WIRE leg, in basis points — the + /// reserve's own revenue, INDEPENDENT of the network `fee_bps`. Every + /// swap that draws liquidity from this reserve pays it, so a + /// chain-to-chain swap between two fee-charging reserves pays both plus + /// the network fee. Set by the owner via `setrsvfee`; `0` (the default) + /// means the reserve charges nothing. See `MAX_OWNER_FEE_BPS`. + uint32_t owner_fee_bps = 0; + /// Unclaimed WIRE this reserve has earned from `owner_fee_bps`, held in + /// this contract's custody until the owner calls `claimrsvfee`. Part of + /// the custody invariant documented on `rewards_bucket`. + uint64_t owner_fee_accrued = 0; + /// Audit total: WIRE this reserve has earned from `owner_fee_bps`. + /// Monotonic — never decremented by a claim. + /// + /// SATURATES at `UINT64_MAX` (~18.45e9 WIRE at 9dp) rather than + /// wrapping. Unlike `owner_fee_accrued` — a balance, bounded by what is + /// actually in custody — this is an unbounded running total, so the + /// ceiling is reachable in principle: it needs roughly 370 turnovers of + /// the entire launch supply through THIS ONE reserve at a 5% owner fee + /// (~19 at the 99% maximum). Past that the counter stops advancing + /// while accrual and claims continue to work normally; only the audit + /// history is truncated, never a balance. + uint64_t owner_fee_lifetime = 0; + uint128_t by_chain_token() const { return (static_cast(chain_code.value) << 64) | token_code.value; } @@ -373,7 +547,8 @@ namespace sysio { (source_token_precision)(connector_weight_bps) (creator_addr)(requested_wire_amount)(external_token_amount) (registered_at_ms)(activated_at_ms)(cancelled_at_ms) - (is_private)(owner)(creator_pub_key)) + (is_private)(owner)(creator_pub_key) + (owner_fee_bps)(owner_fee_accrued)(owner_fee_lifetime)) }; using reserves_t = sysio::kv::table<"reserves"_n, reserve_key, reserve_row, @@ -381,14 +556,17 @@ namespace sysio { sysio::kv::index<"bystatus"_n, sysio::const_mem_fun> >; - /// Singleton accumulator for the rewards half of swap fees. The WIRE - /// stays in this contract's custody — it is NOT transferred out — so the - /// custody invariant is `token_balance == Σ reserve_wire_amount + - /// rewards.balance + in-flight escrow`. `balance` is the portion - /// earmarked for distribution (swept by `drainrewards` and folded into - /// `sysio.system::payepoch`); `lifetime_accrued` is an audit total. (The - /// emissions half of each fee IS transferred to the `sysio` treasury at - /// collection time and is therefore not tracked here.) + /// Singleton accumulator for the rewards (batch-operator) half of swap + /// fees. The WIRE stays in this contract's custody — it is NOT transferred + /// out — so the custody invariant is `token_balance == Σ + /// reserve_wire_amount + rewards.balance + Σ uw_fee_row.balance + + /// Σ reserve_row.owner_fee_accrued + in-flight escrow`. A collected owner + /// fee leaves `reserve_wire_amount` but stays in this contract until + /// `claimrsvfee`, so it is a term in the equation, not an outflow. + /// `balance` is the portion earmarked for distribution + /// (swept by `drainrewards` and folded into `sysio.system::payepoch`); + /// `lifetime_accrued` is an audit total. (The underwriter half of each fee + /// accrues to `uwfees` instead and is therefore not tracked here.) /// /// NOTE: sysio.system reads this row through a layout-compatible local /// definition (a `[[sysio::table]]`-attributed struct cannot be shared @@ -403,6 +581,50 @@ namespace sysio { }; using rewardbkt_t = sysio::kv::global<"rewardbkt"_n, rewards_bucket>; + /// Key for `uwfees` — one row per earning underwriter account. + struct uw_fee_key { + sysio::name underwriter; + uint64_t primary_key() const { return underwriter.value; } + SYSLIB_SERIALIZE(uw_fee_key, (underwriter)) + }; + + /// Per-underwriter accrual of the underwriter half of swap fees. Credited + /// by `route_wire_fee` at settlement (`applyswap` / `applyfromwire` / + /// `paywire`) to the uwreq's winning underwriter, and drained by the + /// owner-authenticated `claimuwfee`. The WIRE never leaves this contract's + /// custody until a claim, so these balances are part of the custody + /// invariant documented on `rewards_bucket`. + /// + /// `balance` is unclaimed WIRE. `lifetime_accrued` / `lifetime_claimed` + /// are monotonic audit totals; a row is created on first accrual and + /// RETAINED at zero balance after a claim so the audit trail survives. + /// The two `lifetime_*` counters SATURATE at `UINT64_MAX` (~18.45e9 WIRE + /// at 9dp) rather than wrapping — see `reserve_row::owner_fee_lifetime` + /// for the reachability arithmetic. They are unbounded running totals, so + /// unlike `balance` (bounded by custody) the ceiling is reachable in + /// principle; past it the audit history is truncated while accrual and + /// `claimuwfee` continue to work normally. + struct [[sysio::table("uwfees")]] uw_fee_row { + sysio::name underwriter; + uint64_t balance = 0; // unclaimed WIRE held in custody + uint64_t lifetime_accrued = 0; // audit: WIRE accrued (saturating) + uint64_t lifetime_claimed = 0; // audit: WIRE paid out (saturating) + SYSLIB_SERIALIZE(uw_fee_row, (underwriter)(balance)(lifetime_accrued)(lifetime_claimed)) + }; + using uwfees_t = sysio::kv::table<"uwfees"_n, uw_fee_key, uw_fee_row>; + + /// Fee-routing configuration singleton. Holds only the governance dial + /// that stage 2 of the fee split consults — the stage-1 underwriter share + /// is the fixed `FEE_UNDERWRITER_SHARE_BPS` constant, and the fee RATE + /// itself lives on `sysio.uwrit::uwconfig`. + struct [[sysio::table("reservcfg")]] reserve_config { + /// Share of each fee's rewards pool routed to the `sysio` emissions + /// treasury; the remainder goes to `rewards_bucket`. Default 0. + uint32_t fee_emissions_share_bps = DEFAULT_FEE_EMISSIONS_SHARE_BPS; + SYSLIB_SERIALIZE(reserve_config, (fee_emissions_share_bps)) + }; + using reservcfg_t = sysio::kv::global<"reservcfg"_n, reserve_config>; + private: using ReserveStatus = opp::types::ReserveStatus; using ChainKind = opp::types::ChainKind; diff --git a/contracts/sysio.reserv/src/sysio.reserv.cpp b/contracts/sysio.reserv/src/sysio.reserv.cpp index 2812b8eaf8..3cee8c6469 100644 --- a/contracts/sysio.reserv/src/sysio.reserv.cpp +++ b/contracts/sysio.reserv/src/sysio.reserv.cpp @@ -53,8 +53,15 @@ constexpr sysio::slug_name WIRE_TOKEN = "WIRE"_s; /// inside the consensus dispatch chain (applyswap / applyfromwire / paywire inline from /// uwrit::try_select_winner). A raw `+=` could wrap the uint64 and corrupt the /// weighted-AMM curve and the `>=` sufficiency checks; cap at UINT64_MAX instead — never wrap, -/// never throw on the consensus path. The cap is unreachable for any real token amount. Delegates -/// to the shared `sysio::opp::safe::add_sat_u64` so the never-wrap rule lives in one place. +/// never throw on the consensus path. Delegates to the shared +/// `sysio::opp::safe::add_sat_u64` so the never-wrap rule lives in one place. +/// +/// The cap is unreachable for any real BALANCE — a balance is bounded by what is +/// actually in custody. That reasoning does NOT carry to the monotonic +/// `lifetime_*` audit counters this helper also credits: those are unbounded +/// running totals and CAN saturate, which is documented at each field +/// (`reserve_row::owner_fee_lifetime`, `uw_fee_row::lifetime_*`). Saturation +/// there truncates audit history only — never a balance, never a payout. inline void add_capped_u64(uint64_t& balance, uint64_t amt) { balance = sysio::opp::safe::add_sat_u64(balance, amt); } @@ -67,6 +74,24 @@ uint32_t uwrit_fee_bps() { return cfg.get_or_default(sysio::uwrit::uw_config{}).fee_bps; } +/// Stage-2 governance dial: the share of each fee's REWARDS POOL routed to the +/// `sysio` emissions treasury. Read fresh per settlement so a `setconfig` takes +/// effect immediately; defaults to 0 (the whole pool is allocated to the +/// batch-operator distribution and no fee leaves custody) until configured. +uint32_t fee_emissions_share_bps(name self) { + reserve::reservcfg_t cfg(self); + return cfg.get_or_default(reserve::reserve_config{}).fee_emissions_share_bps; +} + +/// Credit a reserve's owner-fee revenue — the claimable balance AND the +/// monotonic audit total — from inside an open `modify` lambda. No-op at zero, +/// so a fee-free reserve costs nothing. +void accrue_owner_fee(reserve::reserve_row& row, uint64_t amount) { + if (amount == 0) return; + add_capped_u64(row.owner_fee_accrued, amount); + add_capped_u64(row.owner_fee_lifetime, amount); +} + reserve::reserve_key make_key(sysio::slug_name chain_code, sysio::slug_name token_code, sysio::slug_name reserve_code) { @@ -155,20 +180,65 @@ void queue_attestation_out(name self, ).send(); } -/// Route a collected WIRE swap fee: accrue the rewards share into the on-chain -/// `rewards_bucket` (the WIRE stays in this contract's custody, earmarked for a -/// future distribution) and transfer the emissions share back to the `sysio` -/// treasury. No-op when there is no fee. The custody invariant is preserved: -/// the rewards share moves from a reserve's WIRE side into `rewards.balance` -/// (same custody), and only the emissions share leaves as a real transfer. -void route_wire_fee(name self, const opp::amm::wire_fee& fee) { - if (fee.reward_share > 0) { +/// Route the NETWORK COMPONENT of a collected WIRE swap fee to its three +/// destinations. The reserve-owner shares carried in `fee` are NOT routed here — +/// the settlement actions accrue those to their own reserve rows before calling +/// this, so this function only ever moves the network fee's own split: +/// * the underwriter share accrues to `underwriter`'s `uwfees` row, payable to +/// that account on its own `claimuwfee` call — stays in custody; +/// * the rewards share accrues to the singleton `rewards_bucket`, swept by +/// `drainrewards` into `sysio.system::payepoch` and paid to batch operators +/// — stays in custody; +/// * the emissions share (zero unless `reserve_config` sets one) is +/// TRANSFERRED to the `sysio` treasury — the only part that leaves custody. +/// No-op when there is no fee. +/// +/// At the default config the emissions share is 0, so everything THIS function +/// routes moves into two earmarked accumulators in the SAME custody and the call +/// never changes `token_balance`. With a non-zero share, custody drops by exactly +/// that share. Note the total `wire_fee::fee` is larger than what is routed here +/// whenever a participating reserve charges an owner fee: at the default dial the +/// whole fee ends up spread across the owner accrual(s) the caller already made, +/// `uwfees`, and `rewards_bucket` — not wholly into the two accumulators below. +/// +/// A fee with no winning underwriter (a revert refund) passes an unset +/// `underwriter` together with a zero underwriter share. Should a caller ever +/// pair an unset account with a non-zero share, the share falls through to the +/// rewards bucket rather than stranding WIRE in custody with no claimant. +void route_wire_fee(name self, const opp::amm::wire_fee& fee, name underwriter) { + uint64_t reward_share = fee.reward_share; + + if (fee.underwriter_share > 0 && underwriter.value != 0) { + reserve::uwfees_t uwf(self); + reserve::uw_fee_key key{underwriter}; + auto it = uwf.find(key); + if (it == uwf.end()) { + uwf.emplace(ram_payer, key, reserve::uw_fee_row{ + .underwriter = underwriter, + .balance = fee.underwriter_share, + .lifetime_accrued = fee.underwriter_share, + .lifetime_claimed = 0, + }); + } else { + uwf.modify(ram_payer, key, [&](auto& row) { + add_capped_u64(row.balance, fee.underwriter_share); + add_capped_u64(row.lifetime_accrued, fee.underwriter_share); + }); + } + } else { + reward_share += fee.underwriter_share; + } + + if (reward_share > 0) { reserve::rewardbkt_t bkt(self); auto rb = bkt.get_or_default(reserve::rewards_bucket{}); - add_capped_u64(rb.balance, fee.reward_share); - add_capped_u64(rb.lifetime_accrued, fee.reward_share); + add_capped_u64(rb.balance, reward_share); + add_capped_u64(rb.lifetime_accrued, reward_share); bkt.set(rb, ram_payer); } + + // The ONLY part of a fee that leaves this contract's custody, and only when + // governance has configured a non-zero emissions share. if (fee.emissions_share > 0) { action( permission_level{self, "active"_n}, @@ -537,26 +607,31 @@ uint64_t reserve::swapquote(sysio::slug_name from_chain_code, // Resolve only the non-WIRE side(s); a WIRE endpoint has no token/WIRE pool // (the depot IS the WIRE side). Any required reserve missing or not ACTIVE // yields a 0 quote. - uint64_t src_chain = 0, src_wire = 0; uint32_t src_cw = 0; + uint64_t src_chain = 0, src_wire = 0; uint32_t src_cw = 0, src_fee_bps = 0; if (!src_is_wire) { auto it = tbl.find(make_key(from_chain_code, from_token_code, from_reserve_code)); if (it == tbl.end() || it->status != opp::types::RESERVE_STATUS_ACTIVE) return 0; - src_chain = it->reserve_chain_amount; - src_wire = it->reserve_wire_amount; - src_cw = it->connector_weight_bps; + src_chain = it->reserve_chain_amount; + src_wire = it->reserve_wire_amount; + src_cw = it->connector_weight_bps; + src_fee_bps = it->owner_fee_bps; } - uint64_t dst_chain = 0, dst_wire = 0; uint32_t dst_cw = 0; + uint64_t dst_chain = 0, dst_wire = 0; uint32_t dst_cw = 0, dst_fee_bps = 0; if (!dst_is_wire) { auto it = tbl.find(make_key(to_chain_code, to_token_code, to_reserve_code)); if (it == tbl.end() || it->status != opp::types::RESERVE_STATUS_ACTIVE) return 0; - dst_chain = it->reserve_chain_amount; - dst_wire = it->reserve_wire_amount; - dst_cw = it->connector_weight_bps; + dst_chain = it->reserve_chain_amount; + dst_wire = it->reserve_wire_amount; + dst_cw = it->connector_weight_bps; + dst_fee_bps = it->owner_fee_bps; } + // Each participating reserve's owner fee rides the quote alongside the + // network fee, so the quote prices exactly what settlement will charge. return opp::amm::quote_swap(src_is_wire, src_chain, src_wire, src_cw, dst_is_wire, dst_chain, dst_wire, dst_cw, - from_amount, uwrit_fee_bps()); + from_amount, uwrit_fee_bps(), + src_fee_bps, dst_fee_bps); } uint64_t reserve::rewardbal() { @@ -566,8 +641,10 @@ uint64_t reserve::rewardbal() { void reserve::drainrewards(int64_t amount) { // Only the system treasury (where sysio.system::payepoch runs) may sweep the - // rewards bucket. The swept WIRE is redistributed to producers + batch - // operators at the next pay-epoch. + // rewards bucket. The swept WIRE is allocated exclusively to the + // batch-operator distribution at the next pay-epoch — producers are not paid + // out of swap fees. payepoch pays only eligible shares; what it skips stays in + // the treasury. require_auth(TREASURY_ACCOUNT); // Internal treasury sweep: a non-positive amount means the caller's @@ -640,7 +717,8 @@ void reserve::applyswap(sysio::slug_name src_chain_code, sysio::slug_name dst_chain_code, sysio::slug_name dst_token_code, sysio::slug_name dst_reserve_code, - uint64_t dst_amount) { + uint64_t dst_amount, + sysio::name underwriter) { require_auth(UWRIT_ACCOUNT); sysio::check(src_amount > 0 && dst_amount > 0, "applyswap: amounts must be positive"); @@ -660,19 +738,29 @@ void reserve::applyswap(sysio::slug_name src_chain_code, // the weighted curve (the source reserve's own `connector_weight_bps`) — the // same definition `sysio.uwrit::swap_quote` uses, so the depot's books and // its quotes share one curve. The fee is then taken OUT of this WIRE leg: the - // source side gives up the full gross WIRE, only `net` continues to the - // destination side, and `fee` is routed to rewards + emissions. + // source side gives up the full gross WIRE and only `net` continues to the + // destination side. The TOTAL `fee` spreads across BOTH reserves' owner + // accruals (made in the modifies below), the winning underwriter's accrual, + // the batch-operator rewards bucket, and — only when + // `fee_emissions_share_bps` is set — a transfer of that share to `sysio`. const uint64_t w_gross = opp::amm::token_to_wire(src_it->reserve_chain_amount, src_it->reserve_wire_amount, src_it->connector_weight_bps, src_amount); sysio::check(w_gross > 0, "applyswap: WIRE intermediate is zero"); - const auto fee = opp::amm::split_wire_fee(w_gross, uwrit_fee_bps(), FEE_REWARD_SHARE_BPS); + // BOTH reserves supply liquidity for this swap, so both charge their own + // owner fee on the same WIRE leg — on top of the network fee (WIRE-281). + const auto fee = opp::amm::split_wire_fee(w_gross, uwrit_fee_bps(), FEE_UNDERWRITER_SHARE_BPS, + fee_emissions_share_bps(get_self()), + src_it->owner_fee_bps, dst_it->owner_fee_bps); // SEC-26 / WSA-042 settlement backstop: a zero post-fee WIRE leg credits no // WIRE to the destination reserve below while still debiting its chain side - // — draining it at an arbitrary price. `net == 0` is only reachable at a - // 100% fee, which `sysio.uwrit::setconfig` rejects (MAX_FEE_BPS), so this is - // unreachable defense-in-depth rather than a live path. + // — draining it at an arbitrary price. This is a LIVE path, not unreachable + // defense-in-depth: the caps bound each rate INDEPENDENTLY, so a network fee + // at `sysio.uwrit::MAX_FEE_BPS` (9999) plus either reserve's owner fee at + // MIN_OWNER_FEE_BPS (1) already totals 100%. Such a combination is an + // intentionally rejected configuration — refuse the swap here rather than + // settle it at an arbitrary price. sysio::check(fee.net > 0, "applyswap: zero post-fee WIRE would credit no destination liquidity"); sysio::check(src_it->reserve_wire_amount >= w_gross, "applyswap: insufficient source reserve WIRE for intermediate"); @@ -682,6 +770,7 @@ void reserve::applyswap(sysio::slug_name src_chain_code, tbl.modify(ram_payer, src_pk, [&](auto& row) { add_capped_u64(row.reserve_chain_amount, src_amount); row.reserve_wire_amount -= w_gross; + accrue_owner_fee(row, fee.src_reserve_share); }); // Same-row swaps (identical triples) compose correctly: the second // modify reads the post-first-modify state. The destination receives only @@ -689,17 +778,22 @@ void reserve::applyswap(sysio::slug_name src_chain_code, tbl.modify(ram_payer, dst_pk, [&](auto& row) { add_capped_u64(row.reserve_wire_amount, fee.net); row.reserve_chain_amount -= dst_amount; + accrue_owner_fee(row, fee.dst_reserve_share); }); - // Route the fee (rewards half stays in custody, emissions half leaves). - route_wire_fee(get_self(), fee); + // Route the NETWORK component (the two owner shares already accrued to their + // reserve rows above): underwriter half to `uwfees`, rewards half to + // `rewards_bucket` — both custody-internal — except any configured + // `fee_emissions_share_bps`, the only part that leaves for the treasury. + route_wire_fee(get_self(), fee, underwriter); } void reserve::applyfromwire(sysio::slug_name dst_chain_code, sysio::slug_name dst_token_code, sysio::slug_name dst_reserve_code, uint64_t wire_in, - uint64_t dst_amount) { + uint64_t dst_amount, + sysio::name underwriter) { require_auth(UWRIT_ACCOUNT); sysio::check(wire_in > 0 && dst_amount > 0, "applyfromwire: amounts must be positive"); @@ -713,22 +807,31 @@ void reserve::applyfromwire(sysio::slug_name dst_chain_code, "applyfromwire: insufficient destination reserve balance"); // Fee out of the user's escrowed input WIRE: only the post-fee remainder - // becomes destination-reserve liquidity; the fee is routed to rewards + - // emissions. The full `wire_in` was escrowed in this contract at - // `swapfromwire` time, so custody stays balanced (net -> Σwire, rewards -> - // bucket, emissions -> transferred out). - const auto fee = opp::amm::split_wire_fee(wire_in, uwrit_fee_bps(), FEE_REWARD_SHARE_BPS); + // becomes destination-reserve liquidity. The TOTAL fee spreads across the + // DESTINATION reserve's owner accrual, the winning underwriter's accrual, and + // the rewards bucket. The full `wire_in` was escrowed in this contract at + // `swapfromwire` time; the ONLY part that leaves here is a configured + // `fee_emissions_share_bps` of the rewards pool. At the default-zero dial + // nothing leaves and custody balances as net -> Σwire, fee -> the accruals. + // The source is the depot's own WIRE — there is no source reserve — so only + // the DESTINATION reserve charges an owner fee here. + const auto fee = opp::amm::split_wire_fee(wire_in, uwrit_fee_bps(), FEE_UNDERWRITER_SHARE_BPS, + fee_emissions_share_bps(get_self()), + /*src_reserve_fee_bps*/ 0, it->owner_fee_bps); // SEC-26 / WSA-042 settlement backstop — see applyswap. A zero post-fee WIRE // leg would debit the destination reserve below while crediting zero WIRE. - // Unreachable given `sysio.uwrit::setconfig`'s MAX_FEE_BPS cap. + // Reachable under valid configuration — the network fee at MAX_FEE_BPS (9999) + // plus this reserve's owner fee at MIN_OWNER_FEE_BPS (1) totals 100% — so + // this rejects a configured combination, not an impossible one. sysio::check(fee.net > 0, "applyfromwire: zero post-fee WIRE would credit no destination liquidity"); tbl.modify(ram_payer, pk, [&](auto& row) { add_capped_u64(row.reserve_wire_amount, fee.net); row.reserve_chain_amount -= dst_amount; + accrue_owner_fee(row, fee.dst_reserve_share); }); - route_wire_fee(get_self(), fee); + route_wire_fee(get_self(), fee, underwriter); } void reserve::paywire(sysio::slug_name src_chain_code, @@ -736,7 +839,8 @@ void reserve::paywire(sysio::slug_name src_chain_code, sysio::slug_name src_reserve_code, uint64_t src_amount, sysio::name recipient, - uint64_t wire_out) { + uint64_t wire_out, + sysio::name underwriter) { require_auth(UWRIT_ACCOUNT); sysio::check(src_amount > 0 && wire_out > 0, "paywire: amounts must be positive"); sysio::check(is_account(recipient), "paywire: recipient account does not exist"); @@ -757,7 +861,11 @@ void reserve::paywire(sysio::slug_name src_chain_code, it->connector_weight_bps, src_amount); sysio::check(w_gross > 0, "paywire: WIRE leg is zero"); - const auto fee = opp::amm::split_wire_fee(w_gross, uwrit_fee_bps(), FEE_REWARD_SHARE_BPS); + // The recipient is paid in WIRE — there is no destination reserve — so only + // the SOURCE reserve charges an owner fee here. + const auto fee = opp::amm::split_wire_fee(w_gross, uwrit_fee_bps(), FEE_UNDERWRITER_SHARE_BPS, + fee_emissions_share_bps(get_self()), + it->owner_fee_bps, /*dst_reserve_fee_bps*/ 0); const uint64_t wire_leaving = wire_out + fee.fee; sysio::check(it->reserve_wire_amount >= wire_leaving, "paywire: insufficient source reserve WIRE for payout + fee"); @@ -765,11 +873,16 @@ void reserve::paywire(sysio::slug_name src_chain_code, tbl.modify(ram_payer, pk, [&](auto& row) { add_capped_u64(row.reserve_chain_amount, src_amount); row.reserve_wire_amount -= wire_leaving; + accrue_owner_fee(row, fee.src_reserve_share); }); - // REAL WIRE leaves custody to the recipient; the fee's emissions half also - // leaves (rewards half stays in the bucket). `Σ reserve_wire_amount` and the - // contract's token balance drop together, preserving the custody invariant. + // `wire_out` goes to the recipient. The fee stays behind as three accruals — + // the source reserve's owner accrual (above), the underwriter accrual, and + // the rewards bucket — except any configured `fee_emissions_share_bps`, which + // `route_wire_fee` transfers to the treasury. `Σ reserve_wire_amount` drops by + // `wire_out + fee` while the token balance drops by `wire_out` plus that + // emissions share; the difference is exactly the accruals, preserving the + // invariant. action( permission_level{get_self(), "active"_n}, TOKEN_ACCOUNT, "transfer"_n, @@ -777,7 +890,7 @@ void reserve::paywire(sysio::slug_name src_chain_code, asset(static_cast(wire_out), WIRE_SYMBOL), std::string("sysio.reserv::paywire swap-to-WIRE payout")) ).send(); - route_wire_fee(get_self(), fee); + route_wire_fee(get_self(), fee, underwriter); } void reserve::refundwire(sysio::name recipient, @@ -787,14 +900,25 @@ void reserve::refundwire(sysio::name recipient, sysio::check(wire_amount > 0, "refundwire: amount must be positive"); sysio::check(is_account(recipient), "refundwire: recipient account does not exist"); - // Caller-fault revert fee, routed exactly like a settlement fee (rewards - // share stays in custody, emissions share leaves). Zero bps (no-fault - // refund) makes both the split and the routing no-ops. For any positive - // amount and a fee below 100%, floor division leaves `net >= 1`, so the - // backstop below is unreachable given `sysio.uwrit::setconfig`'s - // MAX_FEE_BPS cap — same defense-in-depth pattern as `applyswap`. It must - // hold: this action is inlined from the never-throw `drainfwq` drain. - const auto fee = opp::amm::split_wire_fee(wire_amount, revert_fee_bps, FEE_REWARD_SHARE_BPS); + // Caller-fault revert fee, routed through the same path as a settlement fee. + // A revert has NO winning underwriter — no collateral was locked for a swap + // that never settled — so the underwriter share is zero and the whole revert + // fee becomes the rewards POOL. `fee_emissions_share_bps` then splits that + // pool exactly as it does a settlement fee's: the whole revert fee lands in + // the rewards bucket only under the default zero dial; a configured dial + // diverts that share to the emissions treasury. Zero bps (no-fault refund) + // makes both the split and the routing no-ops. + // + // Unlike `applyswap` / `applyfromwire`, the backstop below really IS + // unreachable here, and for a reason specific to this path: a refund charges + // NO reserve owner fee (the trailing rates are left at 0), so the total is + // the network fee alone, which `sysio.uwrit::setconfig` caps at MAX_FEE_BPS + // (9999). Floor division then leaves `net >= 1` for any positive amount. + // There is no second rate to stack on top and reach 100%. It must hold: this + // action is inlined from the never-throw `drainfwq` drain. + const auto fee = opp::amm::split_wire_fee(wire_amount, revert_fee_bps, + /*underwriter_share_bps*/ 0, + fee_emissions_share_bps(get_self())); sysio::check(fee.net > 0, "refundwire: revert fee must be below 100%"); action( @@ -804,7 +928,115 @@ void reserve::refundwire(sysio::name recipient, asset(static_cast(fee.net), WIRE_SYMBOL), std::string("sysio.reserv::refundwire swap-from-WIRE refund")) ).send(); - route_wire_fee(get_self(), fee); + route_wire_fee(get_self(), fee, /*underwriter*/ name{}); +} + +void reserve::setconfig(uint32_t fee_emissions_share_bps) { + require_auth(get_self()); + sysio::check(fee_emissions_share_bps <= FEE_SPLIT_TOTAL_BPS, + "setconfig: fee_emissions_share_bps must be <= 10000 (100% of the rewards pool)"); + + reservcfg_t cfg(get_self()); + auto row = cfg.get_or_default(reserve_config{}); + row.fee_emissions_share_bps = fee_emissions_share_bps; + cfg.set(row, ram_payer); +} + +void reserve::setrsvfee(sysio::slug_name chain_code, + sysio::slug_name token_code, + sysio::slug_name reserve_code, + uint32_t owner_fee_bps) { + reserves_t tbl(get_self()); + auto pk = make_key(chain_code, token_code, reserve_code); + auto it = tbl.find(pk); + sysio::check(it != tbl.end(), "setrsvfee: reserve not found"); + sysio::check(it->status == opp::types::RESERVE_STATUS_ACTIVE, + "setrsvfee: reserve is not ACTIVE"); + // A bootstrap-seeded public reserve has no owner, so nobody can authorize a + // fee on it — which is exactly the guard that stops WIRE accruing with no + // claimant. `require_auth(name{})` would abort with a confusing message, so + // say why first. + sysio::check(it->owner.value != 0, "setrsvfee: reserve has no owner"); + require_auth(it->owner); + + // 0 disables the fee; anything else must clear the dust floor and stay below + // the 99% ceiling that keeps a positive remainder on the WIRE leg. + sysio::check(owner_fee_bps == 0 || + (owner_fee_bps >= MIN_OWNER_FEE_BPS && owner_fee_bps <= MAX_OWNER_FEE_BPS), + "setrsvfee: owner_fee_bps must be 0 or within [1, 9900]"); + + tbl.modify(ram_payer, pk, [&](auto& row) { row.owner_fee_bps = owner_fee_bps; }); +} + +uint64_t reserve::rsvfeebal(sysio::slug_name chain_code, + sysio::slug_name token_code, + sysio::slug_name reserve_code) { + reserves_t tbl(get_self()); + auto it = tbl.find(make_key(chain_code, token_code, reserve_code)); + return it == tbl.end() ? 0 : it->owner_fee_accrued; +} + +void reserve::claimrsvfee(sysio::slug_name chain_code, + sysio::slug_name token_code, + sysio::slug_name reserve_code) { + reserves_t tbl(get_self()); + auto pk = make_key(chain_code, token_code, reserve_code); + auto it = tbl.find(pk); + sysio::check(it != tbl.end(), "claimrsvfee: reserve not found"); + sysio::check(it->owner.value != 0, "claimrsvfee: reserve has no owner"); + require_auth(it->owner); + + const uint64_t amount = it->owner_fee_accrued; + const name owner = it->owner; + sysio::check(amount > 0, "claimrsvfee: no unclaimed balance"); + + // Zero the accounting balance first, then move the backing WIRE out of + // custody. `owner_fee_lifetime` is a cumulative audit total — never reduced. + tbl.modify(ram_payer, pk, [&](auto& row) { row.owner_fee_accrued = 0; }); + + action( + permission_level{get_self(), "active"_n}, + TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(get_self(), owner, + asset(static_cast(amount), WIRE_SYMBOL), + std::string("sysio.reserv::reserve owner fee claim")) + ).send(); +} + +uint64_t reserve::uwfeebal(sysio::name underwriter) { + uwfees_t uwf(get_self()); + auto it = uwf.find(uw_fee_key{underwriter}); + return it == uwf.end() ? 0 : it->balance; +} + +void reserve::claimuwfee(sysio::name underwriter) { + // Only the earner may sweep their own accrual. There is no depot-side push: + // an underwriter claims when they choose, and a dormant one costs the chain + // nothing. + require_auth(underwriter); + + uwfees_t uwf(get_self()); + uw_fee_key key{underwriter}; + auto it = uwf.find(key); + sysio::check(it != uwf.end(), "claimuwfee: no accrued swap fees for this underwriter"); + + const uint64_t amount = it->balance; + sysio::check(amount > 0, "claimuwfee: no unclaimed balance"); + + // Zero the accounting balance first, then move the backing WIRE out of + // custody. The row is retained at zero so the lifetime audit totals survive. + uwf.modify(ram_payer, key, [&](auto& row) { + row.balance = 0; + add_capped_u64(row.lifetime_claimed, amount); + }); + + action( + permission_level{get_self(), "active"_n}, + TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(get_self(), underwriter, + asset(static_cast(amount), WIRE_SYMBOL), + std::string("sysio.reserv::underwriter swap-fee claim")) + ).send(); } } // namespace sysio diff --git a/contracts/sysio.reserv/sysio.reserv.abi b/contracts/sysio.reserv/sysio.reserv.abi index 41d8050ca6..e144d1569c 100644 --- a/contracts/sysio.reserv/sysio.reserv.abi +++ b/contracts/sysio.reserv/sysio.reserv.abi @@ -40,6 +40,10 @@ { "name": "dst_amount", "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" } ] }, @@ -78,6 +82,38 @@ { "name": "dst_amount", "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + } + ] + }, + { + "name": "claimrsvfee", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "reserve_code", + "type": "slug_name" + } + ] + }, + { + "name": "claimuwfee", + "base": "", + "fields": [ + { + "name": "underwriter", + "type": "name" } ] }, @@ -250,6 +286,10 @@ { "name": "wire_out", "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" } ] }, @@ -321,6 +361,16 @@ } ] }, + { + "name": "reserve_config", + "base": "", + "fields": [ + { + "name": "fee_emissions_share_bps", + "type": "uint32" + } + ] + }, { "name": "reserve_key", "base": "", @@ -418,6 +468,18 @@ { "name": "creator_pub_key", "type": "bytes" + }, + { + "name": "owner_fee_bps", + "type": "uint32" + }, + { + "name": "owner_fee_accrued", + "type": "uint64" + }, + { + "name": "owner_fee_lifetime", + "type": "uint64" } ] }, @@ -440,6 +502,56 @@ } ] }, + { + "name": "rsvfeebal", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "reserve_code", + "type": "slug_name" + } + ] + }, + { + "name": "setconfig", + "base": "", + "fields": [ + { + "name": "fee_emissions_share_bps", + "type": "uint32" + } + ] + }, + { + "name": "setrsvfee", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "reserve_code", + "type": "slug_name" + }, + { + "name": "owner_fee_bps", + "type": "uint32" + } + ] + }, { "name": "slug_name", "base": "", @@ -483,6 +595,48 @@ "type": "slug_name" } ] + }, + { + "name": "uw_fee_key", + "base": "", + "fields": [ + { + "name": "underwriter", + "type": "name" + } + ] + }, + { + "name": "uw_fee_row", + "base": "", + "fields": [ + { + "name": "underwriter", + "type": "name" + }, + { + "name": "balance", + "type": "uint64" + }, + { + "name": "lifetime_accrued", + "type": "uint64" + }, + { + "name": "lifetime_claimed", + "type": "uint64" + } + ] + }, + { + "name": "uwfeebal", + "base": "", + "fields": [ + { + "name": "underwriter", + "type": "name" + } + ] } ], "actions": [ @@ -496,6 +650,16 @@ "type": "applyswap", "ricardian_contract": "" }, + { + "name": "claimrsvfee", + "type": "claimrsvfee", + "ricardian_contract": "" + }, + { + "name": "claimuwfee", + "type": "claimuwfee", + "ricardian_contract": "" + }, { "name": "debit", "type": "debit", @@ -541,13 +705,41 @@ "type": "rewardbal", "ricardian_contract": "" }, + { + "name": "rsvfeebal", + "type": "rsvfeebal", + "ricardian_contract": "" + }, + { + "name": "setconfig", + "type": "setconfig", + "ricardian_contract": "" + }, + { + "name": "setrsvfee", + "type": "setrsvfee", + "ricardian_contract": "" + }, { "name": "swapquote", "type": "swapquote", "ricardian_contract": "" + }, + { + "name": "uwfeebal", + "type": "uwfeebal", + "ricardian_contract": "" } ], "tables": [ + { + "name": "reservcfg", + "type": "reserve_config", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 11698 + }, { "name": "reserves", "type": "reserve_row", @@ -575,6 +767,14 @@ "key_names": ["name"], "key_types": ["name"], "table_id": 15326 + }, + { + "name": "uwfees", + "type": "uw_fee_row", + "index_type": "i64", + "key_names": ["underwriter"], + "key_types": ["name"], + "table_id": 43959 } ], "ricardian_clauses": [], @@ -584,9 +784,17 @@ "name": "rewardbal", "result_type": "uint64" }, + { + "name": "rsvfeebal", + "result_type": "uint64" + }, { "name": "swapquote", "result_type": "uint64" + }, + { + "name": "uwfeebal", + "result_type": "uint64" } ], "enums": [ diff --git a/contracts/sysio.reserv/sysio.reserv.wasm b/contracts/sysio.reserv/sysio.reserv.wasm index dff1056050..72c7eda803 100755 Binary files a/contracts/sysio.reserv/sysio.reserv.wasm and b/contracts/sysio.reserv/sysio.reserv.wasm differ diff --git a/contracts/sysio.roa/sysio.roa.wasm b/contracts/sysio.roa/sysio.roa.wasm index ad1204bc76..4db1d3d41c 100755 Binary files a/contracts/sysio.roa/sysio.roa.wasm and b/contracts/sysio.roa/sysio.roa.wasm differ diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index 1008561cc5..508965ffff 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -40,8 +40,29 @@ group pool divided by the full group size). A transfer is sent only to members that are opreg-ACTIVE, so the slices of skipped (inactive / slashed / terminated) members stay in the treasury rather than being redistributed to the active ones. Swap-fee rewards from `sysio.reserv`'s `rewards_bucket` are swept in -(`drainrewards`) and folded into the same compute distribution on top of -emissions, split by the same `producer_bps` / `batch_op_bps`. +(`drainrewards`) and allocated **exclusively to the batch-operator +distribution**, on top of their emission share and weighted by that same +per-group active-epoch count. Producers are not paid out of swap fees, so +`producer_bps` / `batch_op_bps` govern the emission split only. + +Allocated is not the same as paid: as with emissions, only **eligible** shares +are actually transferred. WIRE stays in the treasury when there are **no groups +at all**, when an **empty group owns positive active epochs** (its weighted slice +is skipped), when a **member is not opreg-ACTIVE**, or as the **remainder** of the +two integer divisions (per-group weighting, then the even per-member split). A +group active in **zero** epochs is *not* one of these cases — its weighted +allocation is already zero, and because the per-group counts sum to the **actual +accrued-epoch divisor** the remaining groups absorb the whole pool. + +That divisor is the sum of the per-group counters, **not** the configured +`pay_cadence_epochs`. The two can differ — a mid-period `setemitcfg` cadence +change, or the shortened genesis period — and normalizing by the configured value +is what caused a payout to be multiplied. Deriving it from the counters is what +makes the weights partition each pool by construction. + +`epochlog.fee_distributed` records what was actually paid, so it can be lower +than the swept amount — and is `0` when no eligible batch operator existed at all, +even though `drainrewards` swept the bucket to zero regardless. ## Retrieved via a claim action (pulled by recipient) diff --git a/contracts/sysio.system/include/sysio.system/emissions.hpp b/contracts/sysio.system/include/sysio.system/emissions.hpp index 966a10853f..34cbc101ec 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -343,9 +343,11 @@ struct [[sysio::table("epochlog"), sysio::contract("sysio.system")]] epoch_log { int64_t capex_amount = 0; int64_t governance_amount = 0; // Swap-fee rewards (sysio.reserv rewards_bucket) actually distributed to - // producers + batch operators this period, ON TOP of the emission above. - // Sourced from collected swap fees, not the T5 treasury, so it is NOT - // included in total_emission / total_distributed. + // batch operators this period, ON TOP of the emission above. Sourced from + // collected swap fees, not the T5 treasury, so it is NOT included in + // total_emission / total_distributed. Excludes the underwriter half of every + // fee, which accrues in sysio.reserv and is claimed there — it never reaches + // this treasury and so never appears in this log. int64_t fee_distributed = 0; SYSLIB_SERIALIZE(epoch_log, diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index d55ee9333e..0151eb1dce 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -586,9 +586,13 @@ namespace sysiosystem { * `batch_op_groups` is the full state.batch_op_groups vector from * sysio.epoch; payepoch reads t5state.batch_group_epochs to weight * the batch pool proportionally to each group's active-epoch count - * over the period (groups that were active in zero epochs are - * skipped, which can only happen when pay_cadence_epochs < - * batch_op_groups.size()). + * over the period, normalized by the ACTUAL accrued-epoch count (the + * sum of those counters) rather than the configured + * pay_cadence_epochs, which a mid-period setemitcfg change or the + * shortened genesis period can make disagree. Groups active in zero + * epochs are skipped, which happens whenever the accrued count is + * smaller than batch_op_groups.size(); skipping costs them nothing, + * since a zero count already weights their allocation to zero. * * Runtime conditions (config missing, treasury exhausted, balance * insufficient) are caught upstream by the gate, which records the @@ -602,14 +606,20 @@ namespace sysiosystem { /** * Accrue this epoch's per-epoch emission share onto t5state, without - * paying. Called inline by sysio.epoch::advance on every non-pay - * epoch (the cadence-1..cadence-2 epochs of each pay period). Auth: - * require_auth("sysio.epoch"). + * paying. Called inline by sysio.epoch::advance on EVERY successful + * epoch — including a pay epoch, where advance queues this action + * FIRST and payepoch after it, so FIFO inline ordering means payepoch + * observes the post-accrue state. Auth: require_auth("sysio.epoch"). * * Increments t5state.pending_emission_amount by `per_epoch_emission` * and bumps t5state.batch_group_epochs[batch_group_index] by 1, so * the next payepoch sees the period total + per-group counts. * + * Because it also runs on the pay epoch, the counter sum that + * `payepoch` normalizes by includes the epoch being paid. Reading this + * as "non-pay epochs only" understates that sum by one and is how the + * configured-cadence divisor came to look correct. + * * No transfers happen here. Treasury / balance gating is the * gate's responsibility upstream. */ diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 2229fdbc23..bfc01a22e3 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -54,7 +54,7 @@ constexpr sysio::name CAPEX_OPERATIONS_ACCOUNT = "sysio.ops"_n; constexpr sysio::name TOKEN_CONTRACT = "sysio.token"_n; constexpr sysio::name ROA_CONTRACT = "sysio.roa"_n; // sysio.reserv holds the swap-fee rewards bucket that payepoch folds into the -// per-epoch compute distribution. +// per-epoch batch-operator distribution. constexpr sysio::name RESERV_CONTRACT = "sysio.reserv"_n; namespace memo { @@ -546,11 +546,18 @@ void system_contract::accrueepoch(uint32_t epoch_index, // dclaim has funds the moment a claim is credited rather than at the next // pay-epoch. // -// Swap-fee rewards: the rewards half of collected swap fees (sysio.reserv's -// rewards_bucket) is swept here via an inline drainrewards and folded into the -// compute distribution -- producers + batch operators receive it alongside -// emissions, split by the same producer_bps / batch_op_bps. Fees are funded by -// the sweep (not the treasury) and so are excluded from total_distributed. +// Swap-fee rewards: the batch-operator share of collected swap fees +// (sysio.reserv's rewards_bucket) is swept here via an inline drainrewards and +// allocated EXCLUSIVELY to the batch-operator distribution, on top of their +// emission share and weighted by the same per-group active-epoch count. +// Producers are NOT paid out of swap fees, so producer_bps / batch_op_bps govern +// the emission split only -- see the fold-in comment at the drain. Allocated is +// not paid: only ELIGIBLE shares go out, and whatever is skipped stays in this +// treasury, exactly as undistributed emission does. What is actually skipped is +// listed at the batch-op loop below -- note a zero-epoch group is NOT one of +// them, since its weighted allocation is zero to begin with. +// Fees are funded by the sweep +// (not the treasury) and so are excluded from total_distributed. // // Single-trx semantics guarantee gate conditions hold through this call; // payepoch trusts the gate-computed period_emission and does not recompute. @@ -594,12 +601,43 @@ void system_contract::payepoch(uint32_t epoch_index, const int64_t producer_pool = split_bps(compute_amount, cfg.producer_bps); const int64_t batch_pool = compute_amount - producer_pool; + // ----- The period's ACTUAL length, in accrued epochs ----- + // BOTH distributions below normalize by this, and NEITHER may use + // cfg.pay_cadence_epochs for it. accrueepoch increments one batch_group_epochs + // slot per epoch unconditionally, while setemitcfg may change + // pay_cadence_epochs at any time (taking effect on the next advance), so the + // configured cadence and the epochs this period actually spans can disagree -- + // lowering 3->1 after one accrual leaves the counters summing to 2 against a + // configured 1. Deriving the divisor from the counters themselves keeps both + // normalizations correct whatever the config did mid-period. + // + // Sum in int64: each counter is a uint32 epoch tally and the vector is sized + // from batch_op_groups, so the total cannot approach the int64 range. Zero is + // impossible in practice (payepoch asserts accrueepoch ran for this same + // epoch_index, and accrueepoch always increments a slot) but is guarded at + // each use, because a zero divisor would abort the whole advance chain. + int64_t accrued_epochs = 0; + for (const uint32_t group_epoch_count : state.batch_group_epochs) { + accrued_epochs += group_epoch_count; + } + // ----- Swap-fee rewards fold-in ----- - // The rewards half of collected swap fees accrues in sysio.reserv's - // rewards_bucket (the other half already went to this treasury at swap - // time). Fold the whole bucket into THIS period's compute distribution so - // producers + batch operators receive it alongside emissions, split by the - // SAME producer_bps / batch_op_bps and weighted identically. + // The BATCH-OPERATOR half of collected swap fees accrues in sysio.reserv's + // rewards_bucket. The other half accrues per-underwriter in sysio.reserv and + // is drawn by that account's own `claimuwfee` — it never passes through this + // treasury. (When reserv's `fee_emissions_share_bps` dial is non-zero, that + // configured share of the batch-op half is transferred straight to this + // account at collection time and never enters the bucket; the dial defaults + // to zero, leaving the whole half here.) Fold the whole bucket into THIS + // period's batch-operator distribution so batch ops receive it alongside + // emissions, weighted identically by active-epoch count. + // + // Producers are NOT paid out of swap fees: the fee compensates the parties + // that carry an individual swap — the underwriter who locks collateral for it + // and the batch operators who relay it — while producers earn emissions for + // securing the chain. So `producer_bps` / `batch_op_bps` govern the emission + // `compute_amount` split only, and the entire drained fee pool goes to the + // batch-op distribution below. // // The fee WIRE lives in sysio.reserv's custody, so it must be swept here // before the payouts below can spend it. drainrewards is queued FIRST (ahead @@ -610,9 +648,11 @@ void system_contract::payepoch(uint32_t epoch_index, // // Fees are funded by that transfer, NOT the T5 treasury, so fee payouts are // tracked in `fee_paid` and excluded from total_distributed (which governs - // the emission curve). Any fee not distributed (producer round-scaling, - // skipped slashed/terminated recipients, integer-division remainders) stays - // in this treasury, exactly as undistributed emission does. + // the emission curve). Any fee not distributed stays in this treasury, exactly + // as undistributed emission does — see the batch-op loop for what is actually + // retained (an EMPTY group holding positive epochs, non-ACTIVE members, the + // two integer divisions' remainders, or no groups at all). A group active in + // zero epochs retains NOTHING: its weighted allocation is already zero. const int64_t fee_total = get_reserv_rewards_balance(); if (fee_total > 0) { sysio::action( @@ -622,8 +662,7 @@ void system_contract::payepoch(uint32_t epoch_index, std::make_tuple(fee_total) ).send(); } - const int64_t fee_producer_pool = split_bps(fee_total, cfg.producer_bps); - const int64_t fee_batch_pool = fee_total - fee_producer_pool; + const int64_t fee_batch_pool = fee_total; int64_t actual_paid = 0; // emission actually transferred (counts toward total_distributed) int64_t fee_paid = 0; // swap-fee rewards actually transferred (does NOT count toward treasury) @@ -641,16 +680,24 @@ void system_contract::payepoch(uint32_t epoch_index, auto prod_by_rank = _producers.get_index<"prodrank"_n>(); // expected_rounds is derived from the configured epoch duration on - // sysio.epoch (canonical source of truth) scaled by pay_cadence_epochs - // because elig_rounds accumulates across all epochs in the period. + // sysio.epoch (canonical source of truth) scaled by the period's ACTUAL + // accrued epoch count, because elig_rounds accumulates across exactly those + // epochs. It must NOT scale by cfg.pay_cadence_epochs: a mid-period cadence + // change makes the two disagree (see accrued_epochs above), and the + // mismatch silently distorts every producer's pay share -- too small a + // denominator lets everyone hit the clamp and collect their full share, too + // large a one forfeits pay that was earned. Unlike the batch-op pool this + // cannot overpay past producer_pool (the clamp bounds each share by + // emis_share), so it skews proportions rather than the total. const uint32_t epoch_duration_sec = get_epoch_duration_sec(); - // Compute in uint64: epoch_duration_sec (<= 30 days) * pay_cadence_epochs - // (<= uint16 max) * 2 overflows uint32 at the extremes, and a wrapped + // Compute in uint64: epoch_duration_sec (<= 30 days) * the accrued epoch + // count * 2 overflows uint32 at the extremes, and a wrapped // denominator would silently distort every producer's pay share. uint64 // holds the full product with room to spare; the result is a small round // count that fits back into uint64 for the divide below. uint64_t expected_rounds = - (static_cast(epoch_duration_sec) * cfg.pay_cadence_epochs * 2) / TOTAL_BLOCKS_PER_ROUND; + (static_cast(epoch_duration_sec) + * static_cast(accrued_epochs > 0 ? accrued_epochs : 1) * 2) / TOTAL_BLOCKS_PER_ROUND; // Below ~126s of effective period duration (one full 21-producer round // at 0.5s/block), expected_rounds truncates to zero. Falling back to 1 // keeps the pay formula well-defined -- producer pay collapses to @@ -708,39 +755,29 @@ void system_contract::payepoch(uint32_t epoch_index, } } - int64_t distributed_to_producers = 0; // emission portion - int64_t fee_to_producers = 0; // swap-fee portion + // Producers are paid the emission share only — swap fees go to the + // underwriter + batch operators (see the fold-in comment above). + int64_t distributed_to_producers = 0; if (total_weight > 0) { for (const auto& pe : eligible) { - // Emission and fee shares use the same weight and the same - // round-scaling, so a producer's fee tracks its emission reward. const int64_t emis_share = static_cast( static_cast<__int128>(producer_pool) * pe.weight / total_weight); - const int64_t fee_share = static_cast( - static_cast<__int128>(fee_producer_pool) * pe.weight / total_weight); - int64_t emis_pay, fee_pay; + int64_t pay; if (pe.is_standby) { - emis_pay = emis_share; - fee_pay = fee_share; + pay = emis_share; } else { uint64_t r = (pe.elig_rounds > expected_rounds) ? expected_rounds : pe.elig_rounds; - emis_pay = static_cast( + pay = static_cast( static_cast<__int128>(emis_share) * r / expected_rounds); - fee_pay = static_cast( - static_cast<__int128>(fee_share) * r / expected_rounds); } - const int64_t pay = emis_pay + fee_pay; if (pay > 0) { - // One transfer carries both the emission and the fee share. send_wire_transfer(get_self(), pe.owner, pay, memo::producer_reward); - distributed_to_producers += emis_pay; - fee_to_producers += fee_pay; + distributed_to_producers += pay; } } } actual_paid += distributed_to_producers; - fee_paid += fee_to_producers; // Reset round-tracking after distribution (iteration-safe: uses PK snapshot). for (const auto& owner : to_reset) { @@ -759,13 +796,32 @@ void system_contract::payepoch(uint32_t epoch_index, // Batch-op pay. With pay_cadence_epochs > 1 the active group can rotate // multiple times across a period, so each group's slice is weighted by // its active-epoch count (state.batch_group_epochs[g]) over the period. - // sum(batch_group_epochs) == pay_cadence_epochs by construction. Groups - // that were active in zero epochs are skipped (only possible when - // pay_cadence < batch_op_groups.size()); their slice stays in treasury. - // Members not registered as ACTIVE in sysio.opreg (slashed / terminated / - // unknown) are skipped and their slice remains in the treasury. + // + // The divisor is the ACTUAL accrued epoch count -- the sum of those counters + // -- NOT cfg.pay_cadence_epochs. The two can disagree: accrueepoch increments + // one slot per epoch unconditionally, while setemitcfg may change + // pay_cadence_epochs at any time, taking effect on the next advance. Lowering + // cadence 3->1 after one accrual leaves the counters summing to 2 against a + // divisor of 1, which pays 2x batch_pool AND 2x fee_batch_pool -- the surplus + // fee drawn from this treasury even though only one fee pool was swept from + // sysio.reserv, and invisible to total_distributed because fee payouts are + // excluded from it. A shortened genesis period underpays by the inverse. + // Summing the counters makes the per-group weights partition the pool by + // construction, whatever the config did mid-period. + // + // A group active in zero epochs is skipped, but that retains NOTHING: its + // weighted allocation is `pool * 0 / accrued_epochs` == 0, and since the + // counters sum to that divisor the remaining groups already absorb the whole + // pool. What ACTUALLY leaves WIRE behind in the treasury is: + // * no groups at all (the enclosing `if` fails) — the entire pool; + // * an EMPTY group that owns POSITIVE epochs — skipped by the `group.empty()` + // test BEFORE the epoch check, so its weighted slice is never paid; + // * a member not registered ACTIVE in sysio.opreg (slashed / terminated / + // unknown) — that member's per-member slice; + // * the remainders of the two integer divisions below (per-group weighting + // and the even per-member split). // ======================================================================= - if (cfg.pay_cadence_epochs > 0 && !batch_op_groups.empty()) { + if (accrued_epochs > 0 && !batch_op_groups.empty()) { for (size_t g = 0; g < batch_op_groups.size(); ++g) { const auto& group = batch_op_groups[g]; if (group.empty()) continue; @@ -778,9 +834,9 @@ void system_contract::payepoch(uint32_t epoch_index, // count over the period) so a member's fee tracks its emission reward. const int64_t members = static_cast(group.size()); const int64_t group_pool = static_cast( - static_cast<__int128>(batch_pool) * group_epochs / cfg.pay_cadence_epochs); + static_cast<__int128>(batch_pool) * group_epochs / accrued_epochs); const int64_t fee_group_pool = static_cast( - static_cast<__int128>(fee_batch_pool) * group_epochs / cfg.pay_cadence_epochs); + static_cast<__int128>(fee_batch_pool) * group_epochs / accrued_epochs); const int64_t per_member = group_pool / members; const int64_t fee_per_member = fee_group_pool / members; @@ -822,8 +878,8 @@ void system_contract::payepoch(uint32_t epoch_index, // Audit log: records the AUTHORIZED period emission + the four category // amounts for the period that just paid, plus the swap-fee rewards folded - // into the compute distribution (fee_distributed, sourced from swap fees - // rather than the treasury). (Producer / batch-op sub-distribution is + // into the batch-operator distribution (fee_distributed, sourced from swap + // fees rather than the treasury). (Producer / batch-op sub-distribution is // implicit -- recipients are in traces.) One row per pay-epoch; // non-pay-epochs have no audit-log row. epochlog_t epoch_table(get_self()); diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 1826031c57..24838619cb 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ diff --git a/contracts/sysio.token/sysio.token.wasm b/contracts/sysio.token/sysio.token.wasm index db838be5c7..74dbbcd1ce 100755 Binary files a/contracts/sysio.token/sysio.token.wasm and b/contracts/sysio.token/sysio.token.wasm differ diff --git a/contracts/sysio.tokens/sysio.tokens.wasm b/contracts/sysio.tokens/sysio.tokens.wasm index db30119e1d..4cf296e04a 100755 Binary files a/contracts/sysio.tokens/sysio.tokens.wasm and b/contracts/sysio.tokens/sysio.tokens.wasm differ diff --git a/contracts/sysio.uwrit/README.md b/contracts/sysio.uwrit/README.md index 7a3b84ff82..6c9a55d854 100644 --- a/contracts/sysio.uwrit/README.md +++ b/contracts/sysio.uwrit/README.md @@ -1,37 +1,93 @@ # sysio.uwrit -Underwriting ledger, intent/confirmation flow, and fee distribution contract. +Underwriting ledger and swap lifecycle contract. Owns the underwriter COMMIT +race, the collateral lock vector, the swap-from-WIRE escrow queue, and the fee +rate every swap is charged. ## Responsibility -- Tracks underwriter collateral per chain (staked, locked, available) -- Manages the underwriting lifecycle: intent → confirmation → remit → completion -- Enforces 24-hour challenge window hold on committed funds -- Distributes fees (0.1% per spoke) split 50/25/25 among underwriter, other underwriters, batch operators -- Handles collateral updates from outpost attestations -- Executes slashing on underwriters (called by `sysio.chalg`) +- Ingests `SWAP_REQUEST` attestations from outposts and opens an underwrite + request (`uwreqs`) for each. +- Resolves the underwriter COMMIT race: verifies each candidate's signature + against their WIRE account permissions, re-runs the LP variance check, + re-checks available collateral, then picks a winner. +- Writes one collateral lock per required leg and holds it for the full + wall-clock challenge window. **Locks are never released by delivery** — only + `chklocks` sweeps them once `expires_at_ms` passes. +- Settles the winning swap against `sysio.reserv` and queues the outbound + `SWAP_REMIT`. +- Escrows and drains swap-from-WIRE requests (`fwqueue`), charging a revert fee + on caller-fault drain failures. +- Owns the swap fee RATE (`uwconfig.fee_bps`). The fee is charged and + distributed by `sysio.reserv` — see "Fees" below. + +## Fees + +`uwconfig.fee_bps` (default 10 = 0.1%) is the **network fee**, taken out of the +**WIRE leg** of every swap. It is not the whole effective fee: each participating +non-WIRE leg's reserve independently charges its own `owner_fee_bps` +(`sysio.reserv::setrsvfee`, in `[MIN_OWNER_FEE_BPS, MAX_OWNER_FEE_BPS]` or zero). +A chain-to-chain swap therefore pays two owner fees plus the network fee; a swap +against a WIRE endpoint pays one plus the network fee. All of them come off the +same WIRE leg, so together they reduce what the recipient receives. + +`sysio.reserv` routes each part as follows — the owner fees to their reserves, +and the network fee through a **two-stage** split: + +| Part | Recipient | Path | +|---|---|---| +| Each reserve's `owner_fee_bps` | That **reserve's owner** | Accrues to the reserve row's `owner_fee_accrued`; drawn by `sysio.reserv::claimrsvfee` | +| 50% of the **network fee** | The swap's **winning underwriter** | Accrues to `sysio.reserv::uwfees`; drawn by that account's own `sysio.reserv::claimuwfee` | +| The other 50% — the **rewards pool** — less `fee_emissions_share_bps` | **Batch operators** | Accrues to `sysio.reserv::rewardbkt`; swept by `sysio.system::payepoch` into the batch-op distribution | +| `fee_emissions_share_bps` of the **rewards pool** | The `sysio` **emissions treasury** | Transferred out at settlement by `route_wire_fee` | + +The 50/50 network split is the fixed `sysio.reserv::FEE_UNDERWRITER_SHARE_BPS`; +the stage-2 share is the governance dial `reserve_config.fee_emissions_share_bps`, +which **defaults to zero**. At that default every part stays in `sysio.reserv`'s +WIRE custody until claimed or drained and no part of a swap fee reaches the +emissions treasury — but a non-zero dial diverts that share of the rewards pool +to the treasury at settlement, so the custody statement holds only at the +default. Producers are never paid out of swap fees at any setting. + +`uwconfig.fromwire_revert_fee_bps` is charged on the refunded escrow when a +queued from-WIRE swap reverts at drain for a cause the caller controls +(unpriceable target, variance tolerance exceeded). A revert has no winning +underwriter, so the whole revert fee becomes the rewards pool — reaching the +rewards bucket in full under the default zero `fee_emissions_share_bps`, less the +configured emissions share when that dial is set. Reverts caused +by system state changes after enqueue (reserve deactivated, flipped private, +chain deregistered) refund in full. ## Tables -| Table | Type | Description | -|-------|------|-------------| -| `uwconfig` | Singleton | Fee basis points, lock duration, fee share percentages | -| `collateral` | Multi-index | Per-underwriter per-chain collateral tracking | -| `uwledger` | Multi-index | Underwriting entries with status lifecycle | +| Table | Row type | Description | +|-------|----------|-------------| +| `uwconfig` | `uw_config` | Singleton: `fee_bps`, `collateral_lock_duration_ms`, `min_fromwire_amount`, `fromwire_revert_fee_bps`, `uwreq_pending_timeout_epochs`, `uwreq_retention_epochs` | +| `uwreqs` | `uw_request_t` | One row per swap intent — race state in `commits_by`, `winner`, lifecycle status, mirrored `variance_tolerance_bps`. Retained for `uwreq_retention_epochs` after ANY terminal transition — `COMPLETED` (after `chklocks` sweeps the final collateral lock; the reserve settlement itself already happened at winner selection, which is what made the row CONFIRMED), `REJECTED` (immediate failure via `reject_and_refund`), or `EXPIRED` (pending timeout, same path) — then erased by `pruneuwreqs` | +| `locks` | `lock_entry` | Flat per-leg lock vector consulted by `sysio.opreg::available()`. The `byexpire` secondary index lets `chklocks` sweep expired locks in one pass | +| `fwqueue` | `fromwire_q` | Escrowed swap-from-WIRE requests awaiting drain. `byepoch` secondary index | +| `uwcounters` | `uw_counters` | Monotonic id allocators (uwreq ids, lock ids) | ## Actions | Action | Auth | Description | |--------|------|-------------| -| `setconfig` | `sysio.uwrit` | Set fee/lock configuration | -| `submituw` | underwriter | Submit intent to underwrite a message | -| `confirmuw` | `sysio.uwrit` | Confirm after both outposts acknowledge | -| `expirelock` | permissionless | Release expired locks | -| `distfee` | `sysio.uwrit` | Distribute fees after completion | -| `updcltrl` | `sysio.uwrit` | Update collateral from outpost attestations | -| `slash` | `sysio.chalg` | Seize all collateral from slashed underwriter | +| `setconfig` | `sysio.uwrit` | Set the fee rate, lock duration, from-WIRE floor, revert fee, and uwreq lifecycle windows | +| `createuwreq` | `sysio.msgch` | Open an underwrite request from an inbound `SWAP_REQUEST` attestation | +| `rcrdcommit` | `sysio.msgch` | Record an underwriter's per-leg `UNDERWRITE_INTENT_COMMIT` bytes; resolves the race once both legs are present | +| `swapfromwire` | `user` | Escrow WIRE and enqueue a swap-FROM-WIRE request | +| `drainfwq` | `sysio.epoch` or self | Drain the from-WIRE queue: settle what prices, revert the rest (charging the revert fee on caller-fault causes) | +| `chklocks` | `sysio.epoch` or self | Sweep collateral locks whose wall-clock window has expired | +| `pruneuwreqs` | `sysio.epoch` or self | Expire timed-out PENDING uwreqs and erase terminal rows past their retention window | +| `sumlocks` | read-only | Sum an underwriter's active locks for a `(chain, token)` bucket — the lock half of `sysio.opreg::available()` | ## Dependencies -- Queues outbound attestations via `sysio.msgch` -- Slash called by `sysio.chalg` +- Receives inbound attestations via `sysio.msgch`; queues outbound `SWAP_REMIT` + / `SWAP_REVERT` through it. +- Settles against `sysio.reserv` (`applyswap` / `applyfromwire` / `paywire` / + `refundwire`), forwarding the winning underwriter so the fee accrues to them. +- Reads collateral from `sysio.opreg`; its `locks` table is the lock half of + that contract's `available()` rollup. +- Lock sweeping and queue draining are inlined from `sysio.epoch::advance`. +- The off-chain counterpart is `wire-sysio/plugins/underwriter_plugin/`. diff --git a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp index c0105e280b..cab69e298c 100644 --- a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp +++ b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp @@ -191,10 +191,15 @@ namespace sysio { // caused by system state changes after enqueue (reserve deactivated, // flipped private, chain deregistered) refund in full — the caller did // nothing wrong. Successful swaps are unaffected; they already pay - // `fee_bps` at settlement. The default mirrors `fee_bps` as a - // placeholder pending final fee calibration. + // `fee_bps` at settlement. + // + // 500 bps (5%) is the launch default (Jonathan, 2026-08-04), replacing the + // 0.1% placeholder that merely mirrored `fee_bps`. It has to be large + // enough that cycling escrows through the queue is not free. It stays + // governance-tunable via `setconfig` (capped at MAX_FEE_BPS), so this is a + // starting point, not a commitment. static constexpr uint64_t DEFAULT_MIN_FROMWIRE_AMOUNT = 5'000'000'000; // 5 WIRE @ 9 decimals - static constexpr uint32_t DEFAULT_FROMWIRE_REVERT_FEE_BPS = 10; // 0.1% + static constexpr uint32_t DEFAULT_FROMWIRE_REVERT_FEE_BPS = 500; // 5% // Maximum accepted swap fee, in basis points. A 100% fee (10000 bps) // would zero the post-fee WIRE leg of every swap (`net == 0` in @@ -203,6 +208,13 @@ namespace sysio { // draining the reserve at an arbitrary price (SEC-26 / WSA-042). Any fee // below 100% leaves a positive remainder for every positive input, so the // cap is 9999 (mirrors `sysio.reserv::MAX_CONNECTOR_WEIGHT_BPS`). + // + // This bounds THIS rate only. Reserve owner fees are charged off the same + // WIRE leg and capped independently, so the TOTAL can still reach 100% — + // 9999 here plus one owner fee at `sysio.reserv::MIN_OWNER_FEE_BPS` (1) + // does exactly that. The settlement paths' `net > 0` checks are therefore + // live rejections of a configured combination, not dead defense-in-depth; + // see `opp::amm::split_wire_fee`. static constexpr uint32_t MAX_FEE_BPS = 9999; // Upper bound on the collateral lock duration. try_select_winner locks a @@ -218,11 +230,22 @@ namespace sysio { // ----------------------------------------------------------------------- /// Set underwriting fee + lock config. Fields: - /// * `fee_bps` — per-spoke swap fee charged by the depot, taken out of - /// the WIRE leg of every swap (so the ETH/SOL the recipient can - /// receive is reduced). `sysio.reserv` routes the collected fee 50/50 - /// to its on-chain rewards bucket and the `sysio` emissions treasury - /// (see `sysio.reserv::FEE_REWARD_SHARE_BPS`). + /// * `fee_bps` — the NETWORK swap fee charged by the depot per spoke, + /// taken out of the WIRE leg of every swap (so the ETH/SOL the + /// recipient can receive is reduced). It is NOT the whole effective + /// fee: each participating non-WIRE leg's reserve independently charges + /// its own `owner_fee_bps` off the same leg (`sysio.reserv::setrsvfee`). + /// `sysio.reserv` splits the NETWORK component 50/50 between the swap's + /// winning underwriter (claimable via `sysio.reserv::claimuwfee`) and a + /// rewards pool (see `sysio.reserv::FEE_UNDERWRITER_SHARE_BPS`). That + /// pool then splits again by `reserve_config.fee_emissions_share_bps`: + /// that share is transferred to the `sysio` emissions treasury and the + /// remainder accrues to the rewards bucket, which + /// `sysio.system::payepoch` allocates to the batch-operator + /// distribution (paying only eligible shares; the rest stays in the + /// treasury). The dial defaults to zero, so by default the whole pool + /// is allocated to batch operators and no part of a fee leaves + /// `sysio.reserv`'s custody at settlement. /// * `collateral_lock_duration_ms` — wall-clock milliseconds after /// `lock_entry.created_at_ms` that the lock auto-expires (swept by /// `sysio.epoch::advance -> chklocks`). This is the challenge @@ -673,9 +696,13 @@ namespace sysio { >; /// Fee + lock-duration configuration singleton. `fee_bps` is the per-spoke - /// swap fee, charged out of the WIRE leg; the rewards/emissions split of - /// the collected fee is fixed in `sysio.reserv` (FEE_REWARD_SHARE_BPS), so - /// no fee-distribution shares live here. + /// NETWORK swap fee, charged out of the WIRE leg — the reserve owner fees + /// charged alongside it live on their own reserve rows + /// (`sysio.reserv::reserve::owner_fee_bps`), not here. No fee-DISTRIBUTION + /// shares live here either: the underwriter/rewards split of the network + /// fee is fixed in `sysio.reserv` (FEE_UNDERWRITER_SHARE_BPS), and the + /// rewards pool's own emissions share is that contract's governance dial + /// (`reserve_config.fee_emissions_share_bps`). struct [[sysio::table("uwconfig")]] uw_config { uint32_t fee_bps = 10; // 0.1% per spoke /// Wall-clock collateral lock duration — the challenge window. diff --git a/contracts/sysio.uwrit/src/sysio.uwrit.cpp b/contracts/sysio.uwrit/src/sysio.uwrit.cpp index 359373b7ef..f41a10c931 100644 --- a/contracts/sysio.uwrit/src/sysio.uwrit.cpp +++ b/contracts/sysio.uwrit/src/sysio.uwrit.cpp @@ -240,8 +240,9 @@ std::optional find_reserve(sysio::slug_name chain_code, /// Quote `src_amount` of (src_chain, src_token, src_reserve) into /// (dst_chain, dst_token, dst_reserve) along the depot's live curve. Mirrors /// `sysio.reserv::swapquote` exactly — the shared weighted-Bancor kernel (each -/// reserve's `connector_weight_bps`) and the SAME post-fee reduction (`fee_bps` -/// out of the WIRE leg) — so the variance check at SWAP_REQUEST receipt time +/// reserve's `connector_weight_bps`) and the SAME post-fee reduction: the +/// network `fee_bps` PLUS each participating reserve's own `owner_fee_bps`, all +/// out of the WIRE leg — so the variance check at SWAP_REQUEST receipt time /// matches what settlement will deliver, without an inline action call into /// reserv. Returns 0 if any required reserve is missing or not ACTIVE (caller /// treats 0 as "no quote available, skip variance check"). @@ -267,21 +268,23 @@ uint64_t swap_quote(sysio::slug_name src_chain_code, return r; }; - uint64_t sc = 0, sw = 0; uint32_t scw = 0; + uint64_t sc = 0, sw = 0; uint32_t scw = 0, sfee = 0; if (!src_is_wire) { auto r = active(find_reserve(src_chain_code, src_token_code, src_reserve_code)); if (!r) return 0; sc = r->reserve_chain_amount; sw = r->reserve_wire_amount; scw = r->connector_weight_bps; + sfee = r->owner_fee_bps; } - uint64_t dc = 0, dw = 0; uint32_t dcw = 0; + uint64_t dc = 0, dw = 0; uint32_t dcw = 0, dfee = 0; if (!dst_is_wire) { auto r = active(find_reserve(dst_chain_code, dst_token_code, dst_reserve_code)); if (!r) return 0; dc = r->reserve_chain_amount; dw = r->reserve_wire_amount; dcw = r->connector_weight_bps; + dfee = r->owner_fee_bps; } return opp::amm::quote_swap(src_is_wire, sc, sw, scw, dst_is_wire, dc, dw, dcw, - src_amount, fee_bps); + src_amount, fee_bps, sfee, dfee); } /// True iff every NON-WIRE leg of a swap has an ACTIVE reserve row. Lets callers @@ -692,7 +695,10 @@ void uwrit::setconfig(uint32_t fee_bps, // Reject a 100% (or higher) fee: it zeroes the post-fee WIRE leg // (`net == 0`), which let a swap debit destination reserve liquidity while // crediting zero WIRE (SEC-26 / WSA-042). MAX_FEE_BPS == 9999 keeps the - // remainder positive for every positive input. + // remainder positive for every positive input — for THIS rate alone. Reserve + // owner fees stack on the same leg under their own cap, so the total can + // still reach 100%; `sysio.reserv`'s settlement `net > 0` checks reject that + // configured combination. check(fee_bps <= MAX_FEE_BPS, "fee_bps must be below 10000 (100%): a 100% fee zeroes the post-fee WIRE leg"); check(collateral_lock_duration_ms > 0, @@ -1348,8 +1354,14 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { ? opp::amm::token_to_wire(src_r->reserve_chain_amount, src_r->reserve_wire_amount, src_r->connector_weight_bps, req.src_amount) : 0; + // The TOTAL fee `paywire` will charge on this leg: the network fee plus + // the source reserve's own owner fee (swap-to-WIRE has no destination + // reserve). Only `.fee` is read, and the total is independent of how the + // network fee sub-divides, so the emissions share is irrelevant here. const uint64_t to_wire_fee = opp::amm::split_wire_fee( - w_gross, current_fee_bps(self), reserve::FEE_REWARD_SHARE_BPS).fee; + w_gross, current_fee_bps(self), reserve::FEE_UNDERWRITER_SHARE_BPS, + /*emissions_share_bps*/ 0, + src_r ? src_r->owner_fee_bps : 0, /*dst_reserve_fee_bps*/ 0).fee; const uint64_t wire_needed = opp::safe::add_sat_u64(req.dst_amount, to_wire_fee); if (!src_r || w_gross == 0 || src_r->reserve_wire_amount < wire_needed) { sysio::print("try_select_winner: insufficient source-reserve WIRE for " @@ -1509,7 +1521,7 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { permission_level{self, "active"_n}, uwrit::RESERVE_ACCOUNT, "paywire"_n, std::make_tuple(req.src_chain_code, req.src_token_code, req.src_reserve_code, - req.src_amount, towire_recipient, req.dst_amount) + req.src_amount, towire_recipient, req.dst_amount, candidate) ).send(); } else if (!src_needed) { // Swap-from-WIRE: the escrowed WIRE becomes dst-reserve liquidity, @@ -1518,7 +1530,7 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { permission_level{self, "active"_n}, uwrit::RESERVE_ACCOUNT, "applyfromwire"_n, std::make_tuple(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, - req.src_amount, req.dst_amount) + req.src_amount, req.dst_amount, candidate) ).send(); queue_swap_remit(self, remit_dst_outpost_id, remit_encoded); } else { @@ -1529,7 +1541,7 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { std::make_tuple(req.src_chain_code, req.src_token_code, req.src_reserve_code, req.src_amount, req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, - req.dst_amount) + req.dst_amount, candidate) ).send(); queue_swap_remit(self, remit_dst_outpost_id, remit_encoded); } diff --git a/contracts/sysio.uwrit/sysio.uwrit.wasm b/contracts/sysio.uwrit/sysio.uwrit.wasm index cce16ed527..56f86f24eb 100755 Binary files a/contracts/sysio.uwrit/sysio.uwrit.wasm and b/contracts/sysio.uwrit/sysio.uwrit.wasm differ diff --git a/contracts/sysio.wrap/sysio.wrap.wasm b/contracts/sysio.wrap/sysio.wrap.wasm index b5f3944885..bb767c7b05 100755 Binary files a/contracts/sysio.wrap/sysio.wrap.wasm and b/contracts/sysio.wrap/sysio.wrap.wasm differ diff --git a/contracts/tests/amm_math_tests.cpp b/contracts/tests/amm_math_tests.cpp index d834f5cc71..aac26a8b47 100644 --- a/contracts/tests/amm_math_tests.cpp +++ b/contracts/tests/amm_math_tests.cpp @@ -18,6 +18,7 @@ #include #include +#include #include using namespace sysio::opp::amm; @@ -138,16 +139,22 @@ BOOST_AUTO_TEST_CASE(convenience_wrappers) { /// SEC-26 / WSA-042: a 100% fee (`fee_bps == BPS_TOTAL`) consumes the entire /// WIRE leg, leaving `net == 0`. That degenerate post-fee leg is what let a /// from-WIRE / token-to-token swap debit destination reserve liquidity while -/// crediting zero WIRE. Any fee below 100% leaves a positive remainder for -/// every positive input — so rejecting `fee_bps >= BPS_TOTAL` at -/// `sysio.uwrit::setconfig` makes `net == 0` unconstructible at settlement. +/// crediting zero WIRE. Any NETWORK fee below 100% leaves a positive remainder +/// for every positive input, which is why `sysio.uwrit::setconfig` rejects +/// `fee_bps >= BPS_TOTAL`. +/// +/// That cap does NOT make `net == 0` unconstructible at settlement, and this +/// suite must not imply it does: reserve owner fees ride the same leg under a +/// separate cap, so the TOTAL can still reach 100% — see +/// `split_wire_fee_reaches_the_leg_under_valid_configuration` below. The +/// settlement `net > 0` checks are live rejections, not dead code. BOOST_AUTO_TEST_CASE(split_wire_fee_boundaries) { // 100% fee: fee == input, net == 0, and the fee splits exactly. { - const auto f = split_wire_fee(1'000'000'000ULL, BPS_TOTAL, /*reward_share_bps*/5000); + const auto f = split_wire_fee(1'000'000'000ULL, BPS_TOTAL, /*underwriter_share_bps*/5000); BOOST_CHECK_EQUAL(f.fee, 1'000'000'000ULL); BOOST_CHECK_EQUAL(f.net, 0u); - BOOST_CHECK_EQUAL(f.reward_share + f.emissions_share, f.fee); + BOOST_CHECK_EQUAL(f.underwriter_share + f.reward_share, f.fee); } // Over-100% is clamped to 100% (split_wire_fee guards fee_bps > BPS_TOTAL), // still net == 0 — also rejected upstream by setconfig. @@ -167,4 +174,146 @@ BOOST_AUTO_TEST_CASE(split_wire_fee_boundaries) { } } +/// Stage 2 of the split: `emissions_share_bps` divides the REWARDS POOL (what is +/// left after the underwriter's cut), not the whole fee. The default is 0 — the +/// whole pool is allocated to the batch-operator distribution and no fee leaves +/// `sysio.reserv` custody. +BOOST_AUTO_TEST_CASE(split_wire_fee_emissions_share_divides_the_rewards_pool) { + constexpr uint64_t AMOUNT = 1'000'000ULL; + constexpr uint32_t FEE_BPS = 1'000; // 10% -> fee 100'000 + constexpr uint32_t HALF = 5'000; // 50% underwriter -> pool 50'000 + + // Default (omitted) emissions share: nothing is earmarked for the treasury. + { + const auto f = split_wire_fee(AMOUNT, FEE_BPS, HALF); + BOOST_CHECK_EQUAL(f.fee, 100'000u); + BOOST_CHECK_EQUAL(f.underwriter_share, 50'000u); + BOOST_CHECK_EQUAL(f.reward_share, 50'000u); + BOOST_CHECK_EQUAL(f.emissions_share, 0u); + } + // A 40% emissions share takes 40% OF THE POOL (20'000), not of the fee. + { + const auto f = split_wire_fee(AMOUNT, FEE_BPS, HALF, 4'000); + BOOST_CHECK_EQUAL(f.underwriter_share, 50'000u); + BOOST_CHECK_EQUAL(f.emissions_share, 20'000u); + BOOST_CHECK_EQUAL(f.reward_share, 30'000u); + } + // 100% of the pool leaves batch operators with nothing; the underwriter's + // half is untouched by this dial. + { + const auto f = split_wire_fee(AMOUNT, FEE_BPS, HALF, BPS_TOTAL); + BOOST_CHECK_EQUAL(f.underwriter_share, 50'000u); + BOOST_CHECK_EQUAL(f.emissions_share, 50'000u); + BOOST_CHECK_EQUAL(f.reward_share, 0u); + } + // Three-way conservation holds exactly across odd amounts and odd shares — + // each stage takes the REMAINDER, so no subunit is created or lost. + for (uint32_t emis : {0u, 1u, 3'333u, 5'000u, 9'999u, BPS_TOTAL}) { + for (uint64_t amt : {1ULL, 7ULL, 999ULL, 1'000'003ULL, 1'000'000'000'000ULL}) { + const auto f = split_wire_fee(amt, 137, 5'000, emis); + BOOST_CHECK_EQUAL(f.underwriter_share + f.reward_share + f.emissions_share, f.fee); + BOOST_CHECK_EQUAL(f.net + f.fee, amt); + } + } + // Over-100% share clamps rather than wrapping. + { + const auto f = split_wire_fee(AMOUNT, FEE_BPS, HALF, BPS_TOTAL + 7'777); + BOOST_CHECK_EQUAL(f.emissions_share, 50'000u); + BOOST_CHECK_EQUAL(f.reward_share, 0u); + } +} + +/// Three fees ride the same leg (network + both reserve owners), so their TOTAL +/// can carry past `uint64_t` even though each product is computed in `u128`. +/// The total must therefore be summed and compared in `u128` too: a wrapped +/// total is strictly worse than a saturated one, because it reports a small +/// POSITIVE `net` for a leg the fees consumed entirely — and `net > 0` is +/// exactly the gate every caller uses to reject such a swap. +BOOST_AUTO_TEST_CASE(split_wire_fee_stacked_rates_cannot_wrap_the_total) { + constexpr uint64_t MAX = std::numeric_limits::max(); + + // The boundary case: maximum leg, three 100% rates. Summed in uint64 this + // wraps to MAX - 2 and reports net == 2 — a fully-consumed leg passing the + // caller's `net > 0` gate. Clamped, it reports what actually happened. + { + const auto f = split_wire_fee(MAX, BPS_TOTAL, /*underwriter*/5'000, + /*emissions*/0, /*src*/BPS_TOTAL, /*dst*/BPS_TOTAL); + BOOST_CHECK_EQUAL(f.net, 0u); + BOOST_CHECK_EQUAL(f.fee, MAX); + // Each individual share is still its own true (un-wrapped) product. + BOOST_CHECK_EQUAL(f.src_reserve_share, MAX); + BOOST_CHECK_EQUAL(f.dst_reserve_share, MAX); + } + // Same wrap hazard at a partial rate: 40% + 40% + 40% of MAX is 1.2 × MAX, + // which overflows uint64 while each term individually fits. + { + const auto f = split_wire_fee(MAX, 4'000, 5'000, 0, 4'000, 4'000); + BOOST_CHECK_EQUAL(f.net, 0u); + BOOST_CHECK_EQUAL(f.fee, MAX); + } + // Just BELOW the carry: 30% + 30% + 30% of MAX is 0.9 × MAX, so the total + // fits and must be reported exactly — not clamped. + { + const auto f = split_wire_fee(MAX, 3'000, 5'000, 0, 3'000, 3'000); + const uint64_t expected = f.src_reserve_share + f.dst_reserve_share + + f.underwriter_share + f.reward_share + f.emissions_share; + BOOST_CHECK_EQUAL(f.fee, expected); + BOOST_CHECK_GT(f.net, 0u); + BOOST_CHECK_EQUAL(f.net + f.fee, MAX); + } + // Stacked rates that merely REACH the leg (no uint64 carry) also clamp to a + // zero net rather than under-reporting the fee. + { + const auto f = split_wire_fee(1'000'000ULL, 5'000, 5'000, 0, 3'000, 2'000); + BOOST_CHECK_EQUAL(f.fee, 1'000'000u); + BOOST_CHECK_EQUAL(f.net, 0u); + } + // Ordinary stacked rates conserve the leg exactly across odd amounts. + for (uint64_t amt : {1ULL, 7ULL, 999ULL, 1'000'003ULL, 1'000'000'000'000ULL}) { + const auto f = split_wire_fee(amt, 137, 5'000, 1'111, 89, 233); + BOOST_CHECK_EQUAL(f.src_reserve_share + f.dst_reserve_share + + f.underwriter_share + f.reward_share + f.emissions_share, + f.fee); + BOOST_CHECK_EQUAL(f.net + f.fee, amt); + } +} + +/// A zero `net` is an intentionally REJECTED CONFIGURATION, not unreachable +/// defense-in-depth. The two caps bound their rates INDEPENDENTLY — nothing +/// cross-checks the sum — so the maximum network fee `sysio.uwrit::setconfig` +/// accepts plus the minimum owner fee `sysio.reserv::setrsvfee` accepts already +/// consumes the whole leg. This pins that reachability so the `net > 0` checks in +/// `applyswap` / `applyfromwire` are never mistaken for dead code again. +BOOST_AUTO_TEST_CASE(split_wire_fee_reaches_the_leg_under_valid_configuration) { + // Mirrors the on-chain caps: sysio.uwrit::MAX_FEE_BPS / sysio.reserv's + // [MIN_OWNER_FEE_BPS, MAX_OWNER_FEE_BPS]. Duplicated as literals because this + // suite tests the shared AMM kernel and does not link either contract. + constexpr uint32_t MAX_FEE_BPS = 9'999; // 99.99% network fee + constexpr uint32_t MIN_OWNER_FEE_BPS = 1; // 0.01% reserve owner fee + + // 9999 + 1 == BPS_TOTAL exactly: one owner fee at its FLOOR is enough. + { + const auto f = split_wire_fee(1'000'000'000ULL, MAX_FEE_BPS, /*underwriter*/5'000, + /*emissions*/0, /*src*/MIN_OWNER_FEE_BPS, /*dst*/0); + BOOST_CHECK_EQUAL(f.net, 0u); + BOOST_CHECK_EQUAL(f.fee, 1'000'000'000ULL); + } + // Both legs charging the floor overshoots, and still reports a consumed leg + // rather than wrapping or under-reporting. + { + const auto f = split_wire_fee(1'000'000'000ULL, MAX_FEE_BPS, 5'000, 0, + MIN_OWNER_FEE_BPS, MIN_OWNER_FEE_BPS); + BOOST_CHECK_EQUAL(f.net, 0u); + BOOST_CHECK_EQUAL(f.fee, 1'000'000'000ULL); + } + // One bp below the boundary still settles: 9998 + 1 leaves a positive net, so + // the rejection is a genuine boundary and not a blanket refusal of high fees. + { + const auto f = split_wire_fee(1'000'000'000ULL, MAX_FEE_BPS - 1, 5'000, 0, + MIN_OWNER_FEE_BPS, 0); + BOOST_CHECK_GT(f.net, 0u); + BOOST_CHECK_EQUAL(f.net + f.fee, 1'000'000'000ULL); + } +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index a9b4250cf9..0bcc2fb0e4 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -3207,12 +3207,13 @@ BOOST_FIXTURE_TEST_CASE( single_active_producer_full_active_share, sysio_emissio } FC_LOG_AND_RETHROW() // Swap-fee rewards (sysio.reserv rewards_bucket) are folded into payepoch's -// compute distribution: producers + batch operators receive them on top of -// emissions, split by producer_bps / batch_op_bps. End-to-end: deploy reserv, +// BATCH-OPERATOR distribution only: batch ops receive them on top of their +// emission share, and producers receive none of them (producer_bps / +// batch_op_bps govern the emission split alone). End-to-end: deploy reserv, // seed the bucket with a real swap fee, advance to the (cadence-1) pay-epoch, -// and verify the single producer is paid producer_pool + its fee share, the -// bucket is swept to 0, the fee is NOT counted against the emission treasury, -// and the audit log records it. +// and verify the single producer is paid its emission producer_pool and NOTHING +// more, the bucket is swept to 0 regardless, and the fee is NOT counted against +// the emission treasury. BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester ) try { const account_name RESERV = "sysio.reserv"_n; const account_name UWRIT = "sysio.uwrit"_n; @@ -3252,16 +3253,18 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester ("name", "sol")("description", "") ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); + // No winning underwriter on this settlement (`underwriter` unset): the whole + // fee falls through to the rewards bucket, which is the quantity payepoch + // drains. The underwriter half's own accrual + claim is covered by + // sysio.reserv_tests; this test is about the drain and who it reaches. BOOST_REQUIRE_EQUAL( success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() ("src_chain_code", codename("ETH"))("src_token_code", codename("ETH"))("src_reserve_code", codename("PRIMARY")) ("src_amount", 1'000'000'000ULL) ("dst_chain_code", codename("SOLANA"))("dst_token_code", codename("SOL"))("dst_reserve_code", codename("PRIMARY")) - ("dst_amount", 100'000'000ULL) ) ); + ("dst_amount", 100'000'000ULL)("underwriter", name{}) ) ); const int64_t fee_total = reward_balance(); BOOST_REQUIRE_GT( fee_total, 0 ); - const int64_t fee_producer_pool = test_split_bps(fee_total, PRODUCER_BPS); - BOOST_REQUIRE_GT( fee_producer_pool, 0 ); // --- Single full-round producer; advance to the cadence-1 pay-epoch --- setup_producers(1); @@ -3286,17 +3289,22 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester const int64_t gov = log["governance_amount"].as(); const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); - // The producer received its full emission share PLUS its share of the fee - // (expected_rounds clamps to 1 at the 60s epoch, so the single active - // producer takes the whole producer pool). - BOOST_REQUIRE_EQUAL( got, producer_pool + fee_producer_pool ); - BOOST_REQUIRE_GT( got, producer_pool ); // fee folded in: pure emission caps at producer_pool - - // The audit log records the fee actually distributed (only the producer - // share; the batch-op share rolled to treasury with no active group). - BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_producer_pool ); - - // The bucket was swept to zero by the inline drain. + // The producer received its emission share and NOTHING MORE. Swap fees pay + // the parties that carry an individual swap — the winning underwriter and the + // batch operators that relay it — never producers, who earn emissions for + // securing the chain. (expected_rounds clamps to 1 at the 60s epoch, so the + // single active producer takes the whole producer pool.) + BOOST_REQUIRE_EQUAL( got, producer_pool ); + + // Nothing was distributed out of the fee: the whole pool is allocated to the + // batch-operator distribution and this fixture has no active rotation group, + // so every share rolls to treasury. This is the NEGATIVE case — a batch + // operator actually receiving the fee is + // `payepoch_pays_swap_fee_to_active_batch_operator` below. + BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), 0 ); + + // The bucket was still swept to zero by the inline drain — the drain is + // unconditional on there being a recipient, and it must not overdraw. BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); // total_distributed counts emission only (producer_pool + capex + gov, with @@ -3306,6 +3314,228 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester BOOST_REQUIRE_EQUAL( t5_after - t5_before, producer_pool + capex + gov ); } FC_LOG_AND_RETHROW() +// The POSITIVE counterpart: a swap fee actually reaching an ACTIVE batch +// operator's balance. `payepoch_folds_swap_fee_rewards` proves only that the +// bucket is SWEPT — `drainrewards` does that unconditionally, even when every +// payout is skipped — so on its own it cannot distinguish "batch ops were paid" +// from "the fee silently rolled to treasury". Neither can a flow test that waits +// for `sysio.reserv` custody to fall by the reward share, for the same reason. +// +// Scaled to a ONE-member rotation (operators_per_epoch = batch_op_groups = 1) so +// the arithmetic is exact rather than a proportional bound: with one group active +// for the single epoch of a cadence-1 period, that member's slice is the entire +// batch pool and the entire fee pool. Asserts the recipient's balance delta and +// the exact positive `epochlog.fee_distributed`. +BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_emissions_tester ) try { + const account_name RESERV = "sysio.reserv"_n; + const account_name UWRIT = "sysio.uwrit"_n; + const account_name BATCH_OP = "batchopa"_n; + + create_t5_holding_accounts(); + deploy_reserv(); + + abi_serializer reserv_ser; + { + const auto* a = control->find_account_metadata( RESERV ); + BOOST_REQUIRE( a != nullptr ); + abi_def d; + BOOST_REQUIRE_EQUAL( abi_serializer::to_abi(a->abi, d), true ); + reserv_ser.set_abi( d, abi_serializer::create_yield_function(abi_serializer_max_time) ); + } + auto codename = [](std::string_view s) { return mvo()("value", fc::slug_name{s}.value); }; + auto reward_balance = [&]() -> int64_t { + auto data = get_row_by_account(RESERV, RESERV, "rewardbkt"_n, "rewardbkt"_n); + if (data.empty()) return 0; + auto v = reserv_ser.binary_to_variant("rewards_bucket", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + return static_cast(v["balance"].as_uint64()); + }; + + // --- Seed the rewards bucket with a real swap fee (bootstrap window) --- + BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() + ("chain_code", codename("ETH"))("token_code", codename("ETH"))("reserve_code", codename("PRIMARY")) + ("name", "eth")("description", "") + ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) + ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); + BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() + ("chain_code", codename("SOLANA"))("token_code", codename("SOL"))("reserve_code", codename("PRIMARY")) + ("name", "sol")("description", "") + ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) + ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); + // No winning underwriter, so the whole network fee lands in the rewards bucket + // — the quantity payepoch drains and must hand to the batch operator. + BOOST_REQUIRE_EQUAL( success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() + ("src_chain_code", codename("ETH"))("src_token_code", codename("ETH"))("src_reserve_code", codename("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("dst_chain_code", codename("SOLANA"))("dst_token_code", codename("SOL"))("dst_reserve_code", codename("PRIMARY")) + ("dst_amount", 100'000'000ULL)("underwriter", name{}) ) ); + + const int64_t fee_total = reward_balance(); + BOOST_REQUIRE_GT( fee_total, 0 ); + + // --- A one-member rotation group, ACTIVE in opreg --- + // Bootstrapped so the ACTIVE flip bypasses the collateral gate (see + // .claude/rules/bootstrapped-operator-invariants.md); payepoch's own filter is + // `is_op_active(member, OPERATOR_TYPE_BATCH)`, which this satisfies. + create_accounts( { BATCH_OP }, false, false, false, true ); // include_ram_gift + BOOST_REQUIRE_EQUAL( success(), + register_operator( BATCH_OP, OperatorType::OPERATOR_TYPE_BATCH, /*is_bootstrapped*/true ) ); + + // operators_per_epoch = batch_op_groups = 1 -> batch_operator_minimum_active + // is 1, so this single operator satisfies schbatchgps and fills the whole + // window: one group, one member, paid every epoch. + BOOST_REQUIRE_EQUAL( success(), init_epoch_state(60, /*operators_per_epoch*/1, + /*batch_op_groups_count*/1) ); + produce_blocks(1); + BOOST_REQUIRE_EQUAL( success(), push_epoch_action(EPOCH, "schbatchgps"_n, mvo()) ); + + setup_producers(1); + wait_for_producer_schedule(); + produce_complete_cycles(1, 2); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + const int64_t t5_before = get_t5_state()["total_distributed"].as(); + const int64_t bal_before = get_wire_balance(BATCH_OP).get_amount(); + + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + + // --- The payout --- + auto log = get_epoch_log(1); + const int64_t compute = log["compute_amount"].as(); + const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); + const int64_t batch_pool = compute - producer_pool; + + // One group, active for the single epoch of a cadence-1 period, one member: + // the member's slice is the whole batch pool AND the whole fee pool. The + // emission and fee shares ride ONE transfer, so the balance delta is the sum. + const int64_t got = get_wire_balance(BATCH_OP).get_amount() - bal_before; + BOOST_REQUIRE_EQUAL( got, batch_pool + fee_total ); + + // The fee reached a real recipient — the assertion the negative case cannot + // make. Exact value, not merely positive: a fee that leaked into the producer + // pool or was double-counted would still be > 0 here. + BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_total ); + + // Bucket swept, and the fee is NOT charged against the emission curve — + // total_distributed moves by the EMISSION only, excluding fee_total. + BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); + const int64_t capex = log["capex_amount"].as(); + const int64_t gov = log["governance_amount"].as(); + const int64_t t5_after = get_t5_state()["total_distributed"].as(); + BOOST_REQUIRE_EQUAL( t5_after - t5_before, producer_pool + batch_pool + capex + gov ); +} FC_LOG_AND_RETHROW() + +// Lowering pay_cadence_epochs MID-PERIOD must not multiply the payout. +// `accrueepoch` increments one batch_group_epochs slot per epoch unconditionally, +// while `setemitcfg` may change pay_cadence_epochs at any time. Normalizing by +// cfg.pay_cadence_epochs instead of the accrued total made those two disagree: +// cadence 3 -> 1 after one accrual leaves the counters summing to 2 against a +// divisor of 1, paying 2x batch_pool AND 2x fee_batch_pool. The surplus fee is the +// dangerous half — only ONE fee pool was swept from sysio.reserv, so the extra is +// drawn from this treasury, and fee payouts are excluded from total_distributed, +// so it never shows up against the emission curve. +// +// Runs with an ACTIVE batch group and a NON-ZERO fee, because with either absent +// the overpayment is unobservable: no group means nothing is distributed, and a +// zero fee makes the fee half of the bug invisible. +BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_fee_payout, + sysio_emissions_tester ) try { + const account_name RESERV = "sysio.reserv"_n; + const account_name UWRIT = "sysio.uwrit"_n; + const account_name BATCH_OP = "batchopb"_n; + + create_t5_holding_accounts(); + deploy_reserv(); + + abi_serializer reserv_ser; + { + const auto* a = control->find_account_metadata( RESERV ); + BOOST_REQUIRE( a != nullptr ); + abi_def d; + BOOST_REQUIRE_EQUAL( abi_serializer::to_abi(a->abi, d), true ); + reserv_ser.set_abi( d, abi_serializer::create_yield_function(abi_serializer_max_time) ); + } + auto codename = [](std::string_view s) { return mvo()("value", fc::slug_name{s}.value); }; + auto reward_balance = [&]() -> int64_t { + auto data = get_row_by_account(RESERV, RESERV, "rewardbkt"_n, "rewardbkt"_n); + if (data.empty()) return 0; + auto v = reserv_ser.binary_to_variant("rewards_bucket", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + return static_cast(v["balance"].as_uint64()); + }; + + BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() + ("chain_code", codename("ETH"))("token_code", codename("ETH"))("reserve_code", codename("PRIMARY")) + ("name", "eth")("description", "") + ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) + ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); + BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() + ("chain_code", codename("SOLANA"))("token_code", codename("SOL"))("reserve_code", codename("PRIMARY")) + ("name", "sol")("description", "") + ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) + ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); + BOOST_REQUIRE_EQUAL( success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() + ("src_chain_code", codename("ETH"))("src_token_code", codename("ETH"))("src_reserve_code", codename("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("dst_chain_code", codename("SOLANA"))("dst_token_code", codename("SOL"))("dst_reserve_code", codename("PRIMARY")) + ("dst_amount", 100'000'000ULL)("underwriter", name{}) ) ); + + const int64_t fee_total = reward_balance(); + BOOST_REQUIRE_GT( fee_total, 0 ); + + create_accounts( { BATCH_OP }, false, false, false, true ); + BOOST_REQUIRE_EQUAL( success(), + register_operator( BATCH_OP, OperatorType::OPERATOR_TYPE_BATCH, /*is_bootstrapped*/true ) ); + BOOST_REQUIRE_EQUAL( success(), init_epoch_state(60, /*operators_per_epoch*/1, + /*batch_op_groups_count*/1) ); + produce_blocks(1); + BOOST_REQUIRE_EQUAL( success(), push_epoch_action(EPOCH, "schbatchgps"_n, mvo()) ); + + setup_producers(1); + wait_for_producer_schedule(); + produce_complete_cycles(1, 2); + + // Cadence 3: the first advance accrues without paying. + BOOST_REQUIRE_EQUAL( success(), setemitcfg_with_cadence( config::system_account_name, uint16_t(3) ) ); + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + BOOST_REQUIRE_EQUAL( get_t5_state()["epoch_count"].as(), 0u ); // non-pay + BOOST_REQUIRE_GT( get_t5_state()["pending_emission_amount"].as(), 0 ); + + // Drop to cadence 1 mid-period. The counters now sum to 2 while cfg says 1. + BOOST_REQUIRE_EQUAL( success(), setemitcfg_with_cadence( config::system_account_name, uint16_t(1) ) ); + + const int64_t bal_before = get_wire_balance(BATCH_OP).get_amount(); + produce_blocks(130); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); // pay-epoch + BOOST_REQUIRE_EQUAL( get_t5_state()["epoch_count"].as(), 1u ); + + auto log = get_epoch_log(2); + const int64_t compute = log["compute_amount"].as(); + const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); + const int64_t batch_pool = compute - producer_pool; + + // The single group holds BOTH accrued epochs, so normalizing by the accrued + // total (2) gives it the whole pool exactly once -- not twice. + const int64_t got = get_wire_balance(BATCH_OP).get_amount() - bal_before; + BOOST_REQUIRE_EQUAL( got, batch_pool + fee_total ); + + // The load-bearing assertion: the fee is distributed ONCE. Under the old + // divisor this was 2 * fee_total, with the surplus drawn from the treasury. + BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_total ); + BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); + + // And the emission side is not double-paid either. + const int64_t capex = log["capex_amount"].as(); + const int64_t gov = log["governance_amount"].as(); + BOOST_REQUIRE_EQUAL( get_t5_state()["total_distributed"].as(), + producer_pool + batch_pool + capex + gov ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( standby_weight_decreases_by_rank, sysio_emissions_tester ) try { // Rank 22 should receive more than rank 23, which should receive more than rank 24, etc. // Weight formula: w = 29 - rank (22→7, 23→6, 24→5) diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 1e98cbd878..1ad16b363d 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -2573,14 +2573,17 @@ BOOST_FIXTURE_TEST_CASE(swapfromwire_enforces_min_amount, sysio_dispatch_tester) } FC_LOG_AND_RETHROW() } // Caller-controlled drain-time reverts forfeit the configured revert fee: the refund returns the -// escrow minus the fee, and the fee routes through the standard rewards/emissions split exactly +// escrow minus the fee, and the fee routes through the standard `route_wire_fee` path exactly // like a settlement fee — so revert churn pays the system instead of recycling for free. BOOST_FIXTURE_TEST_CASE(drainfwq_charges_revert_fee_on_caller_fault, sysio_dispatch_tester) { try { constexpr uint64_t ESCROW = 5'000'000'000ull; // the default floor exactly constexpr uint32_t REVERT_FEE_BPS = 100; // 1% constexpr uint64_t FEE = ESCROW * REVERT_FEE_BPS / 10000ull; // 0.05 WIRE - // Mirror of sysio.reserv.hpp::FEE_REWARD_SHARE_BPS (50% rewards / 50% emissions). - constexpr uint64_t REWARD_SHARE = FEE / 2; + // A revert has no winning underwriter, so `refundwire` passes a zero underwriter share and + // the WHOLE fee becomes the rewards pool. reserv's `fee_emissions_share_bps` is never set + // here (the default 0), so that pool lands in the rewards bucket intact (batch operators); + // a configured dial would divert its share to the emissions treasury. + constexpr uint64_t REWARD_SHARE = FEE; constexpr uint64_t DEPOT_ORIGIN_ID_0 = 0x8000000000000000ull; const auto WIRE_SYM = symbol(9, "WIRE"); @@ -2619,8 +2622,8 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_charges_revert_fee_on_caller_fault, sysio_dispa BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); - // Row consumed; escrow minus the fee came back; the fee split 50/50 into the reserv rewards - // bucket (custody-internal) and the sysio emissions treasury (real transfer). + // Row consumed; escrow minus the fee came back; the whole fee accrued into the reserv + // rewards bucket (custody-internal — no transfer leaves reserv for a fee). BOOST_REQUIRE(get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "fwqueue"_n, DEPOT_ORIGIN_ID_0).empty()); BOOST_REQUIRE_EQUAL(funded - static_cast(FEE), get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); diff --git a/contracts/tests/sysio.reserv_tests.cpp b/contracts/tests/sysio.reserv_tests.cpp index 0d9c72f0df..7f442e56b9 100644 --- a/contracts/tests/sysio.reserv_tests.cpp +++ b/contracts/tests/sysio.reserv_tests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -51,13 +52,17 @@ class sysio_reserve_tester : public tester { static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; static constexpr auto SYSIO_ACCOUNT = "sysio"_n; + /// Stand-in for a swap's winning underwriter — the account the settlement + /// actions accrue the underwriter half of the fee to. + static constexpr auto UNDERWRITER_ACCOUNT = "underwriter1"_n; sysio_reserve_tester() { produce_blocks(2); // sysio.authex is pre-created by the tester boot (account linking) — // creating it again would collide. create_accounts({RESERVE_ACCOUNT, MSGCH_ACCOUNT, UWRIT_ACCOUNT, - TOKEN_ACCOUNT, CHAINS_ACCOUNT, "alice"_n}); + TOKEN_ACCOUNT, CHAINS_ACCOUNT, "alice"_n, + UNDERWRITER_ACCOUNT}); produce_blocks(2); set_code(RESERVE_ACCOUNT, contracts::reserve_wasm()); @@ -158,6 +163,26 @@ class sysio_reserve_tester : public tester { } } + /// `swapquote` is a read-only action whose ANSWER is its return value. + /// `push_action` above discards the transaction trace and yields only + /// `success()`, so a test written against it passes even when the quote + /// ignores every fee. Decode the action return value instead. + uint64_t swapquote_value(std::string_view from_chain, std::string_view from_token, + std::string_view from_reserve, uint64_t from_amount, + std::string_view to_chain, std::string_view to_token, + std::string_view to_reserve) { + auto trace = tester::push_action(RESERVE_ACCOUNT, "swapquote"_n, RESERVE_ACCOUNT, mvo() + ("from_chain_code", codename_mvo(from_chain)) + ("from_token_code", codename_mvo(from_token)) + ("from_reserve_code", codename_mvo(from_reserve)) + ("from_amount", from_amount) + ("to_chain_code", codename_mvo(to_chain)) + ("to_token_code", codename_mvo(to_token)) + ("to_reserve_code", codename_mvo(to_reserve))); + BOOST_REQUIRE(trace && !trace->action_traces.empty()); + return fc::raw::unpack(trace->action_traces[0].return_value); + } + // ── SlugName helpers (v6) ── static fc::slug_name cn(std::string_view s) { return fc::slug_name{s}; } @@ -200,6 +225,14 @@ class sysio_reserve_tester : public tester { return bal.get_amount(); } + /// Read `underwriter`'s `uwfees` accrual row. Null when they have never + /// earned a swap fee (no row is created until the first accrual). + fc::variant get_uwfees(name underwriter) { + auto data = get_row_by_account(RESERVE_ACCOUNT, RESERVE_ACCOUNT, "uwfees"_n, underwriter); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( + "uw_fee_row", data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + /// Read the `rewards_bucket` singleton (kv::global). Null when never accrued. fc::variant get_rewardbkt() { auto data = get_row_by_account(RESERVE_ACCOUNT, RESERVE_ACCOUNT, "rewardbkt"_n, "rewardbkt"_n); @@ -640,6 +673,7 @@ BOOST_FIXTURE_TEST_CASE(applyswap_requires_uwrit_auth, sysio_reserve_tester) { t ("dst_token_code", codename_mvo("SOL")) ("dst_reserve_code", codename_mvo("PRIMARY")) ("dst_amount", 50) + ("underwriter", "underwriter1") ).find("missing authority of sysio.uwrit") != std::string::npos); } FC_LOG_AND_RETHROW() } @@ -658,7 +692,8 @@ BOOST_FIXTURE_TEST_CASE(applyswap_applies_four_legs, sysio_reserve_tester) { try ("dst_chain_code", codename_mvo("SOLANA")) ("dst_token_code", codename_mvo("SOL")) ("dst_reserve_code", codename_mvo("PRIMARY")) - ("dst_amount", 50))); + ("dst_amount", 50) + ("underwriter", "underwriter1"))); auto src = find_reserve("ETH", "ETH", "PRIMARY"); auto dst = find_reserve("SOLANA", "SOL", "PRIMARY"); @@ -681,7 +716,7 @@ BOOST_FIXTURE_TEST_CASE(applyswap_charges_fee_and_routes_50_50, sysio_reserve_te const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); // w_gross = cp_output(1e12, 1e12, 1e9) = 999'000'999 (50/50 = constant product). - // fee = 999'000'999 * 10 / 10000 = 999'000 ; reward = emis = 499'500 ; + // fee = 999'000'999 * 10 / 10000 = 999'000 ; underwriter = reward = 499'500 ; // net = 999'000'999 - 999'000 = 998'001'999. BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() ("src_chain_code", codename_mvo("ETH")) @@ -691,7 +726,8 @@ BOOST_FIXTURE_TEST_CASE(applyswap_charges_fee_and_routes_50_50, sysio_reserve_te ("dst_chain_code", codename_mvo("SOLANA")) ("dst_token_code", codename_mvo("SOL")) ("dst_reserve_code", codename_mvo("PRIMARY")) - ("dst_amount", 100'000'000ULL))); + ("dst_amount", 100'000'000ULL) + ("underwriter", "underwriter1"))); auto src = find_reserve("ETH", "ETH", "PRIMARY"); auto dst = find_reserve("SOLANA", "SOL", "PRIMARY"); @@ -701,18 +737,410 @@ BOOST_FIXTURE_TEST_CASE(applyswap_charges_fee_and_routes_50_50, sysio_reserve_te BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL + 1'000'000'000ULL, src["reserve_chain_amount"].as_uint64()); BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL - 100'000'000ULL, dst["reserve_chain_amount"].as_uint64()); - // Fee routed 50/50: reward half accrues in the bucket (stays in custody), - // emissions half is transferred to `sysio`. + // Fee split 50/50: half accrues to the winning underwriter's claimable row, + // half to the batch-operator rewards bucket. + auto uwf = get_uwfees(UNDERWRITER_ACCOUNT); + BOOST_REQUIRE_EQUAL(499'500ULL, uwf["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(499'500ULL, uwf["lifetime_accrued"].as_uint64()); + BOOST_REQUIRE_EQUAL(0ULL, uwf["lifetime_claimed"].as_uint64()); + auto bkt = get_rewardbkt(); BOOST_REQUIRE_EQUAL(499'500ULL, bkt["balance"].as_uint64()); BOOST_REQUIRE_EQUAL(499'500ULL, bkt["lifetime_accrued"].as_uint64()); - BOOST_REQUIRE_EQUAL(sysio_before + 499'500, wire_balance(SYSIO_ACCOUNT)); // emissions half left custody - BOOST_REQUIRE_EQUAL(resv_before - 499'500, wire_balance(RESERVE_ACCOUNT)); // only emissions half left + + // BOTH halves stay in reserv custody — no part of a swap fee reaches the + // emissions treasury, so neither real balance moves at settlement. + BOOST_REQUIRE_EQUAL(sysio_before, wire_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(resv_before, wire_balance(RESERVE_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_emissions_share_routes_pool_to_treasury, sysio_reserve_tester) { try { + // Stage 2 of the split is a governance dial: a non-zero + // `fee_emissions_share_bps` diverts that share of the REWARDS POOL (the half + // left after the underwriter's cut) out of custody to the `sysio` treasury. + BOOST_REQUIRE(push_action("alice"_n, "setconfig"_n, mvo()("fee_emissions_share_bps", 5000)) + .find("missing authority of sysio.reserv") != std::string::npos); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: setconfig: fee_emissions_share_bps must be <= 10000 (100% of the rewards pool)"), + push_action(RESERVE_ACCOUNT, "setconfig"_n, mvo()("fee_emissions_share_bps", 10001))); + + // Send the WHOLE rewards pool to the treasury so the split is unambiguous. + BOOST_REQUIRE_EQUAL(success(), push_action(RESERVE_ACCOUNT, "setconfig"_n, + mvo()("fee_emissions_share_bps", 10000))); + + BOOST_REQUIRE_EQUAL(success(), + regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); + BOOST_REQUIRE_EQUAL(success(), + regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); + + const int64_t sysio_before = wire_balance(SYSIO_ACCOUNT); + const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); + + // Same swap as the 50/50 test: fee 999'000, underwriter 499'500, pool 499'500. + BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() + ("src_chain_code", codename_mvo("ETH")) + ("src_token_code", codename_mvo("ETH")) + ("src_reserve_code", codename_mvo("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("dst_chain_code", codename_mvo("SOLANA")) + ("dst_token_code", codename_mvo("SOL")) + ("dst_reserve_code", codename_mvo("PRIMARY")) + ("dst_amount", 100'000'000ULL) + ("underwriter", "underwriter1"))); + + // The underwriter's half is untouched by this dial and stays in custody. + BOOST_REQUIRE_EQUAL(499'500ULL, get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64()); + // The whole pool went to the treasury instead of the rewards bucket, and it + // is the ONLY part of the fee that left custody. + BOOST_REQUIRE(get_rewardbkt().is_null()); + BOOST_REQUIRE_EQUAL(sysio_before + 499'500, wire_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(resv_before - 499'500, wire_balance(RESERVE_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + +// ── Reserve OWNER fee: per-reserve rate, accrual, and owner claim (WIRE-281) ── + +BOOST_FIXTURE_TEST_CASE(setrsvfee_guards_owner_status_and_bounds, sysio_reserve_tester) { try { + // Owner-less (bootstrap-seeded) reserve: nobody can authorize a fee, so no + // WIRE can ever accrue with no claimant. + BOOST_REQUIRE_EQUAL(success(), regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: setrsvfee: reserve has no owner"), + push_action(RESERVE_ACCOUNT, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 100))); + + // An OWNED reserve: only the owner may set the fee. + BOOST_REQUIRE_EQUAL(success(), + regreserve("SOLANA", "SOL", "PRIMARY", 1000, 1000, 5000, false, "alice"_n)); + auto setFee = [&](name signer, uint32_t bps) { + return push_action(signer, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("SOLANA"))("token_code", codename_mvo("SOL")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", bps)); + }; + BOOST_REQUIRE(setFee(UNDERWRITER_ACCOUNT, 100).find("missing authority of alice") != std::string::npos); + + // 0 disables; the [1, 9900] band is accepted at both ends; past 99% rejected. + BOOST_REQUIRE_EQUAL(success(), setFee("alice"_n, 0)); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), setFee("alice"_n, 1)); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), setFee("alice"_n, 9900)); + produce_block(); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: setrsvfee: owner_fee_bps must be 0 or within [1, 9900]"), + setFee("alice"_n, 9901)); + + // Unknown reserve fails before any auth work. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: setrsvfee: reserve not found"), + push_action("alice"_n, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("NOPE")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 10))); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(applyswap_charges_both_reserve_owner_fees, sysio_reserve_tester) { try { + // Jonathan, 2026-08-04: "per reserve" — a chain-to-chain swap pays 3 fees, + // two reserve owners plus the network. + BOOST_REQUIRE_EQUAL(success(), + regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, + 5000, false, "alice"_n)); + BOOST_REQUIRE_EQUAL(success(), + regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, + 5000, false, UNDERWRITER_ACCOUNT)); + + // src 100 bps (1%), dst 200 bps (2%) on the same WIRE leg. + BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 100))); + BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("SOLANA"))("token_code", codename_mvo("SOL")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 200))); + + const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); + + // w_gross = 999'000'999. network 10bps = 999'000; src 1% = 9'990'009; + // dst 2% = 19'980'019; net = w_gross - (999'000 + 9'990'009 + 19'980'019). + BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() + ("src_chain_code", codename_mvo("ETH")) + ("src_token_code", codename_mvo("ETH")) + ("src_reserve_code", codename_mvo("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("dst_chain_code", codename_mvo("SOLANA")) + ("dst_token_code", codename_mvo("SOL")) + ("dst_reserve_code", codename_mvo("PRIMARY")) + ("dst_amount", 100'000'000ULL) + ("underwriter", "underwriter1"))); + + auto src = find_reserve("ETH", "ETH", "PRIMARY"); + auto dst = find_reserve("SOLANA", "SOL", "PRIMARY"); + BOOST_REQUIRE_EQUAL(9'990'009ULL, src["owner_fee_accrued"].as_uint64()); + BOOST_REQUIRE_EQUAL(9'990'009ULL, src["owner_fee_lifetime"].as_uint64()); + BOOST_REQUIRE_EQUAL(19'980'019ULL, dst["owner_fee_accrued"].as_uint64()); + BOOST_REQUIRE_EQUAL(19'980'019ULL, dst["owner_fee_lifetime"].as_uint64()); + + // Destination liquidity is the leg minus ALL THREE fees. + const uint64_t total_fee = 999'000ULL + 9'990'009ULL + 19'980'019ULL; + BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL + (999'000'999ULL - total_fee), + dst["reserve_wire_amount"].as_uint64()); + + // Every fee stayed in custody — the reserve shares as accruals, the network + // fee as the underwriter accrual + rewards bucket. + BOOST_REQUIRE_EQUAL(resv_before, wire_balance(RESERVE_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(single_reserve_paths_charge_only_their_own_side, sysio_reserve_tester) { try { + // A WIRE endpoint has no reserve, so only the one participating reserve + // charges: paywire → source, applyfromwire → destination. + BOOST_REQUIRE_EQUAL(success(), + regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, + 5000, false, "alice"_n)); + BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 100))); + + BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "paywire"_n, mvo() + ("src_chain_code", codename_mvo("ETH")) + ("src_token_code", codename_mvo("ETH")) + ("src_reserve_code", codename_mvo("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("recipient", "alice") + ("wire_out", 100'000'000ULL) + ("underwriter", "underwriter1"))); + + // 1% of the 999'000'999 gross leg accrued to the source reserve's owner. + BOOST_REQUIRE_EQUAL(9'990'009ULL, + find_reserve("ETH", "ETH", "PRIMARY")["owner_fee_accrued"].as_uint64()); + + // applyfromwire: the destination reserve is the only one that charges. + BOOST_REQUIRE_EQUAL(success(), + regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, + 5000, false, UNDERWRITER_ACCOUNT)); + BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("SOLANA"))("token_code", codename_mvo("SOL")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 200))); + BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyfromwire"_n, mvo() + ("dst_chain_code", codename_mvo("SOLANA")) + ("dst_token_code", codename_mvo("SOL")) + ("dst_reserve_code", codename_mvo("PRIMARY")) + ("wire_in", 1'000'000'000ULL) + ("dst_amount", 100'000'000ULL) + ("underwriter", "underwriter1"))); + + // 2% of the 1e9 escrow. The source reserve is untouched by this swap. + BOOST_REQUIRE_EQUAL(20'000'000ULL, + find_reserve("SOLANA", "SOL", "PRIMARY")["owner_fee_accrued"].as_uint64()); + BOOST_REQUIRE_EQUAL(9'990'009ULL, + find_reserve("ETH", "ETH", "PRIMARY")["owner_fee_accrued"].as_uint64()); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(claimrsvfee_pays_owner_and_guards_auth, sysio_reserve_tester) { try { + BOOST_REQUIRE_EQUAL(success(), + regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, + 5000, false, "alice"_n)); + + auto claim = [&](name signer) { + return push_action(signer, "claimrsvfee"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("PRIMARY"))); + }; + // Nothing earned yet. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: claimrsvfee: no unclaimed balance"), claim("alice"_n)); + + BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 100))); + BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "paywire"_n, mvo() + ("src_chain_code", codename_mvo("ETH")) + ("src_token_code", codename_mvo("ETH")) + ("src_reserve_code", codename_mvo("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("recipient", UNDERWRITER_ACCOUNT) + ("wire_out", 100'000'000ULL) + ("underwriter", "underwriter1"))); + + const int64_t alice_before = wire_balance("alice"_n), + resv_before = wire_balance(RESERVE_ACCOUNT); + BOOST_REQUIRE_EQUAL(9'990'009ULL, + find_reserve("ETH", "ETH", "PRIMARY")["owner_fee_accrued"].as_uint64()); + + // Only the owner may sweep it. + BOOST_REQUIRE(claim(UNDERWRITER_ACCOUNT).find("missing authority of alice") != std::string::npos); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + + BOOST_REQUIRE_EQUAL(alice_before + 9'990'009, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(resv_before - 9'990'009, wire_balance(RESERVE_ACCOUNT)); + + auto row = find_reserve("ETH", "ETH", "PRIMARY"); + BOOST_REQUIRE_EQUAL(0ULL, row["owner_fee_accrued"].as_uint64()); + BOOST_REQUIRE_EQUAL(9'990'009ULL, row["owner_fee_lifetime"].as_uint64()); // audit total survives + + produce_block(); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: claimrsvfee: no unclaimed balance"), claim("alice"_n)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(swapquote_prices_the_reserve_owner_fees, sysio_reserve_tester) { try { + // The read-only quote must price EXACTLY what settlement charges, reserve + // fees included — otherwise the variance check drifts against the books. + // So this asserts the DECODED quote against the shared AMM kernel, not just + // that the action succeeded: a quote that ignored `owner_fee_bps` entirely + // would still return success. + constexpr uint64_t POOL = 1'000'000'000'000ULL; + constexpr uint32_t WEIGHT = 5000; + constexpr uint64_t FROM = 1'000'000'000ULL; + constexpr uint32_t OWNER_FEE = 500; // 5%, charged by EACH side's reserve + // `sysio.uwrit` exists as a bare account in this fixture (no contract), so + // the contract's `uwrit_fee_bps()` reads the `uw_config{}` in-struct default. + constexpr uint32_t NETWORK_FEE_BPS = 10; + + BOOST_REQUIRE_EQUAL(success(), + regreserve("ETH", "ETH", "PRIMARY", POOL, POOL, WEIGHT, false, "alice"_n)); + BOOST_REQUIRE_EQUAL(success(), + regreserve("SOLANA", "SOL", "PRIMARY", POOL, POOL, WEIGHT, false, UNDERWRITER_ACCOUNT)); + + /// The kernel's answer for these pools at a given pair of reserve fees. + auto expected = [&](uint32_t src_fee, uint32_t dst_fee) { + return opp::amm::quote_swap(/*src_is_wire*/false, POOL, POOL, WEIGHT, + /*dst_is_wire*/false, POOL, POOL, WEIGHT, + FROM, NETWORK_FEE_BPS, src_fee, dst_fee); + }; + + // Fee-free baseline: network fee only. + const uint64_t before = swapquote_value("ETH", "ETH", "PRIMARY", FROM, + "SOLANA", "SOL", "PRIMARY"); + BOOST_REQUIRE_GT(before, 0u); + BOOST_CHECK_EQUAL(before, expected(0, 0)); + + BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("ETH")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", OWNER_FEE))); + BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("SOLANA"))("token_code", codename_mvo("SOL")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", OWNER_FEE))); + produce_block(); + + // Both owner fees are now priced in, off the same gross WIRE leg. + const uint64_t after = swapquote_value("ETH", "ETH", "PRIMARY", FROM, + "SOLANA", "SOL", "PRIMARY"); + BOOST_CHECK_EQUAL(after, expected(OWNER_FEE, OWNER_FEE)); + BOOST_CHECK_LT(after, before); // the claim this case exists to prove + + // Each side is priced INDEPENDENTLY — clearing one must move the quote back + // by only that side's share, which a quote summing the wrong reserve's rate + // (or double-counting one) would not reproduce. + BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() + ("chain_code", codename_mvo("SOLANA"))("token_code", codename_mvo("SOL")) + ("reserve_code", codename_mvo("PRIMARY"))("owner_fee_bps", 0))); + produce_block(); + const uint64_t source_only = swapquote_value("ETH", "ETH", "PRIMARY", FROM, + "SOLANA", "SOL", "PRIMARY"); + BOOST_CHECK_EQUAL(source_only, expected(OWNER_FEE, 0)); + BOOST_CHECK_LT(source_only, before); + BOOST_CHECK_GT(source_only, after); +} FC_LOG_AND_RETHROW() } + +// ── Underwriter fee accrual + owner-authenticated claim ── + +BOOST_FIXTURE_TEST_CASE(claimuwfee_pays_accrual_and_zeroes_balance, sysio_reserve_tester) { try { + BOOST_REQUIRE_EQUAL(success(), + regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); + BOOST_REQUIRE_EQUAL(success(), + regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); + BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() + ("src_chain_code", codename_mvo("ETH")) + ("src_token_code", codename_mvo("ETH")) + ("src_reserve_code", codename_mvo("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("dst_chain_code", codename_mvo("SOLANA")) + ("dst_token_code", codename_mvo("SOL")) + ("dst_reserve_code", codename_mvo("PRIMARY")) + ("dst_amount", 100'000'000ULL) + ("underwriter", "underwriter1"))); + + const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); + BOOST_REQUIRE_EQUAL(0, wire_balance(UNDERWRITER_ACCOUNT)); + BOOST_REQUIRE_EQUAL(499'500ULL, get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64()); + + // The earner claims their own accrual. + BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "claimuwfee"_n, + mvo()("underwriter", "underwriter1"))); + + // REAL WIRE moved out of reserv custody to the underwriter. + BOOST_REQUIRE_EQUAL(499'500, wire_balance(UNDERWRITER_ACCOUNT)); + BOOST_REQUIRE_EQUAL(resv_before - 499'500, wire_balance(RESERVE_ACCOUNT)); + + // Row retained at zero balance; lifetime totals record the round trip. + auto uwf = get_uwfees(UNDERWRITER_ACCOUNT); + BOOST_REQUIRE_EQUAL(0ULL, uwf["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(499'500ULL, uwf["lifetime_accrued"].as_uint64()); + BOOST_REQUIRE_EQUAL(499'500ULL, uwf["lifetime_claimed"].as_uint64()); + + // A second claim on the drained row is a caller error, not a silent no-op. + // Produce a block first: an identical action re-pushed against the same TAPOS + // reference block serializes to the same tx id and is rejected as a duplicate + // before it ever reaches the contract. + produce_block(); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: claimuwfee: no unclaimed balance"), + push_action(UNDERWRITER_ACCOUNT, "claimuwfee"_n, mvo()("underwriter", "underwriter1"))); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(claimuwfee_requires_earner_auth_and_a_row, sysio_reserve_tester) { try { + // Nobody else may sweep an underwriter's accrual. + BOOST_REQUIRE(push_action("alice"_n, "claimuwfee"_n, mvo()("underwriter", "underwriter1")) + .find("missing authority of underwriter1") != std::string::npos); + + // An underwriter that never earned has no row at all. + BOOST_REQUIRE(get_uwfees(UNDERWRITER_ACCOUNT).is_null()); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: claimuwfee: no accrued swap fees for this underwriter"), + push_action(UNDERWRITER_ACCOUNT, "claimuwfee"_n, mvo()("underwriter", "underwriter1"))); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(applyswap_accrues_per_underwriter_and_accumulates, sysio_reserve_tester) { try { + BOOST_REQUIRE_EQUAL(success(), + regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); + BOOST_REQUIRE_EQUAL(success(), + regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); + + auto swap_won_by = [&](const char* underwriter) { + return push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() + ("src_chain_code", codename_mvo("ETH")) + ("src_token_code", codename_mvo("ETH")) + ("src_reserve_code", codename_mvo("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("dst_chain_code", codename_mvo("SOLANA")) + ("dst_token_code", codename_mvo("SOL")) + ("dst_reserve_code", codename_mvo("PRIMARY")) + ("dst_amount", 100'000'000ULL) + ("underwriter", underwriter)); + }; + + // Two swaps won by the same underwriter accumulate on one row. Each repeat of + // an identical action needs its own block — same TAPOS reference + same bytes + // is one tx id, rejected as a duplicate before reaching the contract. + BOOST_REQUIRE_EQUAL(success(), swap_won_by("underwriter1")); + const uint64_t first = get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64(); + BOOST_REQUIRE_GT(first, 0u); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), swap_won_by("underwriter1")); + auto after_two = get_uwfees(UNDERWRITER_ACCOUNT); + BOOST_REQUIRE_GT(after_two["balance"].as_uint64(), first); + BOOST_REQUIRE_EQUAL(after_two["balance"].as_uint64(), + after_two["lifetime_accrued"].as_uint64()); + + // A different winner accrues to their OWN row, leaving the first untouched. + // (Different `underwriter` bytes, so no duplicate-tx risk here.) + const uint64_t before_other = after_two["balance"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), swap_won_by("alice")); + BOOST_REQUIRE_GT(get_uwfees("alice"_n)["balance"].as_uint64(), 0u); + BOOST_REQUIRE_EQUAL(before_other, get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64()); } FC_LOG_AND_RETHROW() } -// ── drainrewards: sweep the accrued rewards half to the emissions treasury ── +// ── drainrewards: sweep the accrued batch-operator share to the emissions treasury ── // payepoch (sysio.system) calls this inline to fold swap fees into the per-epoch -// producer + batch-operator distribution. +// batch-operator distribution. BOOST_FIXTURE_TEST_CASE(drainrewards_sweeps_bucket_to_treasury, sysio_reserve_tester) { try { // Seed the rewards bucket with a swap fee (same setup as the 50/50 routing test: @@ -729,7 +1157,8 @@ BOOST_FIXTURE_TEST_CASE(drainrewards_sweeps_bucket_to_treasury, sysio_reserve_te ("dst_chain_code", codename_mvo("SOLANA")) ("dst_token_code", codename_mvo("SOL")) ("dst_reserve_code", codename_mvo("PRIMARY")) - ("dst_amount", 100'000'000ULL))); + ("dst_amount", 100'000'000ULL) + ("underwriter", "underwriter1"))); auto bkt = get_rewardbkt(); const uint64_t reward = bkt["balance"].as_uint64(); @@ -779,7 +1208,8 @@ BOOST_FIXTURE_TEST_CASE(applyfromwire_credits_wire_and_debits_chain, sysio_reser ("dst_token_code", codename_mvo("SOL")) ("dst_reserve_code", codename_mvo("PRIMARY")) ("wire_in", 200) - ("dst_amount", 100))); + ("dst_amount", 100) + ("underwriter", "underwriter1"))); auto r = find_reserve("SOLANA", "SOL", "PRIMARY"); BOOST_REQUIRE_EQUAL(1200, r["reserve_wire_amount"].as_uint64()); @@ -799,7 +1229,8 @@ BOOST_FIXTURE_TEST_CASE(paywire_pays_real_wire_from_custody, sysio_reserve_teste ("src_reserve_code", codename_mvo("PRIMARY")) ("src_amount", 100) ("recipient", "alice") - ("wire_out", 200))); + ("wire_out", 200) + ("underwriter", "underwriter1"))); auto r = find_reserve("ETH", "ETH", "PRIMARY"); BOOST_REQUIRE_EQUAL(1100, r["reserve_chain_amount"].as_uint64()); @@ -821,7 +1252,8 @@ BOOST_FIXTURE_TEST_CASE(paywire_rejects_overdraw, sysio_reserve_tester) { try { ("src_reserve_code", codename_mvo("PRIMARY")) ("src_amount", 100) ("recipient", "alice") - ("wire_out", 200))); + ("wire_out", 200) + ("underwriter", "underwriter1"))); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(refundwire_returns_escrow, sysio_reserve_tester) { try { @@ -845,11 +1277,12 @@ BOOST_FIXTURE_TEST_CASE(refundwire_returns_escrow, sysio_reserve_tester) { try { BOOST_REQUIRE(get_rewardbkt().is_null()); // no fee — nothing accrued } FC_LOG_AND_RETHROW() } -// A nonzero revert fee (caller-fault drain revert) is taken out of the refund -// and routed exactly like a settlement fee: rewards share into the bucket -// (custody-internal), emissions share transferred to the treasury. Integer -// split: 10% of 150 = 15 -> rewards floor(15/2) = 7, emissions 8; the shares -// sum to the fee exactly. +// A nonzero revert fee (caller-fault drain revert) is taken out of the refund and +// routed exactly like a settlement fee, through `route_wire_fee`. Arithmetic here: +// 10% of 150 = 15, and a revert has no winning underwriter, so the whole 15 is the +// rewards pool. This fixture never calls `setconfig`, so `fee_emissions_share_bps` +// is the default 0 and all 15 accrues to `rewards_bucket` — custody-internal, with +// nothing transferred to the emissions treasury. BOOST_FIXTURE_TEST_CASE(refundwire_routes_revert_fee, sysio_reserve_tester) { try { BOOST_REQUIRE_EQUAL(success(), regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); @@ -860,12 +1293,16 @@ BOOST_FIXTURE_TEST_CASE(refundwire_routes_revert_fee, sysio_reserve_tester) { tr ("revert_fee_bps", 1000))); // 10% BOOST_REQUIRE_EQUAL(135, wire_balance("alice"_n)); // 150 - 15 fee - BOOST_REQUIRE_EQUAL(857, wire_balance(RESERVE_ACCOUNT)); // 1000 - 135 - 8 emissions + BOOST_REQUIRE_EQUAL(865, wire_balance(RESERVE_ACCOUNT)); // 1000 - 135; the fee stays in custody + // The whole fee, per the header: no underwriter share to carve out, and a + // zero emissions dial leaves the pool intact. A configured dial would divert + // its share to the emissions treasury instead. auto bkt = get_rewardbkt(); BOOST_REQUIRE(!bkt.is_null()); - BOOST_REQUIRE_EQUAL(7u, bkt["balance"].as_uint64()); - BOOST_REQUIRE_EQUAL(7u, bkt["lifetime_accrued"].as_uint64()); + BOOST_REQUIRE_EQUAL(15u, bkt["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(15u, bkt["lifetime_accrued"].as_uint64()); + BOOST_REQUIRE(get_uwfees(UNDERWRITER_ACCOUNT).is_null()); // nobody underwrote it auto r = find_reserve("ETH", "ETH", "PRIMARY"); BOOST_REQUIRE_EQUAL(1000, r["reserve_wire_amount"].as_uint64()); // untouched diff --git a/docs/platform-bootstrap-config.md b/docs/platform-bootstrap-config.md index 7f1a29430c..2ad65d3aa8 100644 --- a/docs/platform-bootstrap-config.md +++ b/docs/platform-bootstrap-config.md @@ -70,8 +70,8 @@ lifecycle fields that are outputs. A hand-authored config wants the opposite: |---|---| | `ChainSpec` | `sysio.chains::regchain(kind, code, external_chain_id, name, description)` | | `TokenSpec` | `sysio.tokens::regtoken(kind, code, symbol_name, description, precision, address)` then `sysio.tokens::regctok(chain_code, token_code, contract_addr, is_native)` | -| `ReserveSpec` | `sysio.reserv::regreserve(chain_code, token_code, reserve_code, name, description, initial_chain_amount, initial_wire_amount, connector_weight_bps, is_private, owner)` | -| `UwritConfig` | `sysio.uwrit::setconfig(fee_bps, collateral_lock_duration_ms)` | +| `ReserveSpec` | `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)` — `source_token_precision` is **not** a `ReserveSpec` field: it comes from the referenced `TokenSpec.precision` (see below) | +| `UwritConfig` | `sysio.uwrit::setconfig(fee_bps, collateral_lock_duration_ms, min_fromwire_amount, fromwire_revert_fee_bps, uwreq_pending_timeout_epochs, uwreq_retention_epochs)` — the spec pins the first two; the caller supplies the rest (see below) | | `t5_reserve_allocation` | none — feeds the `setemitcfg` arithmetic below | | `t5_dex_allocation` | none yet — reserved earmark; carved out of T5 alongside `t5_reserve_allocation` once the DEX-seeding path lands | @@ -80,6 +80,37 @@ The `regreserve` signature (with `is_private` + `owner`) and the ms-based onto them. `owner` is a WIRE account name (`sysio::name`): empty for public reserves, the owning account for private ones. +`regreserve`'s eleven-argument signature includes `source_token_precision` as its +**eighth** argument, which no `ReserveSpec` field supplies. It is the +**depot-frame precision of the paired +token** — i.e. the referenced `TokenSpec.precision`, which is itself +`min(native precision, 9)`. So the mapping is a lookup, not a new config field: +resolve `(chain_code, token_code)` to its `TokenSpec` and pass that spec's +`precision`. `regreserve` rejects a value above 9 (`WIRE_PRECISION`) with +*"source_token_precision exceeds the depot frame (9) — the outpost must downscale +to min(native, 9)"*, which is why V4 bounds `precision` at 9 rather than 18: +a token declared at, say, 18 would satisfy a wider validator and then abort the +irreversible bootstrap at `regtoken`/`regreserve`. Tokens whose native precision +exceeds the frame (ETH at 18) declare the frame value and are downscaled at the +outpost boundary. + +`UwritConfig` carries only `fee_bps` and `collateral_lock_duration_ms`, while +`setconfig` takes six arguments. The remaining four — +`min_fromwire_amount`, `fromwire_revert_fee_bps`, `uwreq_pending_timeout_epochs`, +`uwreq_retention_epochs` — are **not part of this spec**: a bootstrap caller +passes 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. + +`fee_bps` is the **network** fee and not the whole effective swap fee. Each +participating non-WIRE leg's reserve independently charges its own +`owner_fee_bps` off the same WIRE leg; that rate is **not** in `ReserveSpec` and +is set post-bootstrap via `sysio.reserv::setrsvfee`. The network fee itself +splits 50/50 between the winning underwriter and a rewards pool +(`sysio.reserv::FEE_UNDERWRITER_SHARE_BPS`), and that pool splits again by the +optional `reservcfg` dial `fee_emissions_share_bps` — which nothing seeds, so it +reads zero until `sysio.reserv::setconfig` first persists a row. + ## T5 reserve earmark With `A` = the launch T5 allotment, `E` = `t5_reserve_allocation`, and @@ -135,7 +166,7 @@ because the file is hand-authored and drives irreversible actions. A validator | V1 | `schema_version == 1`; `network` non-empty | | V2 | every code is a valid slug (`[A-Z0-9_]`, ≤ 8 chars) | | V3 | chain codes unique; exactly one `CHAIN_KIND_WIRE` chain, code `WIRE` | -| V4 | token codes unique; `chain_code` declared; `precision` ∈ 1..18; native ⇔ kind `NATIVE` + empty address; non-native address well-formed for the chain kind (EVM `0x`+40 hex; SVM base58 → 32 bytes) | +| V4 | token codes unique; `chain_code` declared; `precision` ∈ 1..9 (the depot frame — `sysio.tokens::regtoken` rejects anything higher, so a wider bound here would pass validation and then fail mid-bootstrap); 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 | reserve `(chain, token, code)` unique; references a declared binding; not on the depot; `0 < connector_weight_bps ≤ 9999`; amounts > 0 | | V7 | Σ `initial_wire_amount` ≤ `t5_reserve_allocation` > 0 | diff --git a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto index 542a267c88..51690a3ae6 100644 --- a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto +++ b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto @@ -74,7 +74,15 @@ message TokenSpec { string symbol_name = 3; // Free-form description / provenance note. string description = 4; - // Decimal precision; convention is 9 across the depot frame. + // DEPOT-FRAME decimal precision, i.e. `min(native precision, 9)` — NOT the + // token's native precision. Valid range 1..9: `sysio.tokens::regtoken` rejects + // 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`. uint32 precision = 5; // Chain this token binds to — must reference a declared `ChainSpec.code`. string chain_code = 6; @@ -88,7 +96,10 @@ message TokenSpec { // A launch reserve to seed via // `sysio.reserv::regreserve(chain_code, token_code, reserve_code, name, // description, initial_chain_amount, initial_wire_amount, -// connector_weight_bps, is_private, owner)`. +// 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 @@ -101,6 +112,12 @@ message ReserveSpec { 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; @@ -126,13 +143,33 @@ message ReserveSpec { string owner = 10; } -// Global swap settings applied once via -// `sysio.uwrit::setconfig(fee_bps, collateral_lock_duration_ms)`. +// 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: // -// The collected swap fee is taken out of the WIRE leg of every swap and split -// 50/50 between an on-chain rewards bucket and the emissions treasury inside -// `sysio.reserv` (a fixed protocol constant, FEE_REWARD_SHARE_BPS), so no -// fee-distribution shares are configured here. +// * `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. @@ -142,8 +179,9 @@ message UwritConfig { 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; the - // fee split is now the fixed 50/50 rewards/emissions constant in sysio.reserv. + // 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"; } diff --git a/libraries/opp/test/test_bootstrap_platform_config.cpp b/libraries/opp/test/test_bootstrap_platform_config.cpp index 4e7566d91e..f22bea6d11 100644 --- a/libraries/opp/test/test_bootstrap_platform_config.cpp +++ b/libraries/opp/test/test_bootstrap_platform_config.cpp @@ -136,7 +136,13 @@ std::vector validate(const BootstrapPlatformConfig& c) { e.push_back("V4 token " + t.code() + " references undeclared chain " + t.chain_code()); continue; } - if (t.precision() < 1 || t.precision() > 18) e.push_back("V4 token precision: " + t.code()); + // 1..9, NOT 1..18: `TokenSpec.precision` is the DEPOT-FRAME precision, and + // `sysio.tokens::regtoken` rejects anything above MAX_TOKEN_PRECISION (9). + // Accepting 10..18 here let a config pass strict validation and then throw + // partway through the IRREVERSIBLE bootstrap actions. A token whose native + // 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()]; @@ -252,6 +258,17 @@ BOOST_AUTO_TEST_CASE(validator_rejects_mutations) { tok->set_kind(TokenKind::TOKEN_KIND_NATIVE); tok->clear_contract_address(); BOOST_CHECK(!validate(c).empty()); } + // V4 precision is bounded by the DEPOT FRAME (9), not asset's 18. A token + // declared above the frame is rejected by `sysio.tokens::regtoken`, so + // accepting it here would pass validation and then abort the IRREVERSIBLE + // bootstrap. 10 is the first rejected value; 18 (ETH's native precision, which + // must be declared downscaled as 9) is well past it. + { auto c = base; c.mutable_tokens(0)->set_precision(10); + BOOST_CHECK(!validate(c).empty()); } // V4 one past the frame + { auto c = base; c.mutable_tokens(0)->set_precision(18); + 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);