diff --git a/OVERVIEW.md b/OVERVIEW.md index 592b8d5af9..f96fc5f80a 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -84,7 +84,7 @@ Elected via Appointed Proof of Stake (APoS). They validate transactions and prod Operators scheduled in groups of `operators_per_epoch` (default 21 operators in 3 groups of 7); one group is on duty per epoch, and the schedule slides forward every epoch. They run the `batch_operator_plugin` and are responsible for: - Reading each outpost's outbound envelope and delivering it to `sysio.msgch` - Relaying the depot's outbound envelopes to each outpost -- Cranking the depot: `sysio.msgch::chkcons` (epoch advance), `sysio.chalg::chkdispute`, `sysio.swap::tickyield`, `sysio.liq::queueyield` +- Cranking the depot: `sysio.msgch::chkcons` (epoch advance), `sysio.chalg::chkdispute`, `sysio.swap::tickyield`, `sysio.liq::queueyield`, `sysio.kicker::kick` Batch operators post depot-native collateral on `sysio.opreg`; a non-canonical delivery is slashed and missed deliveries lead to termination. @@ -110,6 +110,7 @@ Holders of a node-owner NFT who commit it on the Ethereum outpost (`NODE_OWNER_R | **sysio.bond** | Generic bonded underwriting of provable statements | | **sysio.liq** | Shadow liq tokens (`LIQETH`, `LIQSOL`) minted against outpost custody, with a WIRE yield index | | **sysio.swap** | Depot-local constant-product AMM; every pair's second leg is WIRE | +| **sysio.kicker** | Budgeted simple-accrual T5 WIRE gifts to LIQ holders, priced by swap TWAP with bounded accrual and daily payouts | | **sysio.dclaim** | Funded WIRE launch import, account linking and claims | | **sysio.andon** | Emergency stop: one cord freezing depot fund egress | | **sysio.roa** | Resources and node-owner registry | diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index ab2759f5ea..588d6bd26d 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -59,6 +59,7 @@ add_subdirectory(sysio.token) add_subdirectory(sysio.wrap) add_subdirectory(sysio.swap) add_subdirectory(sysio.liq) +add_subdirectory(sysio.kicker) add_subdirectory(sysio.bond) add_subdirectory(sysio.synd) add_subdirectory(test_contracts) diff --git a/contracts/sysio.kicker/CMakeLists.txt b/contracts/sysio.kicker/CMakeLists.txt new file mode 100644 index 0000000000..5b7784a3cb --- /dev/null +++ b/contracts/sysio.kicker/CMakeLists.txt @@ -0,0 +1,30 @@ +# Register committed artifacts in host builds and compile with CDT in contract builds. +set(contract_name sysio.kicker) +bootstrap_contract(${contract_name}) +if(BUILD_SYSTEM_CONTRACTS) + find_cdt_magic_enum() + set(SOURCES src/sysio.kicker.cpp) + add_contract(${contract_name} ${contract_name} ${SOURCES}) + set(targets ${contract_name}) + if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) + list(APPEND targets ${contract_name}_native) + add_native_contract( + TARGET ${contract_name}_native + SOURCES ${SOURCES} + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include + CONTRACT_CLASS "sysio::kicker" + HEADERS "sysio.kicker/sysio.kicker.hpp" + ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi) + endif() + foreach(target ${targets}) + target_include_directories(${target} PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_BINARY_DIR}/../../libraries/opp/generated-cdt + ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opp.common/include + ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.andon/include + ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.liq/include + ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.token/include + ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.system/include) + target_link_libraries(${target} INTERFACE magic_enum::magic_enum) + endforeach() +endif() diff --git a/contracts/sysio.kicker/include/sysio.kicker/kicker_math.hpp b/contracts/sysio.kicker/include/sysio.kicker/kicker_math.hpp new file mode 100644 index 0000000000..1f3d768bb2 --- /dev/null +++ b/contracts/sysio.kicker/include/sysio.kicker/kicker_math.hpp @@ -0,0 +1,85 @@ +#pragma once +/** + * @file kicker_math.hpp + * @brief Integer-only simple accrual, floored twice in favour of the treasury. + */ +#include +#include +#include + +namespace sysio::kicker_math { +using u128 = unsigned __int128; +/// Governance rates are basis points; one Julian year is exactly 365.25 days. +inline constexpr uint64_t bps_scale = 10'000; +inline constexpr uint64_t year_sec = 31'557'600; +inline constexpr uint64_t micros_per_sec = 1'000'000; +inline constexpr uint64_t accrual_divisor = bps_scale * year_sec * micros_per_sec; +/// Asset amounts are bounded by the protocol, even when intermediate values are wider. +inline constexpr uint64_t max_amount = (uint64_t{1} << 62) - 1; +/// Default minimum unpaid accrual interval: one hour. +inline constexpr uint32_t default_min_interval_sec = 3600; +/// Default per-token annual simple rate: 2%. +inline constexpr uint16_t default_rate_bps = 200; +/// Default payment granularity: one nine-decimal WIRE. +inline constexpr uint64_t default_min_gift = 1'000'000'000; +/// UTC-sized day used for the mandatory per-pool spend ceiling. +inline constexpr uint64_t day_sec = 86'400; +/// Governance observation window defaults to one day and cannot be shorter than one minute. +inline constexpr uint32_t default_twap_window_sec = day_sec; +inline constexpr uint32_t min_twap_window_sec = 60; +/// Bound catch-up to 72 hours per payment, independently of the observation window. +inline constexpr uint64_t max_catchup_us = 3 * day_sec * micros_per_sec; +/// Governance may lower, but cannot exceed, 1,000 WIRE per pool per UTC day. +inline constexpr uint64_t max_daily_gift = 1'000'000'000'000; +/// Default spot-versus-TWAP guard: a kick pays only while spot is within 50% of the window TWAP. +inline constexpr uint16_t default_max_spot_deviation_bps = 5'000; + +/** Compute floor(S * bps * elapsed_us / (10000 * Julian_year_us)). + * Dividing S*bps first as quotient and remainder keeps both products below + * 2^128 for every asset, accepted rate and uint64 elapsed time. The caller + * applies the catch-up policy before this arithmetic helper. + */ +inline u128 interest(uint64_t supply, uint16_t rate_bps, uint64_t elapsed_us) { + const u128 annual = static_cast(supply) * rate_bps; + return (annual / accrual_divisor) * elapsed_us + + (annual % accrual_divisor) * elapsed_us / accrual_divisor; +} + +/** Price an interest amount at a supplied Q64.64 TWAP. + * Two 128-bit limbs hold the full product, including intervals so long that + * interest itself exceeds uint64. A result outside the asset range is a + * non-payable gift, never a wrapped or clipped transfer. + */ +inline std::optional gift_at_price(uint64_t supply, uint16_t rate_bps, uint64_t elapsed_us, + u128 price) { + const u128 amount = interest(supply, rate_bps, elapsed_us); + opp::twap::cumulative_price product{}, high{}; + opp::twap::accumulate(product, price, static_cast(amount)); + opp::twap::accumulate(high, price, static_cast(amount >> opp::twap::PRICE_FRACTION_BITS)); + const u128 shifted = high.lo << opp::twap::PRICE_FRACTION_BITS; + const u128 old_lo = product.lo; + product.lo += shifted; + product.hi += (high.hi << opp::twap::PRICE_FRACTION_BITS) + + (high.lo >> opp::twap::PRICE_FRACTION_BITS) + (product.lo < old_lo ? 1 : 0); + const u128 priced = product.lo >> opp::twap::PRICE_FRACTION_BITS; + if (product.hi != 0 || priced > max_amount) return std::nullopt; + return static_cast(priced); +} +/** True iff spot strays from the window TWAP by more than `max_deviation_bps` of the TWAP: + * |spot - twap| * 10000 > twap * max_deviation_bps, both sides exact in 256 bits (a Q64.64 price times a + * basis-point count can exceed 128 bits). At the threshold itself the spot does not deviate. A zero TWAP + * deviates from any nonzero spot. + */ +inline bool spot_deviates(u128 spot, u128 twap, uint16_t max_deviation_bps) { + const u128 deviation = spot > twap ? spot - twap : twap - spot; + opp::twap::cumulative_price measured{}, allowed{}; + opp::twap::accumulate(measured, deviation, bps_scale); + opp::twap::accumulate(allowed, twap, max_deviation_bps); + return measured.hi != allowed.hi ? measured.hi > allowed.hi : measured.lo > allowed.lo; +} +/// Ratio-priced entry point for host arithmetic tests. +inline std::optional gift(uint64_t supply, uint16_t rate_bps, uint64_t elapsed_us, + uint64_t pool_wire, uint64_t pool_shadow) { + return gift_at_price(supply, rate_bps, elapsed_us, opp::twap::price_fp(pool_wire, pool_shadow)); +} +} // namespace sysio::kicker_math diff --git a/contracts/sysio.kicker/include/sysio.kicker/sysio.kicker.hpp b/contracts/sysio.kicker/include/sysio.kicker/sysio.kicker.hpp new file mode 100644 index 0000000000..3b972fa945 --- /dev/null +++ b/contracts/sysio.kicker/include/sysio.kicker/sysio.kicker.hpp @@ -0,0 +1,112 @@ +#pragma once +/** + * @file sysio.kicker.hpp + * @brief Governance-budgeted T5 gifts to LIQ holders, triggered by any keeper. + * + * Deploy privileged with roa::setsyscode. Budget is an earmark outside emissions; + * no draw alters sysio.system::t5state.total_distributed. A configurable TWAP values at most + * 72 hours of unpaid accrual per kick, on the smaller of the LIQ supply at the last checkpoint and the + * current supply: a gift never exceeds the honest gift of the supply present at the last checkpoint, and + * LIQ minted after a checkpoint is sized from the next full payment or checkpointing reset. + */ +#include +#include +#include +#include +#include +#include + +namespace sysio { +/// Privileged treasury-to-shadow-yield contract; no donation-triggered payment path. +class [[sysio::contract("sysio.kicker")]] kicker : public contract { +public: + using contract::contract; + /// Governance authority and treasury account. + static constexpr name system_account = "sysio"_n; + /// Depot shadow token contract. + static constexpr name liq_account = "sysio.liq"_n; + /// Native WIRE token contract. + static constexpr name token_account = "sysio.token"_n; + + /// Governance budget in WIRE subunits, payment spacing, minimum TWAP observation span and the spot guard. + struct [[sysio::table("kickcfg")]] kick_config { + uint64_t budget_remaining = 0; ///< Absolute remaining earmark; setconfig replaces it. + /// Minimum unpaid accrual interval before an attempt. + uint32_t min_interval_sec = kicker_math::default_min_interval_sec; + /// Minimum observation span; the next kick uses this value even for an open observation. + uint32_t twap_window_sec = kicker_math::default_twap_window_sec; + /// Most spot may stray from the window TWAP, in basis points of the TWAP, for a kick to pay; 1..10000. + uint16_t max_spot_deviation_bps = kicker_math::default_max_spot_deviation_bps; + SYSLIB_SERIALIZE(kick_config, (budget_remaining)(min_interval_sec)(twap_window_sec)(max_spot_deviation_bps)) + }; + /// Symbol-code primary key for independently configured LIQ tokens. + struct pool_key { + uint64_t symbol_code; + SYSLIB_SERIALIZE(pool_key, (symbol_code)) + }; + /// Per-token accrual and spend state; shortfalls are latest deficits, not duplicated debt. + struct [[sysio::table("kickpools")]] kick_pool { + symbol_code sym; + uint16_t rate_bps = kicker_math::default_rate_bps; + uint64_t min_gift = kicker_math::default_min_gift; + time_point last_kick{}; + uint128_t gifted_total = 0; ///< Lifetime paid WIRE, outside emissions accounting. + uint64_t shortfall_amount = 0; ///< Latest gift minus paid amount; cleared by full payment or zero supply. + time_point shortfall_time{}; ///< Last unpaid shortfall, for governance visibility. + bool gift_overflow = false; ///< Gift exceeded asset range; clock remains open. + /// Spot strayed past max_spot_deviation_bps from the TWAP at the latest priced attempt; clock and observation + /// held. + bool spot_deviation = false; + uint64_t max_gift_per_day = kicker_math::max_daily_gift; ///< Positive ceiling, at most 1,000 WIRE per UTC day. + uint64_t day = 0; ///< UTC day number of the most recent payment. + uint128_t spent_today = 0; + /// LIQ supply, in subunits, at the latest checkpoint: addpool, a full payment, a zero-supply or zero-snapshot + /// restart, or a forfeiting reset. A partial payment lowers it to the supply it priced. Gifts are sized on the + /// smaller of this and the current supply, so LIQ minted after a checkpoint earns nothing until the next full + /// payment or reset; a pool whose payments are all partial keeps its snapshot until one lands. + int64_t supply_snapshot = 0; + opp::twap::cumulative_price price_snapshot; ///< Swap price1 extended to price_time. + time_point price_time{}; ///< Observation starts on addpool or the last payment, full or partial. + SYSLIB_SERIALIZE(kick_pool, (sym)(rate_bps)(min_gift)(last_kick)(gifted_total) + (shortfall_amount)(shortfall_time)(gift_overflow)(spot_deviation) + (max_gift_per_day)(day)(spent_today)(supply_snapshot)(price_snapshot)(price_time)) + }; + /// Governance-owned singleton and per-symbol payment clocks. + using config_table = kv::global<"kickcfg"_n, kick_config>; + using pools_table = kv::table<"kickpools"_n, pool_key, kick_pool>; + + /// Replace budget, payment interval, TWAP window and spot guard. Requires sysio; never settles open intervals. + /// The next kick applies the window to the existing price_time/snapshot without restarting observation. + [[sysio::action]] void setconfig(kick_config cfg); + /// Start accrual now for a registered LIQ/WIRE yield pair. Requires sysio; defaults to 200 bps and 1 WIRE. + [[sysio::action]] void addpool(symbol_code sym, uint16_t rate_bps = kicker_math::default_rate_bps, + uint64_t min_gift = kicker_math::default_min_gift, + uint64_t max_gift_per_day = kicker_math::max_daily_gift); + /// Change rate/minimum/daily ceiling without resetting spending or observation; prices bounded accrual. + /// Requires sysio. + [[sysio::action]] void setpool(symbol_code sym, uint16_t rate_bps, uint64_t min_gift, uint64_t max_gift_per_day); + /// Stop accrual and erase the row; re-adding starts a new interval. Requires sysio. + [[sysio::action]] void rmpool(symbol_code sym); + /// Permissionless TWAP gift after the configured observation window; payment forfeits accrual older than 72 hours. + /// The gift is sized on min(supply_snapshot, current LIQ supply): it never exceeds the honest gift of the supply + /// present at the last checkpoint, and LIQ minted after a checkpoint is sized from the next full payment or + /// checkpointing reset (a pool whose payments are all partial keeps its snapshot until one lands). New LIQ still + /// takes its pro-rata share of that correctly sized gift through addyield, which divides by the current supply. + /// Sizing sees the supply at the two ends of the interval, not inside it: LIQ burned after a checkpoint and + /// re-minted before the next payment or checkpointing reset is sized as if held throughout; a kick that neither + /// pays nor checkpoints leaves that gap open. The bound above and the daily cap still hold. + /// A zero snapshot with nonzero supply only checkpoints. A gift below min_gift while the supply has grown past + /// the snapshot and at least 72 hours are unpaid forfeits the interval and checkpoints at the current supply, but + /// only if the same interval sized on the current supply at the same TWAP reaches min_gift (or overflows), i.e. the + /// snapshot is the binding constraint; a price dip that sinks both gifts below the minimum holds instead, while a + /// dip that leaves only the snapshot's gift below the minimum still forfeits. + /// Pays nothing while spot strays from the window TWAP by more than max_spot_deviation_bps, holding the clock. + /// Partial payments advance a floored pro-rata clock within the bounded 72-hour accrual interval; every payment + /// restarts the observation, so a partial payment's remainder waits for, and is priced by, a fresh window. + /// Skips retain state. + [[sysio::action]] void kick(symbol_code sym); +private: + /// Validate governance settings at both pool configuration boundaries. + static void validate_pool(uint16_t rate_bps, uint64_t min_gift, uint64_t max_gift_per_day); +}; +} // namespace sysio diff --git a/contracts/sysio.kicker/src/sysio.kicker.cpp b/contracts/sysio.kicker/src/sysio.kicker.cpp new file mode 100644 index 0000000000..61c4426865 --- /dev/null +++ b/contracts/sysio.kicker/src/sysio.kicker.cpp @@ -0,0 +1,248 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sysio { +/// Keep the dependency-free arithmetic bounds in lock-step with the protocol asset range. +static_assert(kicker_math::max_amount == asset::max_amount); +namespace { +/// Stable governance and keeper diagnostics. +namespace errors { +constexpr auto no_yield_pool = "LIQ token has no yield pool"; +constexpr auto missing_swap_pool = "swap pool does not exist"; +constexpr auto invalid_pair = "pool must be LIQ/WIRE with a LIQ yield leg"; +constexpr auto negative_reserve = "negative swap reserve"; +constexpr auto missing_config = "kicker not configured"; +constexpr auto negative_pending = "negative pending emissions"; +constexpr auto invalid_balance = "invalid treasury WIRE balance"; +constexpr auto invalid_rate = "rate exceeds 10000 bps"; +constexpr auto invalid_minimum = "invalid minimum gift"; +constexpr auto invalid_cap = "invalid daily cap"; +constexpr auto invalid_budget = "budget exceeds asset range"; +constexpr auto invalid_interval = "minimum interval must be positive"; +constexpr auto invalid_twap_window = "TWAP window must be at least 60 seconds"; +constexpr auto invalid_spot_deviation = "spot deviation must be 1 to 10000 bps"; +constexpr auto invalid_symbol = "invalid LIQ symbol"; +constexpr auto missing_shadow = "LIQ token does not exist"; +constexpr auto duplicate_pool = "pool already configured"; +constexpr auto unknown_pool = "unknown kicker pool"; +constexpr auto negative_supply = "negative LIQ supply"; +constexpr auto missing_price = "swap price accumulator does not exist"; +} // namespace errors + +/// Inline treasury draw memo, preserved on the token transfer trace. +constexpr auto gift_memo = "T5 LIQ kicker"; +/// Active permission for privileged inline transfers and addyield. +constexpr name active_permission = "active"_n; +/// Validate the shared LIQ stat and its live swap pair together, never price a mismatched leg. +opp::swap_state::currency_stats pair_for(symbol_code sym, const liq::currency_stats& shadow) { + check(shadow.pair_symbol.raw() != 0, errors::no_yield_pool); + const auto pair = opp::swap_state::stats(opp::swap_state::account).get( + {shadow.pair_symbol.raw()}, errors::missing_swap_pool); + check(pair.pool1.contract == kicker::liq_account && pair.pool1.quantity.symbol == shadow.supply.symbol && + pair.pool1.quantity.symbol.code() == sym && pair.pool2.contract == kicker::token_account && + pair.pool2.quantity.symbol == opp::wire::asset_symbol && pair.yield_leg && + pair.yield_leg->get_contract() == kicker::liq_account && + pair.yield_leg->get_symbol() == shadow.supply.symbol, errors::invalid_pair); + check(pair.pool1.quantity.amount >= 0 && pair.pool2.quantity.amount >= 0, errors::negative_reserve); + return pair; +} +/// Extend swap's pre-mutation cumulative price to now without changing or settling the swap pool. +opp::twap::cumulative_price current_price(const opp::swap_state::currency_stats& pair, time_point now) { + const auto accum = opp::swap_state::priceaccums(opp::swap_state::account).get( + {pair.supply.symbol.code().raw()}, errors::missing_price); + auto price = accum.price1; + if (now > accum.last_update) + opp::twap::accumulate(price, opp::twap::price_fp(pair.pool2.quantity.amount, pair.pool1.quantity.amount), + (now - accum.last_update).count()); + return price; +} +/// Reserve pending emissions, outstanding claims and the remaining emission ceiling; missing state fails closed. +uint64_t treasury_available() { + using namespace sysiosystem::emissions; + const t5state_t treasury(kicker::system_account); + const emitcfg_t emission_config(kicker::system_account); + if (!treasury.exists() || !emission_config.exists()) return 0; + const auto state = treasury.get(); + check(state.pending_emission_amount >= 0, errors::negative_pending); + const auto claims = payclaimtot_t(kicker::system_account).get_or_default(pay_claim_total{}); + const token::accounts balances(kicker::token_account, kicker::system_account.value); + const token::acct_key key{opp::wire::asset_symbol.code().raw()}; + if (!balances.contains(key)) return 0; + const auto balance = balances.get(key).balance; + check(balance.symbol == opp::wire::asset_symbol && balance.amount >= 0, errors::invalid_balance); + const auto emission = emission_config.get(); + // Widen before subtraction so malformed or exhausted state cannot wrap a reserve. + const auto remaining = std::max<__int128>(0, static_cast<__int128>(emission.t5_distributable) - + emission.t5_floor - state.total_distributed); + const auto reserved = static_cast(state.pending_emission_amount) + claims.outstanding + + static_cast(remaining); + if (reserved >= static_cast(balance.amount)) return 0; + return static_cast(static_cast(balance.amount) - reserved); +} +/// Checkpoint without paying: restart the accrual clock now at `supply` and clear the attempt diagnostics, whose +/// accrual the restart forfeits. The price observation is left as it is. +void checkpoint(kicker::kick_pool& pool, time_point now, int64_t supply) { + pool.last_kick = now; + pool.supply_snapshot = supply; + pool.shortfall_amount = 0; + pool.shortfall_time = time_point{}; + pool.gift_overflow = false; + pool.spot_deviation = false; +} +} // namespace + +void kicker::validate_pool(uint16_t rate_bps, uint64_t min_gift, uint64_t max_gift_per_day) { + check(rate_bps <= kicker_math::bps_scale, errors::invalid_rate); + check(min_gift > 0 && min_gift <= kicker_math::max_amount, errors::invalid_minimum); + check(max_gift_per_day >= min_gift && max_gift_per_day <= kicker_math::max_daily_gift, + errors::invalid_cap); +} +void kicker::setconfig(kick_config cfg) { + require_auth(system_account); + check(cfg.budget_remaining <= kicker_math::max_amount, errors::invalid_budget); + check(cfg.min_interval_sec > 0, errors::invalid_interval); + check(cfg.twap_window_sec >= kicker_math::min_twap_window_sec, errors::invalid_twap_window); + check(cfg.max_spot_deviation_bps > 0 && cfg.max_spot_deviation_bps <= kicker_math::bps_scale, + errors::invalid_spot_deviation); + config_table(get_self()).set(cfg, system_account); +} +void kicker::addpool(symbol_code sym, uint16_t rate_bps, uint64_t min_gift, uint64_t max_gift_per_day) { + require_auth(system_account); + validate_pool(rate_bps, min_gift, max_gift_per_day); + check(sym.is_valid(), errors::invalid_symbol); + const auto shadow = liq::stats(liq_account).get({sym.raw()}, errors::missing_shadow); + const auto pair = pair_for(sym, shadow); + pools_table rows(get_self()); + check(rows.find({sym.raw()}) == rows.end(), errors::duplicate_pool); + kick_pool pool; + pool.sym = sym; + pool.rate_bps = rate_bps; + pool.min_gift = min_gift; + pool.max_gift_per_day = max_gift_per_day; + pool.last_kick = current_time_point(); + pool.supply_snapshot = shadow.supply.amount; + pool.price_time = pool.last_kick; + pool.price_snapshot = current_price(pair, pool.price_time); + rows.emplace(system_account, {sym.raw()}, [&](auto& row) { row = pool; }); +} +void kicker::setpool(symbol_code sym, uint16_t rate_bps, uint64_t min_gift, uint64_t max_gift_per_day) { + require_auth(system_account); + validate_pool(rate_bps, min_gift, max_gift_per_day); + pools_table rows(get_self()); + const auto it = rows.find({sym.raw()}); + check(it != rows.end(), errors::unknown_pool); + rows.modify(system_account, pool_key{sym.raw()}, [&](auto& row) { + row.rate_bps = rate_bps; + row.min_gift = min_gift; + row.max_gift_per_day = max_gift_per_day; + }); +} +void kicker::rmpool(symbol_code sym) { + require_auth(system_account); + pools_table rows(get_self()); + const auto it = rows.find({sym.raw()}); + check(it != rows.end(), errors::unknown_pool); + rows.erase(it); +} +void kicker::kick(symbol_code sym) { + config_table config(get_self()); + auto cfg = config.get(errors::missing_config); + pools_table rows(get_self()); + auto pool = rows.get({sym.raw()}, errors::unknown_pool); + const auto now = current_time_point(); + if (now <= pool.last_kick) return; + const uint64_t unpaid = (now - pool.last_kick).count(); + if (unpaid < static_cast(cfg.min_interval_sec) * kicker_math::micros_per_sec || + andon::pulled(andon::ANDON_ACCOUNT)) return; + const auto shadow = liq::stats(liq_account).get({sym.raw()}, errors::missing_shadow); + check(shadow.supply.amount >= 0, errors::negative_supply); + // No supply accrues nothing; a zero snapshot means none of the current supply was present at the last + // checkpoint, so its accrual starts now. Both restart the clock and pay nothing. + if (shadow.supply.amount == 0 || pool.supply_snapshot == 0) { + checkpoint(pool, now, shadow.supply.amount); + rows.set(system_account, {sym.raw()}, pool); + return; + } + const auto pair = pair_for(sym, shadow); + if (now <= pool.price_time || (now - pool.price_time).count() < + static_cast(cfg.twap_window_sec) * kicker_math::micros_per_sec) return; + const auto cumulative = current_price(pair, now); + const auto price = opp::twap::average_price(opp::twap::difference(cumulative, pool.price_snapshot), + (now - pool.price_time).count()); + const auto elapsed = std::min(unpaid, kicker_math::max_catchup_us); + // Size on the smaller supply: a gift never exceeds the honest gift of the supply present at the last checkpoint, + // and LIQ minted after it is sized from the next full payment or checkpointing reset (a pool whose payments are + // all partial keeps its snapshot until one lands). addyield still shares the gift across the current supply. + const auto sized_supply = std::min(pool.supply_snapshot, shadow.supply.amount); + const auto gift = kicker_math::gift_at_price(sized_supply, pool.rate_bps, elapsed, price); + if (gift && *gift < pool.min_gift) { + // A snapshot too small to ever reach min_gift would hold a grown supply unpaid forever. Once the 72-hour + // catch-up is saturated nothing more can accrue, so forfeit the interval and checkpoint at the current supply, + // but only when the snapshot is the binding constraint: the same interval sized on the CURRENT supply at the + // same TWAP must reach min_gift. A price dip pushes both gifts under the minimum, and the TWAP is not the + // snapshot's fault, so that kick holds and the refilled pool still pays the interval. A current-supply gift that + // overflows is far above any minimum, so the snapshot is binding and the reset is allowed. + const auto grown = kicker_math::gift_at_price(shadow.supply.amount, pool.rate_bps, elapsed, price); + if (shadow.supply.amount > pool.supply_snapshot && unpaid >= kicker_math::max_catchup_us && + (!grown || *grown >= pool.min_gift)) { + checkpoint(pool, now, shadow.supply.amount); + rows.set(system_account, {sym.raw()}, pool); + } + return; + } + // A payment needs spot near the price it pays at. A pump held across the checkpoint shows here as spot far from + // the window's TWAP: the kick pays nothing and keeps both the clock and the observation, so the same price + // history must still be diluted, by honest trading or time, before anything is paid at it. + pool.spot_deviation = kicker_math::spot_deviates( + opp::twap::price_fp(pair.pool2.quantity.amount, pair.pool1.quantity.amount), price, cfg.max_spot_deviation_bps); + if (pool.spot_deviation) { + pool.shortfall_amount = gift ? *gift : 0; + pool.gift_overflow = !gift; + pool.shortfall_time = now; + rows.set(system_account, {sym.raw()}, pool); + return; + } + const uint64_t day = static_cast(now.time_since_epoch().count()) / + (kicker_math::day_sec * kicker_math::micros_per_sec); + const uint128_t spent = day == pool.day ? pool.spent_today : 0; + uint64_t available = std::min(cfg.budget_remaining, treasury_available()); + available = std::min(available, pool.max_gift_per_day > spent + ? pool.max_gift_per_day - static_cast(spent) : 0); + // Overflow has no exact representable denominator: keep the entire interval open. + const uint64_t paid = gift && available >= pool.min_gift ? std::min(*gift, available) : 0; + pool.shortfall_amount = gift ? *gift - paid : 0; + pool.gift_overflow = !gift; + if (!gift || paid < pool.min_gift) { + pool.shortfall_time = now; + rows.set(system_account, {sym.raw()}, pool); + return; + } + // Both operands fit uint64; the widened product is exact, floored per the owner's ruling; the sub-microsecond + // remainder stays in the open interval (holder-favoured, < 1 subunit at realistic supply). + pool.last_kick = now - microseconds(elapsed) + + microseconds(static_cast(static_cast(elapsed) * paid / *gift)); + // Every payment settles the price history it used. A partial payment's unpaid accrual is priced by the next + // observation, never again by the history that already funded this one. + pool.price_snapshot = cumulative; + pool.price_time = now; + // A full payment checkpoints the current supply; a partial one keeps the supply it priced for the remainder. + pool.supply_snapshot = paid == *gift ? shadow.supply.amount : sized_supply; + pool.gifted_total += paid; + pool.shortfall_time = pool.shortfall_amount ? now : time_point{}; + pool.day = day; + pool.spent_today = spent + paid; + cfg.budget_remaining -= paid; + rows.set(system_account, {sym.raw()}, pool); + config.set(cfg, system_account); + token::transfer_action(token_account, {system_account, active_permission}).send( + system_account, get_self(), asset(paid, opp::wire::asset_symbol), gift_memo); + action(permission_level{get_self(), active_permission}, liq_account, opp::shadow::ADDYIELD_ACTION, + std::make_tuple(get_self(), asset(paid, opp::wire::asset_symbol), sym)).send(); +} +} // namespace sysio diff --git a/contracts/sysio.kicker/sysio.kicker.abi b/contracts/sysio.kicker/sysio.kicker.abi new file mode 100644 index 0000000000..4932578af4 --- /dev/null +++ b/contracts/sysio.kicker/sysio.kicker.abi @@ -0,0 +1,241 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "addpool", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol_code" + }, + { + "name": "rate_bps", + "type": "uint16" + }, + { + "name": "min_gift", + "type": "uint64" + }, + { + "name": "max_gift_per_day", + "type": "uint64" + } + ] + }, + { + "name": "cumulative_price", + "base": "", + "fields": [ + { + "name": "lo", + "type": "uint128" + }, + { + "name": "hi", + "type": "uint128" + } + ] + }, + { + "name": "kick", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol_code" + } + ] + }, + { + "name": "kick_config", + "base": "", + "fields": [ + { + "name": "budget_remaining", + "type": "uint64" + }, + { + "name": "min_interval_sec", + "type": "uint32" + }, + { + "name": "twap_window_sec", + "type": "uint32" + }, + { + "name": "max_spot_deviation_bps", + "type": "uint16" + } + ] + }, + { + "name": "kick_pool", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol_code" + }, + { + "name": "rate_bps", + "type": "uint16" + }, + { + "name": "min_gift", + "type": "uint64" + }, + { + "name": "last_kick", + "type": "time_point" + }, + { + "name": "gifted_total", + "type": "uint128" + }, + { + "name": "shortfall_amount", + "type": "uint64" + }, + { + "name": "shortfall_time", + "type": "time_point" + }, + { + "name": "gift_overflow", + "type": "bool" + }, + { + "name": "spot_deviation", + "type": "bool" + }, + { + "name": "max_gift_per_day", + "type": "uint64" + }, + { + "name": "day", + "type": "uint64" + }, + { + "name": "spent_today", + "type": "uint128" + }, + { + "name": "supply_snapshot", + "type": "int64" + }, + { + "name": "price_snapshot", + "type": "cumulative_price" + }, + { + "name": "price_time", + "type": "time_point" + } + ] + }, + { + "name": "pool_key", + "base": "", + "fields": [ + { + "name": "symbol_code", + "type": "uint64" + } + ] + }, + { + "name": "rmpool", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol_code" + } + ] + }, + { + "name": "setconfig", + "base": "", + "fields": [ + { + "name": "cfg", + "type": "kick_config" + } + ] + }, + { + "name": "setpool", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol_code" + }, + { + "name": "rate_bps", + "type": "uint16" + }, + { + "name": "min_gift", + "type": "uint64" + }, + { + "name": "max_gift_per_day", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "addpool", + "type": "addpool", + "ricardian_contract": "" + }, + { + "name": "kick", + "type": "kick", + "ricardian_contract": "" + }, + { + "name": "rmpool", + "type": "rmpool", + "ricardian_contract": "" + }, + { + "name": "setconfig", + "type": "setconfig", + "ricardian_contract": "" + }, + { + "name": "setpool", + "type": "setpool", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "kickcfg", + "type": "kick_config", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 50218 + }, + { + "name": "kickpools", + "type": "kick_pool", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 1606 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/sysio.kicker/sysio.kicker.wasm b/contracts/sysio.kicker/sysio.kicker.wasm new file mode 100644 index 0000000000..ed8492a32f Binary files /dev/null and b/contracts/sysio.kicker/sysio.kicker.wasm differ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/swap_state.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/swap_state.hpp new file mode 100644 index 0000000000..39c89a186c --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/swap_state.hpp @@ -0,0 +1,76 @@ +#pragma once +/** + * @file swap_state.hpp + * @brief Shared AMM state for sysio.swap and read-only contract consumers. + * + * LAYOUT IS SHARED STATE: field order and table keys are the serialized contract + * between the AMM and readers. Pool1/pool2 are the live balances at action time; + * reading their ratio needs no sync and does not settle unclaimed shadow yield. + */ +#include +#include +#include +#include +#include + +namespace sysio::opp::swap_state { + +/// The AMM account owning these tables. +inline constexpr name account = "sysio.swap"_n; +/// Current pair balances and LP supply. +inline constexpr name stat_table = "stat"_n; +/// Cumulative prices, for readers that need a TWAP. +inline constexpr name price_table = "priceaccum"_n; + +/// A pair's rows in `stat` and `priceaccum`: keyed by the LP token's symbol code. +struct pair_key { + uint64_t symbol_code; + SYSLIB_SERIALIZE(pair_key, (symbol_code)) +}; + +/// Complete on-chain pair layout, unchanged by extraction. +struct currency_stats { + asset supply; + asset max_supply; + name issuer; + extended_asset pool1; + extended_asset pool2; + int fee; + name fee_authority; ///< whose signature changefee requires for this pair + asset locked_shares; ///< part of `supply` held by no account, never redeemable + std::optional yield_leg; ///< the shadow leg of a yield pool; empty for a plain pool + uint32_t conversion_horizon_sec = 0; ///< H: the reservoir is meant to sell over this long + uint32_t depth_cap_bps = 0; ///< hard ceiling on one clip, bps of the pool's shadow side + int64_t clip_floor = 0; ///< least a clip may be, in units of the shadow + int64_t last_tick_depth = 0; ///< the shadow side as of the last setyield or selling tick. + ///< The depth cap is taken against the SMALLER of this and + ///< the current side, so a shadow side inflated inside one + ///< transaction cannot widen the cap that bounds it. + time_point last_tick{}; ///< elapsed-time base of the clip formula: the last tick that + ///< sold, the last setyield, or when the reservoir last + ///< went from empty to funded, whichever is latest. A tick + ///< that sells nothing deliberately leaves it alone. + SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority) + (locked_shares)(yield_leg)(conversion_horizon_sec)(depth_cap_bps) + (clip_floor)(last_tick_depth)(last_tick)) +}; + +/// Cumulative-price accumulators for a pair (sysio.opp.common/twap.hpp). +/// `price1` sums the Q64.64 price of one unit of pool1 in units of pool2, +/// times elapsed microseconds; `price2` the reverse. Both advance, at the +/// spot price that held since `last_update`, immediately before the pools +/// change and on `sync`. A reader snapshots the row at t0 and computes +/// `twap::average_price(twap::difference(now, snapshot), t - t0)`. +struct price_accumulator { + sysio::opp::twap::cumulative_price price1; + sysio::opp::twap::cumulative_price price2; + time_point last_update; + SYSLIB_SERIALIZE(price_accumulator, (price1)(price2)(last_update)) +}; + +/// Public read-only stat view; ownership is declared only by sysio.swap. +using stats = kv::table; +/// Public read-only accumulator view. +using priceaccums = kv::table; + +} // namespace sysio::opp::swap_state diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index a868f191d2..06937fc583 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -8,7 +8,7 @@ #include #include #include -#include +#include #include #include #include @@ -168,11 +168,8 @@ namespace sysio { // --- Keys --- - /// A pair's rows in `stat` and `priceaccum`: keyed by the LP token's symbol code. - struct pair_key { - uint64_t symbol_code; - SYSLIB_SERIALIZE(pair_key, (symbol_code)) - }; + /// Shared primary key, serialized identically for writers and readers. + using pair_key = opp::swap_state::pair_key; /// An LP-token balance row, scoped by its owner: keyed by the symbol code. struct account_key { @@ -231,31 +228,8 @@ namespace sysio { SYSLIB_SERIALIZE(evodex_account, (balance)) }; - struct [[sysio::table("stat")]] currency_stats { - asset supply; - asset max_supply; - name issuer; - extended_asset pool1; - extended_asset pool2; - int fee; - name fee_authority; ///< whose signature changefee requires for this pair - asset locked_shares; ///< part of `supply` held by no account, never redeemable - std::optional yield_leg; ///< the shadow leg of a yield pool; empty for a plain pool - uint32_t conversion_horizon_sec = 0; ///< H: the reservoir is meant to sell over this long - uint32_t depth_cap_bps = 0; ///< hard ceiling on one clip, bps of the pool's shadow side - int64_t clip_floor = 0; ///< least a clip may be, in units of the shadow - int64_t last_tick_depth = 0; ///< the shadow side as of the last setyield or selling tick. - ///< The depth cap is taken against the SMALLER of this and - ///< the current side, so a shadow side inflated inside one - ///< transaction cannot widen the cap that bounds it. - time_point last_tick{}; ///< elapsed-time base of the clip formula: the last tick that - ///< sold, the last setyield, or when the reservoir last - ///< went from empty to funded, whichever is latest. A tick - ///< that sells nothing deliberately leaves it alone. - SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority) - (locked_shares)(yield_leg)(conversion_horizon_sec)(depth_cap_bps) - (clip_floor)(last_tick_depth)(last_tick)) - }; + /// Shared AMM pair row; alias declares the table owned by this contract. + using currency_stats = opp::swap_state::currency_stats; /// A yield payout the contract has claimed and credited to `pair` but not yet /// received. `quantity` is what the shadow contract's `claim` must deliver, @@ -292,27 +266,17 @@ namespace sysio { SYSLIB_SERIALIZE(pair_index, (evo_symbol)) }; - /// Cumulative-price accumulators for a pair (sysio.opp.common/twap.hpp). - /// `price1` sums the Q64.64 price of one unit of pool1 in units of pool2, - /// times elapsed microseconds; `price2` the reverse. Both advance, at the - /// spot price that held since `last_update`, immediately before the pools - /// change and on `sync`. A reader snapshots the row at t0 and computes - /// `twap::average_price(twap::difference(now, snapshot), t - t0)`. - struct [[sysio::table("priceaccum")]] price_accumulator { - sysio::opp::twap::cumulative_price price1; - sysio::opp::twap::cumulative_price price2; - time_point last_update; - SYSLIB_SERIALIZE(price_accumulator, (price1)(price2)(last_update)) - }; + /// Shared cumulative-price row. + using price_accumulator = opp::swap_state::price_accumulator; // --- Tables --- using swapconfig_t = kv::global<"swapconfig"_n, swap_config>; using accounts = kv::scoped_table<"accounts"_n, account_key, account>; using evodexacnts = kv::scoped_table<"evodexacnts"_n, extended_symbol_key, evodex_account>; - using stats = kv::table<"stat"_n, pair_key, currency_stats>; + using stats = opp::swap_state::stats; using evoindexes = kv::table<"evoindex"_n, pair_identity_key, pair_index>; - using priceaccums = kv::table<"priceaccum"_n, pair_key, price_accumulator>; + using priceaccums = opp::swap_state::priceaccums; using yieldpayouts = kv::table<"yieldpayouts"_n, contract_key, payout_receipt>; using yieldfunds = kv::table<"yieldfunds"_n, funder_key, fund_receipt>; using reservoirs = kv::table<"reservoirs"_n, pair_key, reservoir>; diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index 59abd27f92..6b64726379 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -171,10 +171,154 @@ owner calls `claimnodedis` (auth = their own account) to transfer the newly-vested amount, gated by `min_claimable` (memo `Node Owner distribution`). `viewnodedist` is the read-only preview. -### LIQ yield and retired capital draws - -LIQ holders receive only supplied WIRE yield proceeds and donations. The T5 yield bonus and -`fundclaim` action are removed. Any unallocated emissions remainder stays in the treasury. +### LIQ yield and the independent kicker earmark + +LIQ holders receive supplied WIRE proceeds and donations through `sysio.liq::addyield`. +The retired per-intake bonus and `fundclaim` action remain removed. `sysio.kicker` is a +separate privileged contract: a permissionless `kick(sym)` draws from `sysio` within a +governance earmark and distributes the gift to all holders through `addyield`. + +The gift is simple accrual on the **smaller** of the LIQ supply at the pool's last +checkpoint (`supply_snapshot`) and the current `stat.supply`, at a **TWAP** of the +registered LIQ/WIRE swap pool, for at most the last 72 hours since payment. +Each token has its own clock and rate (default 200 bps); a year is 31,557,600 seconds. +The two arithmetic floors favour the treasury. Kicker snapshots swap's `priceaccum.price1` +on `addpool` and after each payment, full or partial. It extends that accumulator to the action time +using the unchanged-reserve tail, then divides the cumulative difference by elapsed time. +Governance sets `kickcfg.twap_window_sec` through +`setconfig({budget_remaining, min_interval_sec, twap_window_sec, max_spot_deviation_bps})`. The window defaults +to **86,400 seconds (one day)** and must be at least **60 seconds**; production keeps +one day. Test clusters can configure a shorter window to observe payments, with +`min_interval_sec` and `min_gift` also low enough to permit them. +The observation must span at least the configured window; there is no spot fallback during warm-up. +The next `kick` applies a changed window to the in-flight observation without resetting +`price_time`, `price_snapshot` or the accrual clock: lowering it can permit payment +on that attempt; raising it delays eligibility until that same observation is long enough. +Nonpaying attempts retain the observation, so its TWAP can span more than the configured +window. Every payment, full or partial, restarts it: history that priced one payment never +prices another. A same-block trade has zero weight until time passes at the new price. + +Sizing on the smaller supply bounds every payment: **a gift never exceeds the honest gift of +the supply present at the last checkpoint; LIQ minted after a checkpoint is sized from the +next full payment or checkpointing reset.** Without it a holder could mint LIQ just before a +mature kick and be paid up to 72 hours of accrual on supply that existed for one block. +`addpool` checkpoints the current supply, and so does a full payment. A partial payment keeps the supply it priced, so its +remainder cannot be re-sized on LIQ minted in between. `addyield` still divides each +correctly sized gift by the current supply, so LIQ minted after the checkpoint takes its +pro-rata share of that gift and the earlier holders' share shrinks by the same amount; that +dilution is the accepted residual, and it is never more than the gift itself. A burn below the +snapshot lowers the gift at once. A pool whose payments are all partial (the sized gift exceeds +the daily cap, or budget top-ups stay below it) never re-checkpoints, so LIQ minted meanwhile +stays unsized until a full payment or a reset lands. + +A second residual sits beside the pro-rata one: sizing sees the supply at the two ends of the +interval, not inside it. LIQ present at both checkpoints is sized for the whole interval even if +it was burned in between (for example desyndicated after a full-payment checkpoint and +re-syndicated before the next payment or checkpointing reset; a kick that neither pays nor +checkpoints leaves the gap open), so "not paid for the interval it did not hold" does not hold; the treasury bound +(never above the honest gift of the supply present at the last checkpoint) and the daily cap +still do. + +A one-block price spike in a shallow pool contributes its duration weight to the +arithmetic TWAP. A large trade held across one block can push one kick's gift to the daily +cap: the attacker then partly unwinds so spot sits near the inflated TWAP, kicks, and +finishes the unwind in the same block, and same-block trades carry no TWAP weight, so no +spot threshold stops it. A holder of most of the LIQ supply, who receives most of that +gift, can then profit from the round trip. This residual risk is accepted. + +The spot guard bounds the straddle. A pump raised just before one kick and unwound just +after it would otherwise put its area into two adjacent observations and fund two capped +payouts. A kick pays only while the pool's spot is within `kickcfg.max_spot_deviation_bps` +of the window TWAP (`|spot - TWAP| / TWAP`, default **5,000 bps**, set from 1 to 10,000). +Past it the kick pays nothing, records `spot_deviation`, and holds both the clock and the +observation. The straddle's first kick sees spot far above the TWAP, and its second, after +a full unwind, prices at no more than `spot / (1 - threshold)` (twice spot at the default) +instead of the pumped TWAP. **One price excursion therefore funds one capped payout plus at +most `d * cap` of excess on the next payout** (500 WIRE at the default 5,000 bps), where `d` +is the threshold as a fraction: that payout's excess over its honest value is +`min(cap, honest / (1 - d)) - honest`, largest when its honest gift is `cap * (1 - d)`. A +straddle whose first kick follows a partial unwind reaches it; the regression +`partial_unwind_straddle_overpays_at_most_deviation_times_cap` measures 50.02 WIRE of excess +in a construction near the cap and 499.98 WIRE at the worst point. Minting LIQ between the two +kicks does not widen it: the second payout is sized on the supply checkpointed by the first, and +`partial_unwind_straddle_with_mint_stays_within_bound` checks a tenfold mint stays within it. The bound holds for every +threshold from 1 to 10,000 bps: at the 10,000 bps maximum (`d = 1`) the second kick's price +is no longer bounded by spot, so its excess can reach the cap itself, two capped payouts, and +the sizing rule below becomes `2 * max_gift_per_day`. A trip holds rather than restarts the +observation: a restart would let anyone discard a pool's accrued observation with one atomic +trade and delay its payment by a full window at almost no cost. Governance registers only pools with deep liquidity and sizes +`max_gift_per_day` so that `(1 + d) * max_gift_per_day` stays well under the cheaper of two +costs: holding that pool's price for a full window, or a one-block spike that buys a window's +TWAP area. + +`max_spot_deviation_bps` is a liveness and griefing knob, not a manipulation-cost one: the +one-block path above passes any threshold. A trip holds payment until the observation's +TWAP is back within the threshold of spot. For an honest move by a factor `r` when the +observation is `D` old, payment resumes after `D * ((r - 1) / d - 1) / r` for a rise and +`D * ((1 - r) / d - 1) / r` for a fall, where `d` is the threshold as a fraction (zero or +less means at once). At the 5,000 bps default a fall of up to 50% or a rise of up to 50% +clears at once, a doubling clears after half of `D`, and a tripling after `D`. + +Griefing cuts the other way, by two routes. The one-block route holds payment past +observation length `L` with a single 0.5 s spike of more than `1 + 2L * d / (1 - d)` times +spot: more than 172,801x per day of delay at the default, about 31 million WIRE of capital +on a 75,000/75,000 WIRE-LIQ pool. A continuous hold needs far less capital: keeping spot +just above `(1 + d) * P * (t / D) ^ d`, where `P` is the TWAP of an observation `D` old and +`t` is its age, trips the guard throughout. At the default, a one-day delay on a one-day-old +observation ramps spot from 1.5x to about 2.12x `P`, about 34 thousand WIRE of capital on +that pool, at the cost of holding the pool off-price for the day against arbitrage. A +tighter threshold holds payment through more honest volatility and makes both routes +cheaper; a wider one does the reverse and loosens the straddle bound. + +Governance sets an absolute `budget_remaining` outside `t5_distributable`. Kicker draws +never modify `t5state.total_distributed`. Every draw reserves `pending_emission_amount`, +`payclaimtot.outstanding`, and the nonnegative remaining emission ceiling +(`t5_distributable - t5_floor - total_distributed`) from the live WIRE balance. +Absent emissions configuration or T5 state permits no draw. + +Funding or a daily allowance below the accrued gift pays the available amount if it +meets `min_gift`, then sets `last_kick` to `now - elapsed + floor(elapsed * paid / gift)`, +where `elapsed = min(now - last_kick, 72 hours)`. A successful payment forfeits older +accrual. Within the bounded 72 hours, the unpaid remainder keeps accruing; the next +payment prices it, with any new accrual, at the TWAP of a fresh observation that +starts at the partial payment. +A below-minimum allowance or Andon hold leaves the clock open. An empty supply, or a +zero `supply_snapshot` under a nonzero supply, checkpoints instead of pricing: the clock +restarts at the current supply and all shortfall diagnostics clear, except during an Andon +hold. The price observation is kept. A zero snapshot means none of today's supply was present +at the last checkpoint, so its accrual starts at this kick. A gift below `min_gift` writes +nothing, with one exception: while the supply has grown past the snapshot, the full 72 +hours are unpaid, and the same interval sized on the current supply at the same TWAP would reach +`min_gift` (or overflow), the small snapshot is the binding constraint and nothing more can accrue on it, +so the kick forfeits the interval and checkpoints the current supply instead of holding the grown supply +unpaid forever. When the TWAP is the cause (a price dip sinks the current-supply gift below `min_gift` +too), the kick holds and the refilled pool still pays the interval. A dip that leaves only the +snapshot's gift below `min_gift` (the current-supply gift still reaches it) still forfeits: the +criterion is the snapshot being binding at that TWAP, not the TWAP being honest. +Governance must set a positive UTC-day ceiling at least `min_gift` and no larger +than **1,000 WIRE per pool per day**, which is also the C++ default. Zero is invalid. +Retries and configuration changes retain the current day's spending. Set `min_gift` +no higher than a day's expected accrual if the pool is intended to pay. + +TWAP, bounded accrual and the mandatory cap apply independently of keeper cadence. +`min_interval_sec` is a floor, not a schedule: +every ACTIVE batch operator pushes `kick(sym)` once a pool could pay — its interval has +elapsed, its LIQ supply is not zero, its observation spans the window, and its budget and +UTC-day allowance cover `min_gift` (`batch_operator_plugin`, `--batch-kick-crank`) — and governance still monitors +`last_kick` for a pool that stops advancing. An attempt that still does not pay is retried +with a doubling backoff of up to an hour. Each poll reads and attempts at most ten pools, +resuming the next page on the next poll; a complete sweep prunes removed symbols. For +the supplies it looks up only those pools' `sysio.liq::stat` rows, one exact read each. + +`shortfall_amount` records `gift - paid` for the latest shortfall, not cumulative +debt. `shortfall_time` records that observation. `gift_overflow` records a gift +outside the asset range: this implementation pays nothing and retains the clock, +because it does not compute an exact partial-payment denominator for overflow. +`spot_deviation` records whether the latest attempt that reached the guard found spot past +it; that attempt's gift is the shortfall. An attempt that stops earlier (below `min_gift`, +during warm-up, under an Andon hold) writes nothing and leaves the flag as it was, except the saturated +below-minimum checkpoint above, which clears it. A later full payment clears all four diagnostics. +An unallocated emissions remainder stays in the treasury; it does not authorize a kicker budget by itself. The external staking-reward message and `sysio.dclaim::onreward` are removed. Pre-launch imports and already funded DClaim balances still link through AuthX and pay through diff --git a/contracts/tests/CMakeLists.txt b/contracts/tests/CMakeLists.txt index d46394f9da..60773dfb49 100644 --- a/contracts/tests/CMakeLists.txt +++ b/contracts/tests/CMakeLists.txt @@ -9,6 +9,7 @@ include_directories(${CMAKE_CURRENT_BINARY_DIR}) include_directories(${CMAKE_SOURCE_DIR}/contracts/sysio.system/include) include_directories(${CMAKE_SOURCE_DIR}/contracts/sysio.opp.common/include) include_directories(${CMAKE_SOURCE_DIR}/contracts/sysio.councl/include) +include_directories(${CMAKE_SOURCE_DIR}/contracts/sysio.kicker/include) # UNIT TESTING ### include(CTest) # eliminates DartConfiguration.tcl errors at test runtime enable_testing() diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index 202e506231..be96caed8e 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -36,6 +36,10 @@ struct contracts { static std::vector tokens_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.tokens/sysio.tokens.abi"); } static std::vector swap_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.swap/sysio.swap.wasm"); } static std::vector swap_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.swap/sysio.swap.abi"); } + /// Privileged T5 gifts to LIQ holders, built from this checkout. + static std::vector kicker_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.kicker/sysio.kicker.wasm"); } + /// ABI paired with the committed kicker WASM. + static std::vector kicker_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.kicker/sysio.kicker.abi"); } static std::vector liq_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.wasm"); } static std::vector liq_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.abi"); } static std::vector bond_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.bond/sysio.bond.wasm"); } diff --git a/contracts/tests/kicker_test_reference.hpp b/contracts/tests/kicker_test_reference.hpp new file mode 100644 index 0000000000..db4f8d2c33 --- /dev/null +++ b/contracts/tests/kicker_test_reference.hpp @@ -0,0 +1,18 @@ +#pragma once +/** @file kicker_test_reference.hpp + * @brief Independent literal arithmetic oracle shared by host and chain tests. + */ +#include +#include +namespace kicker_test_reference { +using wide = boost::multiprecision::uint256_t; +/// Independent literal divisor and fixed-point valuation. +inline wide wide_reference(uint64_t supply, uint16_t rate, uint64_t elapsed, uint64_t wire, uint64_t shadow) { + const wide accrued = wide(supply) * rate * elapsed / (wide(10'000) * 31'557'600 * 1'000'000); + return (accrued * ((wide(wire) << 64) / shadow)) >> 64; +} +/// Narrow the shared wide oracle only when the caller's fixture bounds the gift. +inline uint64_t reference(uint64_t supply, uint16_t rate, uint64_t elapsed, uint64_t wire, uint64_t shadow) { + return static_cast(wide_reference(supply, rate, elapsed, wire, shadow)); +} +} diff --git a/contracts/tests/kicker_tester.hpp b/contracts/tests/kicker_tester.hpp new file mode 100644 index 0000000000..ebb5a49d5f --- /dev/null +++ b/contracts/tests/kicker_tester.hpp @@ -0,0 +1,238 @@ +#pragma once +/** @file kicker_tester.hpp + * @brief Shared real-contract fixture for deterministic and differential kicker tests. + */ +#include "sysio_liq_tester.hpp" +#include +#include "kicker_test_reference.hpp" +namespace kicker_test_support { +namespace math = sysio::kicker_math; +using kicker_test_reference::wide; +using kicker_test_reference::reference; +/// Configuration field identifiers shared by the kicker fixture and its assertions. +namespace kicker { +namespace field { +inline constexpr auto cfg = "cfg"; +inline constexpr auto budget_remaining = "budget_remaining"; +inline constexpr auto min_interval_sec = "min_interval_sec"; +inline constexpr auto twap_window_sec = "twap_window_sec"; +inline constexpr auto max_spot_deviation_bps = "max_spot_deviation_bps"; +} +} +/// Real LIQ/swap/kicker plus emissions and epoch contracts; treasury state can be stressed explicitly. +class kicker_tester : public sysio_liq_tester { +public: + static constexpr auto KICKER = "sysio.kicker"_n; + static constexpr auto EPOCH = "sysio.epoch"_n; + static constexpr uint64_t seed = 75'000 * UNIT; + static constexpr uint64_t holder = 25'000 * UNIT; + static constexpr uint64_t budget = 1'000'000 * UNIT; + abi_serializer kicker_ser, system_ser, epoch_ser; + + /// Bootstrap the real reserve-owning contracts, then start one token's accrual clock. + kicker_tester() { + BOOST_REQUIRE_EQUAL(success(), regliqpool(SOLANA, LIQSOL, POOL_SYM, seed, seed)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, holder)); + create_accounts({KICKER, EPOCH, "sysio.andon"_n, "sysio.opreg"_n, "sysio.chalg"_n}); + deploy(KICKER, contracts::kicker_wasm(), contracts::kicker_abi(), kicker_ser, true); + deploy(SYSIO_ACCOUNT, contracts::system_wasm(), contracts::system_abi(), system_ser, true); + deploy(EPOCH, contracts::epoch_wasm(), contracts::epoch_abi(), epoch_ser, true); + BOOST_REQUIRE_EQUAL(success(), push(EPOCH, epoch_ser, EPOCH, "setconfig"_n, mvo() + ("epoch_duration_sec", 60)("operators_per_epoch", 7)("batch_operator_minimum_active", 21) + ("batch_op_groups", 3)("epoch_retention_envelope_log_count", 1000))); + const auto emission = mvo() + ("t1_allocation", 0)("t2_allocation", 0)("t3_allocation", 0) + ("t1_duration", 1)("t2_duration", 1)("t3_duration", 1)("min_claimable", 0) + ("t5_distributable", 375'000'000 * UNIT)("t5_floor", 125'000'000 * UNIT) + ("target_annual_decay_bps", 100)("annual_initial_emission", 1'000'000 * UNIT) + ("annual_max_emission", 1'000'000 * UNIT)("annual_min_emission", UNIT) + ("compute_bps", 0)("capex_bps", 0)("governance_bps", 0) + ("producer_bps", 7000)("batch_op_bps", 3000)("standby_end_rank", 22) + ("standby_bps", 0)("epoch_log_retention_count", 1000)("pay_cadence_epochs", 1); + BOOST_REQUIRE_EQUAL(success(), push(SYSIO_ACCOUNT, system_ser, SYSIO_ACCOUNT, "setemitcfg"_n, + mvo()("cfg", emission))); + BOOST_REQUIRE_EQUAL(success(), push(SYSIO_ACCOUNT, system_ser, SYSIO_ACCOUNT, "initt5"_n, + mvo()("start_time", time_point_sec(control->pending_block_time())))); + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + BOOST_REQUIRE_EQUAL(success(), add()); + } + /// Change the absolute earmark, minimum payment spacing, TWAP observation window and spot guard. + action_result configure(uint64_t remaining, uint32_t interval = math::default_min_interval_sec, + name signer = SYSIO_ACCOUNT, uint32_t window = math::default_twap_window_sec, + uint16_t deviation = math::default_max_spot_deviation_bps) { + return push(KICKER, kicker_ser, signer, "setconfig"_n, + mvo()(kicker::field::cfg, mvo()(kicker::field::budget_remaining, remaining) + (kicker::field::min_interval_sec, interval)(kicker::field::twap_window_sec, window) + (kicker::field::max_spot_deviation_bps, deviation))); + } + /// Decode the current governance settings, including defaults serialized by configure. + fc::variant configuration() { + return decode(kicker_ser, "kick_config", get_row_by_id(KICKER, KICKER, "kickcfg"_n, "kickcfg"_n.value)); + } + /// Start a token at the protocol defaults unless the test supplies overrides. + action_result add(symbol sym = LIQSOL_SYM, uint16_t rate = math::default_rate_bps, + uint64_t minimum = math::default_min_gift, uint64_t cap = math::max_daily_gift, + name signer = SYSIO_ACCOUNT) { + return push(KICKER, kicker_ser, signer, "addpool"_n, + mvo()("sym", sym.to_symbol_code())("rate_bps", rate)("min_gift", minimum)("max_gift_per_day", cap)); + } + /// Change a pool without closing its current accrual interval. + action_result change(uint16_t rate, uint64_t minimum, uint64_t cap = math::max_daily_gift, symbol sym = LIQSOL_SYM, + name signer = SYSIO_ACCOUNT) { + return push(KICKER, kicker_ser, signer, "setpool"_n, + mvo()("sym", sym.to_symbol_code())("rate_bps", rate)("min_gift", minimum)("max_gift_per_day", cap)); + } + /// Remove and re-add a pool at the protocol defaults, so its new interval checkpoints the current LIQ supply. + void reopen(symbol sym = LIQSOL_SYM) { + BOOST_REQUIRE_EQUAL(success(), push(KICKER, kicker_ser, SYSIO_ACCOUNT, "rmpool"_n, + mvo()("sym", sym.to_symbol_code()))); + BOOST_REQUIRE_EQUAL(success(), add(sym)); + } + /// Invoke a permissionless keeper action as an ordinary holder. + action_result kick(symbol sym = LIQSOL_SYM) { + return push(KICKER, kicker_ser, "bob"_n, "kick"_n, mvo()("sym", sym.to_symbol_code())); + } + /// Decode the per-symbol payment clock and diagnostics. + fc::variant pool(symbol sym = LIQSOL_SYM) { + return decode(kicker_ser, "kick_pool", get_row_by_id(KICKER, KICKER, "kickpools"_n, sym.to_symbol_code().value)); + } + /// Raw row bytes used to verify that minimum/interval skips write nothing. + std::vector bytes() { return get_row_by_id(KICKER, KICKER, "kickpools"_n, LIQSOL_SYM.to_symbol_code().value); } + /// Paid amount (fixture budgets bound this well below uint64). + uint64_t paid(symbol sym = LIQSOL_SYM) { return static_cast(pool(sym)["gifted_total"].as_uint128()); } + /// Exact binary pool state; ABI JSON time formatting loses microsecond precision. + struct pool_state { + uint64_t sym = 0, minimum = 0, shortfall = 0, cap = 0, day = 0; + uint16_t rate = 0; + time_point clock, observed; + fc::uint128_t total = 0, spent = 0; + bool overflow = false, deviation = false; + /// LIQ supply at the latest checkpoint; gifts are sized on the smaller of this and the current supply. + int64_t supply_snapshot = 0; + /// The price observation: swap's cumulative price at its start, and the start. + fc::uint128_t snapshot_lo = 0, snapshot_hi = 0; + time_point price_time; + }; + /// Decode every pool field once for clock and differential-state consumers. + pool_state decode_pool(symbol sym = LIQSOL_SYM) { + const auto data = get_row_by_id(KICKER, KICKER, "kickpools"_n, sym.to_symbol_code().value); + fc::datastream stream(data.data(), data.size()); + pool_state row; + fc::raw::unpack(stream, row.sym); + fc::raw::unpack(stream, row.rate); + fc::raw::unpack(stream, row.minimum); + fc::raw::unpack(stream, row.clock); + fc::raw::unpack(stream, row.total); + fc::raw::unpack(stream, row.shortfall); + fc::raw::unpack(stream, row.observed); + fc::raw::unpack(stream, row.overflow); + fc::raw::unpack(stream, row.deviation); + fc::raw::unpack(stream, row.cap); + fc::raw::unpack(stream, row.day); + fc::raw::unpack(stream, row.spent); + fc::raw::unpack(stream, row.supply_snapshot); + fc::raw::unpack(stream, row.snapshot_lo); + fc::raw::unpack(stream, row.snapshot_hi); + fc::raw::unpack(stream, row.price_time); + return row; + } + /// Read the serialized clock without JSON precision loss. + time_point kick_clock(symbol sym = LIQSOL_SYM) { return decode_pool(sym).clock; } + /// Elapsed microseconds for the next action's block timestamp. + uint64_t elapsed(symbol sym = LIQSOL_SYM) { + return (control->pending_block_time() - kick_clock(sym)).count(); + } + /// Independent 256-bit Q64.64 TWAP of the pool's current observation over serialized swap history, including + /// the idle tail to the next action's time. + wide twap(symbol sym = LIQSOL_SYM) { + const auto row = stat_row(sym); + const auto pair_code = symbol::from_string("9," + row["pair_symbol"].as_string()); + const auto pair = swap_pool_row(pair_code); + const auto observation = pool(sym); + const auto now = control->pending_block_time(); + const auto span = (now - observation["price_time"].as()).count(); + const auto accum = decode(swap_abi_ser, "price_accumulator", + get_row_by_id(SWAP_ACCOUNT, SWAP_ACCOUNT, "priceaccum"_n, pair_code.to_symbol_code().value)); + const auto cumulative = [](const fc::variant& v) -> wide { + return (wide(v["hi"].as_uint128()) << 128) + wide(v["lo"].as_uint128()); + }; + const auto wire = pair["pool2"]["quantity"].as().get_amount(); + const auto shadow = pair["pool1"]["quantity"].as().get_amount(); + const wide spot = shadow ? (wide(wire) << 64) / shadow : wide(0); + const wide current = cumulative(accum["price1"]) + spot * (now - accum["last_update"].as()).count(); + return span > 0 ? (current - cumulative(observation["price_snapshot"])) / span : wide(0); + } + /// Independent gift oracle: the bounded accrual of the smaller of the checkpointed and the current LIQ supply at + /// `twap()`, or 0 before the window has passed. + uint64_t expected(symbol sym = LIQSOL_SYM) { + const auto row = stat_row(sym); + const auto observation = pool(sym); + const auto span = (control->pending_block_time() - observation["price_time"].as()).count(); + if (span < configuration()[kicker::field::twap_window_sec].as_uint64() * math::micros_per_sec) return 0; + const auto sized_supply = std::min(observation["supply_snapshot"].as_int64(), + row["supply"].as().get_amount()); + const wide accrued = wide(sized_supply) * observation["rate_bps"].as_uint64() * + std::min(elapsed(sym), math::max_catchup_us) / (wide(10'000) * 31'557'600 * 1'000'000); + return static_cast((accrued * twap(sym)) >> 64); + } + /// Advance chain time in one block, without waiting on the host clock. + void wait(uint64_t seconds) { produce_block(fc::seconds(seconds)); } + /// Inject a known span of price history for arithmetic stress tests, independent of live reserves. + void price_history(symbol sym, symbol pair, uint64_t wire, uint64_t shadow, uint32_t seconds = math::day_sec) { + constexpr auto accumulator_table = "priceaccum"_n; + constexpr auto pool_table = "kickpools"_n; + const auto now = control->pending_block_time(); + const auto start = now - fc::seconds(seconds); + const auto zero = mvo()("lo", fc::uint128_t{0})("hi", fc::uint128_t{0}); + const wide cumulative = ((wide(wire) << 64) / shadow) * (now - start).count(); + const wide mask = (wide(1) << 128) - 1; + auto accum = mvo()("price1", mvo()("lo", static_cast(cumulative & mask)) + ("hi", static_cast(cumulative >> 128)))("price2", zero)("last_update", now); + write_row(SWAP_ACCOUNT, swap_abi_ser, accumulator_table, pair.to_symbol_code().value, "price_accumulator", accum); + // Preserve the exact microsecond accrual clock: ABI JSON time formatting truncates it. + auto data = get_row_by_id(KICKER, KICKER, pool_table, sym.to_symbol_code().value); + const auto observation = fc::raw::pack(std::make_tuple(fc::uint128_t{0}, fc::uint128_t{0}, start)); + BOOST_REQUIRE_GE(data.size(), observation.size()); + std::copy(observation.begin(), observation.end(), data.end() - observation.size()); + write_raw_row(KICKER, pool_table, sym.to_symbol_code().value, data); + } + /// Read a reserve-owning singleton through the deployed system ABI. + fc::variant treasury(name table, const char* type) { + return decode(system_ser, type, get_row_by_id(SYSIO_ACCOUNT, SYSIO_ACCOUNT, table, table.value)); + } + /// Install controlled reserve stress through actual serialized on-chain layouts. + void write_global(name table, const char* type, const variant_object& value) { + write_row(SYSIO_ACCOUNT, system_ser, table, table.value, type, value); + } + /// Inject serialized fixture stress state into a KV row without using contract arithmetic. + void write_row(name code, abi_serializer& ser, name table, uint64_t id, const char* type, + const variant_object& value) { + const auto encoded = ser.variant_to_binary(type, value, fixture_abi_yield_function()); + write_raw_row(code, table, id, encoded); + } + /// Store exact serialized bytes without routing time_point fields through JSON. + void write_raw_row(name code, name table, uint64_t id, const std::vector& encoded) { + char key[chain::kv_pri_key_size]; + chain::kv_encode_be64(key, id); + auto& db = const_cast(control->db()); + const auto& index = db.get_index(); + const auto it = index.find(boost::make_tuple(code, chain::compute_table_id(table.value), + std::string_view(key, sizeof(key)))); + if (it != index.end()) db.modify(*it, [&](auto& row) { row.value.assign(encoded.data(), encoded.size()); }); + else db.create([&](auto& row) { + row.code = code; + row.payer = SYSIO_ACCOUNT; + row.table_id = chain::compute_table_id(table.value); + row.key.assign(key, sizeof(key)); + row.value.assign(encoded.data(), encoded.size()); + }); + } + /// Add nonzero pending emissions and claims for reserve and gate tests. + void reserves(uint64_t pending, uint64_t claims) { + auto state = mvo(treasury("t5state"_n, "t5_state").get_object()); + state("pending_emission_amount", pending); + write_global("t5state"_n, "t5_state", state); + write_global("payclaimtot"_n, "pay_claim_total", mvo()("outstanding", claims)); + } +}; +} // namespace kicker_test_support diff --git a/contracts/tests/sysio.kicker_fuzz_tests.cpp b/contracts/tests/sysio.kicker_fuzz_tests.cpp new file mode 100644 index 0000000000..747eebdee5 --- /dev/null +++ b/contracts/tests/sysio.kicker_fuzz_tests.cpp @@ -0,0 +1,574 @@ +/** @file sysio.kicker_fuzz_tests.cpp + * @brief Seeded differential sequences: the model is independent in ARITHMETIC; + * its control flow follows the rulings in kick()'s order. + * Supply/configured-span price history/reserve rows are injected boundary inputs, not a model of swap execution. + * Two real LIQ pools share real treasury transfers and addyield; no kicker math helper is the oracle. + */ +#include "kicker_tester.hpp" +#include +#include +#include +#include +#include + +namespace { +using kicker_test_support::kicker_tester; +using kicker_test_reference::wide; +/// Literal protocol units, independently specified by the owner's rulings. +constexpr uint64_t unit = 1'000'000'000, micros = 1'000'000, day_seconds = 86'400; +constexpr uint64_t basis_points = 10'000; +/// Independent literal safety limits for the differential model. +constexpr uint64_t catchup_micros = 259'200'000'000; +constexpr uint64_t daily_ceiling = 1000 * unit; +/// Independent governance observation settings and maturity boundaries. +constexpr uint32_t minimum_window = 60, default_window = 86'400; +constexpr std::array windows{minimum_window, default_window, 2 * default_window}; +/// Random campaign sizes and logarithmic input scales; no step selects a forced outcome. +constexpr uint32_t default_spacing = 3600; +/// Longer randomly selected spacing exposes funded short intervals in the fixed campaign. +constexpr uint32_t random_spacing = day_seconds; +constexpr auto funding_account = "carol"_n; +constexpr auto remove_pool_action = "rmpool"_n; +constexpr uint64_t funding_range = 100, reserve_range = 50, treasury_base = 300; +static_assert(treasury_base > 2 * reserve_range + funding_range); +constexpr uint64_t minimum_range = 5, distributed_range = 1000; +constexpr uint64_t boundary_supply = 100'000 * unit, boundary_minimum = unit; +constexpr uint64_t boundary_funding = 100 * unit, boundary_distributed = 101 * unit; +constexpr uint64_t midday_seconds = day_seconds / 2; +constexpr uint64_t asset_limit = (uint64_t{1} << 62) - 1; +constexpr unsigned sequences = 150, steps = 16, batch_size = 10; +constexpr unsigned hold_toggle_denominator = 8, exhaustion_denominator = 5; +constexpr std::array supply_scales{unit, 100 * unit, 100'000 * unit, 100'000'000 * unit, asset_limit}; +constexpr std::array price_scales{unit, 100 * unit, 10'000 * unit, 1'000'000 * unit, asset_limit}; +constexpr auto precision_prefix = "9,"; +/// Contract identifiers shared by deterministic injection and random differential checks. +namespace field { +constexpr auto sym = "sym", distributable = "t5_distributable", supply = "supply"; +constexpr auto distributed = "total_distributed", floor = "t5_floor"; +constexpr auto pool1 = "pool1", pool2 = "pool2", pot = "pot", carry = "carry", index = "index"; +constexpr auto budget = "budget_remaining", spacing = "min_interval_sec"; +constexpr auto window = "twap_window_sec", deviation = "max_spot_deviation_bps"; +} +/// Historical campaign identity combines kicker PR 675 with the original source SHA prefix 651c01. +constexpr uint64_t seed_base = 0x675'651c01; +/// Golden-ratio Weyl stride spreads adjacent sequence seeds across the 64-bit seed space. +constexpr uint64_t seed_stride = 0x9e3779b97f4a7c15ULL; +constexpr auto config_table = "kickcfg"_n; +constexpr auto stat_table = "stat"_n; +constexpr auto eth_pair = "ETHPOOL"; +constexpr auto stat_type = "currency_stats"; +constexpr auto config_type = "kick_config"; +constexpr auto emission_table = "emitcfg"_n, state_table = "t5state"_n; +constexpr auto emission_type = "emission_config", state_type = "t5_state"; +constexpr auto swap_table = "stat"_n; +constexpr auto swap_type = "currency_stats"; +constexpr std::array intervals{1, 30, 3599, 3600, 15'768, 86'400, 2'592'000, 7'776'000}; +/// Literal spot-guard settings: the default, a tighter and the widest accepted threshold. +constexpr uint16_t default_deviation = 5'000; +constexpr std::array deviations{1'000, default_deviation, 10'000}; +/// Window prices as num/den of spot, weighted toward ones inside the default guard so the funding and cap paths +/// stay covered: equal (three entries), near spot on both sides, 3/2 (outside only the tight setting), and 1/2 and +/// 3/1 (outside the default). +constexpr std::array, 10> history_ratios{ + std::pair{1, 1}, {1, 1}, {1, 1}, {19, 20}, {21, 20}, {10, 11}, {11, 10}, {3, 2}, {1, 2}, {3, 1}}; +/// The window's price when it differs from spot, and the guard threshold the kick runs under. +struct pricing { + uint64_t twap_wire = 0, twap_shadow = 0; ///< zero shadow: the window priced at spot throughout + uint16_t deviation = default_deviation; +}; + +/// Outcomes counted only among independently sampled random kicks. +struct coverage { + uint64_t random_price = 0, full_random_price = 0, partial = 0, subminimum = 0, unfunded = 0; + uint64_t reserve_exhausted = 0, cap_exhausted = 0, exclusive_cap = 0, held_would_pay = 0, spaced_would_pay = 0; + uint64_t deviation_refused = 0, deviation_paid = 0; +}; +/// Half-observed random-only floors, independent of fixture precondition assertions. +constexpr coverage coverage_floor{ + .random_price = 132, .full_random_price = 20, .partial = 112, .subminimum = 207, .unfunded = 43, + .reserve_exhausted = 42, .cap_exhausted = 2, .exclusive_cap = 2, .held_would_pay = 142, .spaced_would_pay = 4, + .deviation_refused = 54, .deviation_paid = 82}; +/// Draw magnitude and scale in a specified order, reproducible across compiler evaluation orders. +uint64_t sample_amount(std::mt19937_64& random, const std::array& scales) { + const auto amount_draw = random(); + const auto scale_draw = random(); + return 1 + amount_draw % scales[scale_draw % scales.size()]; +} +/// Complete per-pool state, including exact microsecond clocks (JSON timestamps truncate them). +struct state { + uint64_t sym, minimum, total, shortfall, cap, day, spent; + uint16_t rate; + int64_t clock, observed; + bool overflow, deviation; + /// LIQ supply at the latest checkpoint; gifts are sized on the smaller of it and the current supply. + int64_t supply_snapshot; + /// The price observation: cumulative price at its start, and the start in microseconds. + wide snapshot; + int64_t price_time; +}; +/// Adapt the shared fixture decoder to the arithmetic model's bounded integer fields. +state read(kicker_tester& t, symbol sym) { + const auto row = t.decode_pool(sym); + state r; + r.sym = row.sym; r.rate = row.rate; r.minimum = row.minimum; + r.clock = row.clock.time_since_epoch().count(); + r.observed = row.observed.time_since_epoch().count(); + r.total = static_cast(row.total); r.spent = static_cast(row.spent); + r.shortfall = row.shortfall; r.overflow = row.overflow; r.deviation = row.deviation; + r.cap = row.cap; r.day = row.day; + r.supply_snapshot = row.supply_snapshot; + r.snapshot = (wide(row.snapshot_hi) << 128) + wide(row.snapshot_lo); + r.price_time = row.price_time.time_since_epoch().count(); + return r; +} +/// Assert every accrual and spending field rather than accepting a correct payment with an incorrect clock/diagnostic. +void compare_accrual(const state& a, const state& b) { + BOOST_REQUIRE_EQUAL(a.sym, b.sym); + BOOST_REQUIRE_EQUAL(a.rate, b.rate); + BOOST_REQUIRE_EQUAL(a.minimum, b.minimum); + BOOST_REQUIRE_EQUAL(a.clock, b.clock); + BOOST_REQUIRE_EQUAL(a.total, b.total); + BOOST_REQUIRE_EQUAL(a.shortfall, b.shortfall); + BOOST_REQUIRE_EQUAL(a.observed, b.observed); + BOOST_REQUIRE_EQUAL(a.overflow, b.overflow); + BOOST_REQUIRE_EQUAL(a.deviation, b.deviation); + BOOST_REQUIRE_EQUAL(a.cap, b.cap); + BOOST_REQUIRE_EQUAL(a.day, b.day); + BOOST_REQUIRE_EQUAL(a.spent, b.spent); + BOOST_REQUIRE_EQUAL(a.supply_snapshot, b.supply_snapshot); +} +/// `compare_accrual` plus the price observation, which every payment, full or partial, restarts. +void compare(const state& a, const state& b) { + compare_accrual(a, b); + BOOST_REQUIRE(a.snapshot == b.snapshot); + BOOST_REQUIRE_EQUAL(a.price_time, b.price_time); +} +/// The contract's checkpoint: restart the clock at `supply`, clear the diagnostics, keep the price observation. +uint64_t checkpoint(state& s, int64_t now, uint64_t supply) { + s.clock = now; s.shortfall = 0; s.observed = 0; s.overflow = false; s.deviation = false; + s.supply_snapshot = int64_t(supply); + return 0; +} +/// Apply kick rulings in order using shared wide exact interest and floored Q64 valuation. The price observation is +/// the one `inject_observation` installed: `observation` seconds at the window price (spot unless `price` says +/// otherwise), ending now; spot is `wire / shadow`. +uint64_t advance(state& s, int64_t now, uint64_t supply, uint64_t wire, uint64_t shadow, + uint64_t budget, uint64_t treasury, uint64_t pending, uint64_t claims, + uint64_t remaining, uint32_t spacing, bool held, + uint32_t window = default_window, uint32_t observation = default_window, pricing price = {}) { + BOOST_REQUIRE_GT(now, s.clock); + if (uint64_t(now - s.clock) < spacing * micros || held) return 0; + if (!supply || !s.supply_snapshot) return checkpoint(s, now, supply); + if (observation < window) return 0; + const uint64_t twap_wire = price.twap_shadow ? price.twap_wire : wire; + const uint64_t twap_shadow = price.twap_shadow ? price.twap_shadow : shadow; + const auto elapsed = std::min(uint64_t(now - s.clock), catchup_micros); + // Size on the smaller of the checkpointed and the current supply. + const auto sized = std::min(uint64_t(s.supply_snapshot), supply); + const wide gift = kicker_test_reference::wide_reference(sized, s.rate, elapsed, twap_wire, twap_shadow); + if (gift < s.minimum) { + // A saturated interval on a grown supply forfeits and restarts at the current supply, but only when the + // snapshot is binding: the current-supply gift at the same TWAP reaches the minimum. + if (supply > uint64_t(s.supply_snapshot) && uint64_t(now - s.clock) >= catchup_micros && + kicker_test_reference::wide_reference(supply, s.rate, elapsed, twap_wire, twap_shadow) >= s.minimum) + return checkpoint(s, now, supply); + return 0; + } + // The guard: |spot - TWAP| * 10000 > TWAP * threshold holds the clock and the observation and pays nothing. + const wide spot_q = (wide(wire) << 64) / shadow, twap_q = (wide(twap_wire) << 64) / twap_shadow; + s.deviation = (spot_q > twap_q ? spot_q - twap_q : twap_q - spot_q) * basis_points > twap_q * price.deviation; + if (s.deviation) { + s.overflow = gift > asset_limit; + s.shortfall = s.overflow ? 0 : uint64_t(gift); + s.observed = now; + return 0; + } + const wide reserve = wide(pending) + claims + remaining; + uint64_t available = reserve >= treasury ? 0 : uint64_t(wide(treasury) - reserve); + available = std::min(available, budget); + const auto today = uint64_t(now) / (day_seconds * micros); + const auto spent = today == s.day ? s.spent : 0; + available = std::min(available, spent >= s.cap ? 0 : s.cap - spent); + s.overflow = gift > asset_limit; + const uint64_t paid = !s.overflow && available >= s.minimum ? std::min(uint64_t(gift), available) : 0; + s.shortfall = s.overflow ? 0 : uint64_t(gift) - paid; + s.observed = s.shortfall || s.overflow ? now : 0; + if (paid) { + s.clock = now - elapsed + int64_t(wide(elapsed) * paid / gift); + s.total += paid; s.day = today; s.spent = spent + paid; + // A full payment checkpoints the current supply; a partial one keeps the supply it priced. + s.supply_snapshot = int64_t(wide(paid) == gift ? supply : sized); + // Every payment settles the history it priced: the observation restarts now, at the cumulative price. + s.snapshot = twap_q * (uint64_t(observation) * micros); + s.price_time = now; + } + return paid; +} +/// The observation `kicker_tester::price_history` installs: a zero snapshot `observation` seconds before now. +void inject_observation(state& s, int64_t now, uint32_t observation) { + s.snapshot = 0; + s.price_time = now - int64_t(observation) * int64_t(micros); +} +/// Inject current supply and spot through the real contract layouts. +void inject_price(kicker_tester& t, symbol sym, symbol pair_sym, uint64_t supply, uint64_t wire, uint64_t shadow) { + auto stat = mvo(t.stat_row(sym).get_object()); + stat(field::supply, asset(supply, sym)); + t.write_row(t.LIQ_ACCOUNT, t.liq_abi_ser, stat_table, sym.to_symbol_code().value, stat_type, stat); + auto pair = mvo(t.swap_pool_row(pair_sym).get_object()); + pair(field::pool1, extended_asset(asset(shadow, sym), t.LIQ_ACCOUNT)) + (field::pool2, extended_asset(asset(wire, t.WIRE_SYM), t.TOKEN_ACCOUNT)); + t.write_row(t.SWAP_ACCOUNT, t.swap_abi_ser, swap_table, pair_sym.to_symbol_code().value, swap_type, pair); +} +/// Set reserve components independently, including a ceiling below already distributed emissions. +void inject_reserves(kicker_tester& t, uint64_t pending, uint64_t claims, uint64_t remaining, + uint64_t distributed, bool saturated = false) { + auto treasury_state = mvo(t.treasury(state_table, state_type).get_object()); + treasury_state(field::distributed, distributed); + t.write_global(state_table, state_type, treasury_state); + auto cfg = mvo(t.treasury(emission_table, emission_type).get_object()); + cfg(field::distributable, cfg[field::floor].as_uint64() + (saturated ? 0 : distributed + remaining)); + t.write_global(emission_table, emission_type, cfg); + t.reserves(pending, claims); +} +/// Adjust custody with real transfers so available funding is an independently controlled input. +void fund(kicker_tester& t, uint64_t target) { + const auto current = uint64_t(t.wire_balance(t.SYSIO_ACCOUNT)); + if (current > target) + BOOST_REQUIRE_EQUAL(t.success(), t.transfer_wire(t.SYSIO_ACCOUNT, funding_account, current - target)); + else if (current < target) + BOOST_REQUIRE_EQUAL(t.success(), t.transfer_wire(funding_account, t.SYSIO_ACCOUNT, target - current)); +} +/// Execute one kick and check the complete model, custody, budget, yield distribution and unchanged treasury. +uint64_t check_kick(kicker_tester& t, state& expected, symbol sym, uint64_t supply, uint64_t wire, + uint64_t shadow, uint64_t budget, uint64_t pending, uint64_t claims, + uint64_t remaining, uint32_t spacing, bool held, + uint32_t window = default_window, uint32_t observation = default_window, pricing price = {}) { + const auto before = t.wire_balance(t.SYSIO_ACCOUNT); + const auto liq_before = t.wire_balance(t.LIQ_ACCOUNT); + const auto system_before = t.get_row_by_id(t.SYSIO_ACCOUNT, t.SYSIO_ACCOUNT, state_table, state_table.value); + const auto pot_before = t.index_row(sym); + const auto pot = pot_before.is_null() ? 0 : pot_before[field::pot].as_uint64(); + const auto carry = pot_before.is_null() ? 0 : pot_before[field::carry].as_uint64(); + const auto index = pot_before.is_null() ? fc::uint128_t{0} : pot_before[field::index].as_uint128(); + const auto now = t.control->pending_block_time().time_since_epoch().count(); + const auto token = t.stat_row(sym); + const auto pair = symbol::from_string(std::string(precision_prefix) + token["pair_symbol"].as_string()); + t.price_history(sym, pair, price.twap_shadow ? price.twap_wire : wire, price.twap_shadow ? price.twap_shadow : shadow, + observation); + inject_observation(expected, now, observation); + const auto paid = advance(expected, now, supply, wire, shadow, budget, before, + pending, claims, remaining, spacing, held, window, observation, price); + BOOST_REQUIRE_EQUAL(t.success(), t.kick(sym)); + compare(expected, read(t, sym)); + BOOST_REQUIRE_EQUAL(before - paid, t.wire_balance(t.SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(liq_before + paid, t.wire_balance(t.LIQ_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0, t.wire_balance(t.KICKER)); + const auto actual_cfg = t.decode(t.kicker_ser, config_type, + t.get_row_by_id(t.KICKER, t.KICKER, config_table, config_table.value)); + BOOST_REQUIRE_EQUAL(budget - paid, actual_cfg[field::budget].as_uint64()); + BOOST_REQUIRE_EQUAL(spacing, actual_cfg[field::spacing].as_uint64()); + BOOST_REQUIRE_EQUAL(window, actual_cfg[field::window].as_uint64()); + BOOST_REQUIRE_EQUAL(price.deviation, actual_cfg[field::deviation].as_uint64()); + BOOST_REQUIRE(system_before == t.get_row_by_id(t.SYSIO_ACCOUNT, t.SYSIO_ACCOUNT, + state_table, state_table.value)); + const auto idx = t.index_row(sym); + BOOST_REQUIRE_EQUAL(pot + paid, idx.is_null() ? 0 : idx[field::pot].as_uint64()); + if (paid) { + const auto distribution = yield_reference::distribute(paid, supply, carry); + BOOST_REQUIRE_EQUAL(distribution.carry, idx[field::carry].as_uint64()); + BOOST_REQUIRE(idx[field::index].as_uint128() == index + fc::uint128_t(distribution.index_delta)); + } else { + BOOST_REQUIRE_EQUAL(carry, idx.is_null() ? 0 : idx[field::carry].as_uint64()); + BOOST_REQUIRE(index == (idx.is_null() ? fc::uint128_t{0} : idx[field::index].as_uint128())); + } + return paid; +} +} // namespace + +BOOST_AUTO_TEST_SUITE(sysio_kicker_fuzz_tests) +/// Independently model below/equal/above maturity at minimum, default and multi-day governance windows. +BOOST_AUTO_TEST_CASE(kicker_fuzz_window_boundaries) { + for (const auto window : windows) { + for (const uint32_t observation : {window - 1, window, window + 1}) { + BOOST_TEST_CONTEXT("window=" << window << " observation=" << observation) { + kicker_tester t; + BOOST_REQUIRE_EQUAL(t.success(), t.change(basis_points, 1)); + BOOST_REQUIRE_EQUAL(t.success(), t.configure(boundary_funding, 1, t.SYSIO_ACCOUNT, window)); + t.wait(day_seconds); + fund(t, boundary_funding + 3 * unit); + inject_reserves(t, unit, unit, unit, boundary_distributed); + inject_price(t, t.LIQSOL_SYM, t.POOL_SYM, boundary_supply, unit, unit); + auto expected = read(t, t.LIQSOL_SYM); + const auto paid = check_kick(t, expected, t.LIQSOL_SYM, boundary_supply, unit, unit, + boundary_funding, unit, unit, unit, 1, false, window, observation); + BOOST_CHECK_EQUAL(paid > 0, observation >= window); + } + } + } +} + +/// Every named boundary owns a fresh fixture and asserts its outcome directly. +BOOST_AUTO_TEST_CASE(kicker_fuzz_boundaries) { + /// Isolated fixtures for the protocol gates and arithmetic boundaries. + enum class boundary { treasury_min_minus_one, budget_min_minus_one, available_exact, gift_exact, + cap_exact, saturation, reserve_exhausted, overflow, zero_supply, spacing, hold }; + constexpr std::array boundaries{boundary::treasury_min_minus_one, boundary::budget_min_minus_one, + boundary::available_exact, boundary::gift_exact, boundary::cap_exact, boundary::saturation, + boundary::reserve_exhausted, boundary::overflow, boundary::zero_supply, boundary::spacing, boundary::hold}; + for (unsigned index = 0; index < boundaries.size(); ++index) { + BOOST_TEST_CONTEXT("boundary=" << magic_enum::enum_name(boundaries[index])) { + const auto kind = boundaries[index]; + kicker_tester t; + // Move explicitly to the next UTC noon before any same-day cap test setup. + const auto seconds = uint64_t(t.control->pending_block_time().time_since_epoch().count()) / micros; + t.wait(day_seconds - seconds % day_seconds + midday_seconds); + // addpool checkpoints the current supply, and gifts are sized on it: the overflow boundary needs the limit. + if (kind == boundary::overflow) inject_price(t, t.LIQSOL_SYM, t.POOL_SYM, asset_limit, unit, unit); + BOOST_REQUIRE_EQUAL(t.success(), t.push(t.KICKER, t.kicker_ser, t.SYSIO_ACCOUNT, remove_pool_action, + mvo()(field::sym, t.LIQSOL_SYM.to_symbol_code()))); + BOOST_REQUIRE_EQUAL(t.success(), t.add()); + uint64_t minimum = boundary_minimum, budget = boundary_funding, available = boundary_funding; + uint64_t supply = boundary_supply, wire = unit, shadow = unit; + uint64_t pending = unit, claims = unit, remaining = unit, cap = daily_ceiling; + uint32_t spacing = 1; + if (kind == boundary::treasury_min_minus_one) available = minimum - 1; + if (kind == boundary::budget_min_minus_one) budget = minimum - 1; + if (kind == boundary::available_exact) available = minimum; + if (kind == boundary::cap_exact) cap = minimum; + if (kind == boundary::saturation) remaining = 0; + if (kind == boundary::overflow) { supply = asset_limit; wire = asset_limit; shadow = 1; } + if (kind == boundary::spacing) spacing = default_spacing; + BOOST_REQUIRE_EQUAL(t.success(), t.change(basis_points, minimum, cap)); + BOOST_REQUIRE_EQUAL(t.success(), t.configure(budget, spacing)); + fund(t, available + pending + claims + remaining); + if (kind == boundary::reserve_exhausted) + remaining = uint64_t(t.wire_balance(t.SYSIO_ACCOUNT)) + unit; + if (kind != boundary::spacing) t.wait(day_seconds / 4); + if (kind == boundary::gift_exact) { + minimum = kicker_test_reference::reference(supply, basis_points, + t.elapsed() + chain::config::block_interval_us, wire, shadow); + BOOST_REQUIRE_GT(minimum, 0); + BOOST_REQUIRE_EQUAL(t.success(), t.change(basis_points, minimum)); + } + abi_serializer andon_ser; + namespace cord = sysio_system::test_support::andon; + if (kind == boundary::hold) { + cord::deploy(t, andon_ser, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL(t.success(), cord::pull(t, andon_ser)); + } + if (kind == boundary::spacing) { + // The next kick lands exactly one block below the inclusive payment threshold. + const auto target = spacing * micros - chain::config::block_interval_us; + BOOST_REQUIRE_LT(t.elapsed(), target); + t.produce_block(fc::microseconds(target - t.elapsed())); + BOOST_REQUIRE_EQUAL(t.elapsed(), target); + } + // Install stress rows after all time-producing setup actions. + inject_reserves(t, pending, claims, remaining, boundary_distributed, kind == boundary::saturation); + inject_price(t, t.LIQSOL_SYM, t.POOL_SYM, supply, wire, shadow); + auto expected = read(t, t.LIQSOL_SYM); + if (kind == boundary::zero_supply) { + // Establish real nonzero diagnostics before exercising their zero-supply reset. + BOOST_REQUIRE_EQUAL(t.success(), t.configure(0, spacing)); + BOOST_REQUIRE_EQUAL(check_kick(t, expected, t.LIQSOL_SYM, supply, wire, shadow, 0, + pending, claims, remaining, spacing, false), 0); + BOOST_REQUIRE_GT(expected.shortfall, 0); + BOOST_REQUIRE_GT(expected.observed, 0); + BOOST_REQUIRE_EQUAL(t.success(), t.configure(budget, spacing)); + supply = 0; + inject_price(t, t.LIQSOL_SYM, t.POOL_SYM, supply, wire, shadow); + } + const auto old = expected; + const auto sized = std::min(uint64_t(expected.supply_snapshot), supply); + const auto gift = kicker_test_reference::wide_reference(sized, expected.rate, + std::min(t.elapsed(), catchup_micros), wire, shadow); + if (kind == boundary::spacing) BOOST_REQUIRE_GT(gift, minimum); + const auto paid = check_kick(t, expected, t.LIQSOL_SYM, supply, wire, shadow, budget, + pending, claims, remaining, spacing, kind == boundary::hold); + if (kind == boundary::treasury_min_minus_one || kind == boundary::budget_min_minus_one || + kind == boundary::reserve_exhausted) { + BOOST_REQUIRE_EQUAL(paid, 0); + BOOST_REQUIRE_GT(gift, minimum); + BOOST_REQUIRE_EQUAL(expected.shortfall, uint64_t(gift)); + BOOST_REQUIRE_EQUAL(expected.clock, old.clock); + } else if (kind == boundary::overflow) { + BOOST_REQUIRE_EQUAL(paid, 0); + BOOST_REQUIRE(expected.overflow); + BOOST_REQUIRE_EQUAL(expected.clock, old.clock); + } else if (kind == boundary::zero_supply) { + BOOST_REQUIRE_EQUAL(paid, 0); + BOOST_REQUIRE_GT(expected.clock, old.clock); + BOOST_REQUIRE_EQUAL(expected.shortfall, 0); + BOOST_REQUIRE_EQUAL(expected.observed, 0); + } else if (kind == boundary::spacing || kind == boundary::hold) { + BOOST_REQUIRE_EQUAL(paid, 0); + // The fixture installed a new observation; nothing else moved. + compare_accrual(old, expected); + BOOST_REQUIRE(expected.snapshot == 0); + BOOST_REQUIRE_GT(gift, minimum); + if (kind == boundary::spacing) { + BOOST_REQUIRE_EQUAL(t.elapsed(), spacing * micros); + BOOST_REQUIRE_GT(check_kick(t, expected, t.LIQSOL_SYM, supply, wire, shadow, budget, + pending, claims, remaining, spacing, false), 0); + } + if (kind == boundary::hold) { + BOOST_REQUIRE_EQUAL(t.success(), cord::clear(t, andon_ser)); + BOOST_REQUIRE_GT(check_kick(t, expected, t.LIQSOL_SYM, supply, wire, shadow, budget, + pending, claims, remaining, spacing, false), 0); + } + } else { + BOOST_REQUIRE_EQUAL(paid, kind == boundary::saturation ? uint64_t(gift) : minimum); + if (kind == boundary::gift_exact) { + BOOST_REQUIRE_EQUAL(gift, wide(minimum)); + BOOST_REQUIRE_EQUAL(expected.shortfall, 0); + } + if (kind == boundary::saturation) { + const auto cfg = t.treasury(emission_table, emission_type); + BOOST_REQUIRE_LT(cfg[field::distributable].as_uint64(), + cfg[field::floor].as_uint64() + boundary_distributed); + } + if (kind == boundary::cap_exact) { + BOOST_REQUIRE_EQUAL(expected.spent, cap); + const auto now = t.control->pending_block_time().time_since_epoch().count(); + BOOST_REQUIRE_EQUAL(expected.day, uint64_t(now) / (day_seconds * micros)); + BOOST_REQUIRE_EQUAL(check_kick(t, expected, t.LIQSOL_SYM, supply, wire, shadow, budget - paid, + pending, claims, remaining, spacing, false), 0); + BOOST_REQUIRE_GT(expected.shortfall, 0); + } + } + } + } +} + +/// Replay uses the batch's first sequence through the failing sequence: pools share fixture history. +/// Seeds are fixed; random two-pool sequences have no step anchors or fixed hold windows. +BOOST_AUTO_TEST_CASE(kicker_fuzz_sequences) { + coverage covered; + for (unsigned batch = 0; batch < sequences; batch += batch_size) { + BOOST_TEST_CONTEXT("batch=" << batch / batch_size << " first_sequence=" << batch) { + kicker_tester t; + const auto eth_pool = symbol::from_string(std::string(precision_prefix) + eth_pair); + BOOST_REQUIRE_EQUAL(t.success(), t.create(t.LIQETH_SYM, t.ETH, t.LIQETH)); + BOOST_REQUIRE_EQUAL(t.success(), t.regliqpool(t.ETH, t.LIQETH, eth_pool, t.seed, t.seed)); + namespace cord = sysio_system::test_support::andon; + abi_serializer andon_ser; + cord::deploy(t, andon_ser, contracts::andon_wasm(), contracts::andon_abi()); + const std::array symbols{t.LIQSOL_SYM, t.LIQETH_SYM}; + const std::array pairs{t.POOL_SYM, eth_pool}; + bool held = false; + for (unsigned sequence = batch; sequence < batch + batch_size; ++sequence) { + const auto seed = seed_base + seed_stride * sequence; + std::mt19937_64 random(seed); + BOOST_TEST_CONTEXT("seed=" << seed << " sequence=" << sequence) { + for (unsigned i = 0; i < symbols.size(); ++i) { + if (i == 0 || sequence != batch) + BOOST_REQUIRE_EQUAL(t.success(), t.push(t.KICKER, t.kicker_ser, t.SYSIO_ACCOUNT, remove_pool_action, + mvo()(field::sym, symbols[i].to_symbol_code()))); + BOOST_REQUIRE_EQUAL(t.success(), t.add(symbols[i])); + } + std::array model{read(t, symbols[0]), read(t, symbols[1])}; + for (unsigned step = 0; step < steps; ++step) { + BOOST_TEST_CONTEXT("step=" << step) { + const auto i = unsigned(random() % symbols.size()); + auto& expected = model[i]; + t.wait(intervals[random() % intervals.size()]); + if (random() % hold_toggle_denominator == 0) { + held = !held; + BOOST_REQUIRE_EQUAL(t.success(), held ? cord::pull(t, andon_ser) : cord::clear(t, andon_ser)); + } + expected.rate = random() % (basis_points + 1); + expected.minimum = 1 + random() % (minimum_range * unit); + expected.cap = random() % exhaustion_denominator == 0 ? daily_ceiling + : expected.minimum + random() % (funding_range * unit); + BOOST_REQUIRE_EQUAL(t.success(), t.change(expected.rate, expected.minimum, expected.cap, symbols[i])); + const uint32_t spacing = random() % 2 ? random_spacing : 1; + const uint64_t budget = random() % exhaustion_denominator == 0 + ? 0 : random() % (funding_range * unit); + const auto window = step % 2 ? minimum_window : default_window; + pricing price; + price.deviation = deviations[random() % deviations.size()]; + BOOST_REQUIRE_EQUAL(t.success(), t.configure(budget, spacing, t.SYSIO_ACCOUNT, window, + price.deviation)); + fund(t, treasury_base * unit + random() % (funding_range * unit)); + const auto treasury = uint64_t(t.wire_balance(t.SYSIO_ACCOUNT)); + const uint64_t pending = random() % (reserve_range * unit); + const uint64_t claims = random() % (reserve_range * unit); + const uint64_t free = random() % (funding_range * unit); + const uint64_t remaining = random() % exhaustion_denominator == 0 ? treasury + unit + : treasury - pending - claims - free; + const uint64_t distributed = 1 + random() % (distributed_range * unit); + inject_reserves(t, pending, claims, remaining, distributed); + const uint64_t supply = sample_amount(random, supply_scales); + const uint64_t shadow = sample_amount(random, price_scales); + const uint64_t wire = sample_amount(random, price_scales); + inject_price(t, symbols[i], pairs[i], supply, wire, shadow); + // The window's price: spot, or spot scaled by a literal ratio inside or outside the guard. + const auto [ratio_num, ratio_den] = history_ratios[random() % history_ratios.size()]; + price.twap_wire = uint64_t(std::clamp(wide(wire) * ratio_num / ratio_den, wide(1), wide(asset_limit))); + price.twap_shadow = shadow; + const auto now = t.control->pending_block_time().time_since_epoch().count(); + const auto old = expected; + auto without_hold = old; + const auto payable = advance(without_hold, now, supply, wire, shadow, budget, treasury, + pending, claims, remaining, spacing, false, window, default_window, + price); + auto without_spacing = old; + const auto unspaced_payable = advance(without_spacing, now, supply, wire, shadow, budget, treasury, + pending, claims, remaining, 0, held, window, default_window, + price); + const auto sized = std::min(uint64_t(old.supply_snapshot), supply); + const auto gift = kicker_test_reference::wide_reference(sized, old.rate, + std::min(uint64_t(now - old.clock), catchup_micros), + price.twap_wire, price.twap_shadow); + const auto paid = check_kick(t, expected, symbols[i], supply, wire, shadow, budget, + pending, claims, remaining, spacing, held, window, default_window, + price); + compare(model[1 - i], read(t, symbols[1 - i])); + const bool priced = !held && uint64_t(now - old.clock) >= spacing * micros; + const bool payable_gift = priced && gift >= old.minimum && gift <= asset_limit; + const bool guarded = payable_gift && expected.deviation; + covered.random_price += paid > 0 && wire != shadow; + covered.full_random_price += paid > 0 && wire != shadow && wide(paid) == gift; + covered.partial += paid > 0 && expected.shortfall > 0; + covered.subminimum += priced && gift > 0 && gift < old.minimum && paid == 0; + covered.unfunded += payable_gift && !guarded && paid == 0 && budget < old.minimum; + covered.reserve_exhausted += payable_gift && !guarded && paid == 0 && + wide(pending) + claims + remaining >= treasury; + covered.cap_exhausted += payable_gift && !guarded && paid == 0 && old.cap > 0 && old.spent >= old.cap && + old.day == uint64_t(now) / (day_seconds * micros); + covered.exclusive_cap += payable_gift && !guarded && paid == 0 && old.cap > 0 && old.spent >= old.cap && + old.day == uint64_t(now) / (day_seconds * micros) && budget >= old.minimum && + wide(pending) + claims + remaining < treasury && + wide(treasury) - pending - claims - remaining >= old.minimum; + covered.held_would_pay += held && payable > 0; + covered.spaced_would_pay += uint64_t(now - old.clock) < spacing * micros && unspaced_payable > 0; + covered.deviation_refused += guarded && paid == 0; + covered.deviation_paid += paid > 0 && price.twap_wire != wire; + } + } + } + } + } + } + BOOST_TEST_MESSAGE("coverage random_price=" << covered.random_price + << " full_random_price=" << covered.full_random_price << " partial=" << covered.partial + << " subminimum=" << covered.subminimum << " unfunded=" << covered.unfunded + << " reserve_exhausted=" << covered.reserve_exhausted << " cap_exhausted=" << covered.cap_exhausted + << " exclusive_cap=" << covered.exclusive_cap + << " held_would_pay=" << covered.held_would_pay << " spaced_would_pay=" << covered.spaced_would_pay + << " deviation_refused=" << covered.deviation_refused << " deviation_paid=" << covered.deviation_paid); + // Floors are about half the measured fixed-seed counts and guard against coverage drift. + BOOST_REQUIRE_GE(covered.random_price, coverage_floor.random_price); + BOOST_REQUIRE_GE(covered.full_random_price, coverage_floor.full_random_price); + BOOST_REQUIRE_GE(covered.exclusive_cap, coverage_floor.exclusive_cap); + BOOST_REQUIRE_GE(covered.spaced_would_pay, coverage_floor.spaced_would_pay); + BOOST_REQUIRE_GE(covered.partial, coverage_floor.partial); + BOOST_REQUIRE_GE(covered.subminimum, coverage_floor.subminimum); + BOOST_REQUIRE_GE(covered.unfunded, coverage_floor.unfunded); + BOOST_REQUIRE_GE(covered.reserve_exhausted, coverage_floor.reserve_exhausted); + BOOST_REQUIRE_GE(covered.cap_exhausted, coverage_floor.cap_exhausted); + BOOST_REQUIRE_GE(covered.held_would_pay, coverage_floor.held_would_pay); + BOOST_REQUIRE_GE(covered.deviation_refused, coverage_floor.deviation_refused); + BOOST_REQUIRE_GE(covered.deviation_paid, coverage_floor.deviation_paid); +} +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.kicker_math_tests.cpp b/contracts/tests/sysio.kicker_math_tests.cpp new file mode 100644 index 0000000000..56aa6d78b4 --- /dev/null +++ b/contracts/tests/sysio.kicker_math_tests.cpp @@ -0,0 +1,68 @@ +/** @file sysio.kicker_math_tests.cpp + * @brief Host arithmetic gates with an independent wide integer oracle. + */ +#include +#include +#include "../sysio.kicker/include/sysio.kicker/kicker_math.hpp" +#include +#include +#include "kicker_test_reference.hpp" +namespace math = sysio::kicker_math; +using kicker_test_reference::wide; +using kicker_test_reference::reference; +BOOST_AUTO_TEST_SUITE(sysio_kicker_math_tests) +/// A literal year at 200 bps pays exactly one fiftieth of principal. +BOOST_AUTO_TEST_CASE(literal_year) { + constexpr uint64_t principal = 100'000'000'000'000; + BOOST_CHECK(math::interest(principal, 200, 31'557'600'000'000ULL) == principal / 50); + BOOST_CHECK_EQUAL(2'000'000'000'000ULL, + math::gift(principal, 200, 31'557'600'000'000ULL, 1, 1).value()); +} +/// Bounds compare the on-chain helper with a wider independent reference, including uncapped long intervals. +BOOST_AUTO_TEST_CASE(overflow_bounds_and_floors) { + const auto max = math::max_amount; + const uint64_t elapsed = std::numeric_limits::max(); + const auto interest = math::interest(max, 10000, elapsed); + const auto correct = wide(max) * 10000 * elapsed / (wide(10'000) * 31'557'600 * 1'000'000); + BOOST_CHECK(wide(interest) == correct); + BOOST_CHECK(!math::gift(max, 10000, elapsed, max, 1)); + BOOST_CHECK(!math::gift(max, 10000, math::year_sec * math::micros_per_sec * 2, 1, 1)); + BOOST_CHECK_EQUAL(max, math::gift(max, 10000, math::year_sec * math::micros_per_sec, 1, 1).value()); + BOOST_CHECK_EQUAL(reference(max, 200, elapsed, 1, max), math::gift(max, 200, elapsed, 1, max).value()); + for (uint64_t s : {uint64_t{1}, uint64_t{100'000'000'000'000}, max}) + for (uint64_t dt : {uint64_t{1}, math::day_sec * math::micros_per_sec, + math::year_sec * math::micros_per_sec * 1000}) + BOOST_CHECK_EQUAL(reference(s, 200, dt, 1, max), math::gift(s, 200, dt, 1, max).value()); + BOOST_CHECK_EQUAL(0, math::gift(max, 0, elapsed, max, 1).value()); + BOOST_CHECK_EQUAL(0, math::gift(max, 10000, elapsed, 1, 0).value()); +} +/// The spot guard's threshold is inclusive and exact, on both sides of the TWAP and at the widest prices. +BOOST_AUTO_TEST_CASE(spot_deviation_threshold) { + const math::u128 one = math::u128{1} << 64; // 1 WIRE/LIQ in Q64.64 + BOOST_CHECK(!math::spot_deviates(one, one, 1)); + // Spot 1, TWAP 2: |1 - 2| / 2 is exactly 5,000 bps. + BOOST_CHECK(!math::spot_deviates(one, 2 * one, 5'000)); + BOOST_CHECK(math::spot_deviates(one, 2 * one, 4'999)); + // Spot 1, TWAP 1/2: exactly 10,000 bps. + BOOST_CHECK(!math::spot_deviates(one, one / 2, 10'000)); + BOOST_CHECK(math::spot_deviates(one, one / 2, 9'999)); + // Spot 11/10 of TWAP 10: exactly 1,000 bps. + BOOST_CHECK(!math::spot_deviates(11 * one, 10 * one, 1'000)); + BOOST_CHECK(math::spot_deviates(11 * one + 1, 10 * one, 1'000)); + // Spot 3/2 of TWAP 1: exactly the 5,000 bps default. + BOOST_CHECK_EQUAL(5'000, math::default_max_spot_deviation_bps); + BOOST_CHECK(!math::spot_deviates(3 * one / 2, one, math::default_max_spot_deviation_bps)); + BOOST_CHECK(math::spot_deviates(3 * one / 2 + 1, one, math::default_max_spot_deviation_bps)); + // A zero TWAP deviates from any nonzero spot; zero against zero does not. + BOOST_CHECK(math::spot_deviates(1, 0, 10'000)); + BOOST_CHECK(!math::spot_deviates(0, 0, 1)); + // Prices near 2^128 times 10,000 overflow 128 bits; the comparison stays exact against a wide oracle. + const math::u128 top = ~math::u128{0}; + for (const auto [spot, twap, bps] : {std::tuple{top, top / 2, 10'000}, + {top / 2, top, 5'000}, {top / 2, top, 4'999}, {top, top - 1, 1}}) { + const wide gap = spot > twap ? wide(spot) - wide(twap) : wide(twap) - wide(spot); + BOOST_CHECK_EQUAL(gap * 10'000 > wide(twap) * bps, math::spot_deviates(spot, twap, bps)); + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.kicker_tests.cpp b/contracts/tests/sysio.kicker_tests.cpp new file mode 100644 index 0000000000..9d2b0965a7 --- /dev/null +++ b/contracts/tests/sysio.kicker_tests.cpp @@ -0,0 +1,1261 @@ +#include "kicker_tester.hpp" +#include + +namespace { +namespace math = sysio::kicker_math; +namespace kicker = kicker_test_support::kicker; +/// Independent expectations pin the governance defaults and validation boundary. +constexpr uint32_t expected_default_twap_window_sec = 86'400; +constexpr uint32_t expected_min_twap_window_sec = 60; +constexpr uint32_t below_min_twap_window_sec = expected_min_twap_window_sec - 1; +constexpr uint32_t disabled_twap_window_sec = 0; +constexpr auto invalid_twap_window_message = "TWAP window must be at least 60 seconds"; +constexpr auto invalid_spot_deviation_message = "spot deviation must be 1 to 10000 bps"; +using kicker_test_reference::wide; +using kicker_test_reference::reference; +using kicker_test_support::kicker_tester; +/// The review's construction: 100,000 LIQ for exactly one day at price 1, and the LIQ minted just before the kick +/// that would size a current-supply gift at the 1,000 WIRE daily cap. +constexpr int64_t review_supply = 100'000 * sysio_liq_tester::UNIT; +constexpr uint64_t review_mint = 18'162'500 * sysio_liq_tester::UNIT; +/// floor(100,000 LIQ * 200 bps * one day / Julian year), in WIRE subunits: the honest gift of the review's supply. +constexpr uint64_t review_honest_gift = 5'475'701'574; +/// One LIQ subunit: a supply whose gift never reaches the default one-WIRE minimum. +constexpr int64_t tiny_supply = 1; +/// Burn every LIQ the fixture holds except `left` subunits, leaving the swap reserves (and so the price) as they are. +void burn_to(kicker_tester& t, int64_t left) { + BOOST_REQUIRE_EQUAL(t.success(), t.transfer_shadow("alice"_n, t.SYND_ACCOUNT, t.holder)); + BOOST_REQUIRE_EQUAL(t.success(), t.transfer_shadow(t.SWAP_ACCOUNT, t.SYND_ACCOUNT, t.seed)); + const auto token_code = sysio_system::test_support::codename_mvo(t.LIQSOL); + BOOST_REQUIRE_EQUAL(t.success(), t.push_liq(t.SYND_ACCOUNT, "burn"_n, + mvo()("token_code", token_code)("amount", uint64_t(t.seed + t.holder - left)))); + BOOST_REQUIRE_EQUAL(left, t.supply()); +} +/// Move the next action to exactly `span_us` after the pool's accrual clock, which must sit on a block boundary (a +/// checkpoint or addpool). It reads the clock through the deployed ABI, so it also runs against an older row layout. +void advance_elapsed(kicker_tester& t, uint64_t span_us) { + const auto target = t.pool()["last_kick"].as() + fc::microseconds(span_us); + BOOST_REQUIRE(t.control->pending_block_time() < target); + t.produce_block(target - t.control->pending_block_time()); + BOOST_REQUIRE(t.control->pending_block_time() == target); +} +/// One day of accrual, in microseconds. +constexpr uint64_t day_us = math::day_sec * math::micros_per_sec; +/// The fixture's swap reserves are equal, so every observation prices at exactly 1 WIRE per LIQ. +constexpr uint64_t unit_price = 1; +} // namespace + +BOOST_AUTO_TEST_SUITE(sysio_kicker_tests) + +/// Below-minimum calls leave row bytes untouched; the eventual payment covers the full open span. +BOOST_FIXTURE_TEST_CASE(skip_then_pay_and_pro_rata_claim, kicker_tester) { + const auto initial = bytes(); + const auto treasury_before = wire_balance(SYSIO_ACCOUNT); + wait(3600); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == initial); + BOOST_CHECK_EQUAL(treasury_before, wire_balance(SYSIO_ACCOUNT)); + wait(math::day_sec); + const auto gift = expected(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); + BOOST_CHECK_EQUAL(treasury_before - gift, wire_balance(SYSIO_ACCOUNT)); + BOOST_CHECK_EQUAL(0, wire_balance(KICKER)); + BOOST_CHECK_EQUAL(gift, pot()); + BOOST_CHECK_LE(owed("alice"_n), gift / 4); + const auto distribution = yield_reference::distribute(gift, supply(), 0); + BOOST_CHECK_EQUAL(static_cast(distribution.carry), index_row()["carry"].as_uint64()); + BOOST_CHECK_EQUAL(owed("alice"_n), static_cast( + yield_reference::wide(holder) * distribution.index_delta / yield_reference::Scale)); + BOOST_CHECK_LE(gift / 4 - owed("alice"_n), holder / yield_reference::Scale + 1); + const auto before = wire_balance("alice"_n); + const auto claimable = owed("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_CHECK_EQUAL(before + claimable, wire_balance("alice"_n)); +} + +/// Regular daily payments preserve simple accrual; a missed week can recover only 72 hours. +BOOST_AUTO_TEST_CASE(call_pattern_independence) { + constexpr uint64_t supply = 100'000'000'000'000; + constexpr uint64_t day = math::day_sec * math::micros_per_sec; + const auto once = math::gift(supply, 200, 7 * day, 1, 1).value(); + const auto daily = 7 * math::gift(supply, 200, day, 1, 1).value(); + BOOST_CHECK_GE(once, daily); + BOOST_CHECK_LE(once - daily, 7); + kicker_tester frequent, single; + BOOST_REQUIRE_EQUAL(frequent.success(), frequent.change(200, 1)); + BOOST_REQUIRE_EQUAL(single.success(), single.change(200, 1)); + const auto start_f = frequent.kick_clock(); + const auto start_s = single.kick_clock(); + uint64_t expected_frequent = 0; + for (unsigned i = 0; i < 7; ++i) { + frequent.wait(math::day_sec); + expected_frequent += reference(supply, 200, frequent.elapsed(), 1, 1); + BOOST_REQUIRE_EQUAL(frequent.success(), frequent.kick()); + } + const auto span = frequent.kick_clock() - start_f; + const auto wait_span = span - (single.control->pending_block_time() - start_s); + single.produce_block(wait_span); + BOOST_REQUIRE_EQUAL(single.success(), single.kick()); + // Daily payments include their block-slot offsets; the missed week is bounded to 72 hours. + const auto expected_single = reference(supply, 200, 3 * day, 1, 1); + BOOST_CHECK_EQUAL(expected_single, single.paid()); + BOOST_CHECK_EQUAL(expected_frequent, frequent.paid()); + BOOST_CHECK_GT(frequent.paid(), 2 * single.paid()); +} + +/// A real trade just before kick has only its elapsed-time weight in the gift, and LIQ minted just before it is not +/// sized at all (the checkpointed supply is the smaller); with spot now far above that TWAP, the spot guard refuses +/// it and holds the clock. +BOOST_FIXTURE_TEST_CASE(twap_after_trade_and_current_supply, kicker_tester) { + wait(math::day_sec); + const auto old_gift = expected(); + BOOST_REQUIRE_EQUAL(success(), openext("bob"_n, "bob"_n, extended_symbol{WIRE_SYM, TOKEN_ACCOUNT})); + BOOST_REQUIRE_EQUAL(success(), openext("bob"_n, "bob"_n, extended_symbol{LIQSOL_SYM, LIQ_ACCOUNT})); + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, "bob"_n, seed)); + BOOST_REQUIRE_EQUAL(success(), transfer_wire("bob"_n, SWAP_ACCOUNT, seed)); + BOOST_REQUIRE_EQUAL(success(), exchange("bob"_n, POOL_SYM, extended_asset{asset(seed, WIRE_SYM), TOKEN_ACCOUNT}, + asset(1, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL(success(), mint("bob"_n, holder)); + BOOST_REQUIRE_EQUAL(supply() - int64_t(holder), pool()["supply_snapshot"].as_int64()); + const auto twap_gift = expected(); + const auto pair = swap_pool_row(POOL_SYM); + const auto spot_gift = reference(supply(), 200, math::max_catchup_us, + pair["pool2"]["quantity"].as().get_amount(), pair["pool1"]["quantity"].as().get_amount()); + BOOST_REQUIRE_GT(spot_gift, 3 * old_gift); + BOOST_CHECK_LT(twap_gift, 2 * old_gift); // Spot nearly quadrupled; the new 25% of supply is not sized yet. + const auto clock = kick_clock(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK(decode_pool().deviation); + BOOST_CHECK_EQUAL(twap_gift, decode_pool().shortfall); + BOOST_CHECK(clock == kick_clock()); +} + +/// The review's ten-million-WIRE pump contributes zero weight to the TWAP when kick follows in the same block, and +/// the spot guard refuses the kick outright. +BOOST_FIXTURE_TEST_CASE(large_same_block_trade_cannot_amplify_gift, kicker_tester) { + constexpr uint64_t attack = 10'000'000 * UNIT; + wait(3 * math::day_sec); + BOOST_REQUIRE_EQUAL(success(), openext("bob"_n, "bob"_n, extended_symbol{WIRE_SYM, TOKEN_ACCOUNT})); + BOOST_REQUIRE_EQUAL(success(), openext("bob"_n, "bob"_n, extended_symbol{LIQSOL_SYM, LIQ_ACCOUNT})); + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, "bob"_n, attack)); + BOOST_REQUIRE_EQUAL(success(), transfer_wire("bob"_n, SWAP_ACCOUNT, attack)); + const auto baseline = expected(); + base_tester::push_action(SWAP_ACCOUNT, "exchange"_n, "bob"_n, mvo() + ("user", "bob"_n)("pair_token", POOL_SYM.to_symbol_code()) + ("ext_asset_in", extended_asset{asset(attack, WIRE_SYM), TOKEN_ACCOUNT}) + ("min_expected", asset(1, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL(baseline, expected()); + base_tester::push_action(KICKER, "kick"_n, "bob"_n, mvo()("sym", LIQSOL_SYM.to_symbol_code())); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK(decode_pool().deviation); + BOOST_CHECK_EQUAL(baseline, decode_pool().shortfall); + BOOST_CHECK_EQUAL(reference(supply(), 200, math::max_catchup_us, 1, 1), baseline); + produce_block(); +} + +/// Even a tiny minimum and abundant funds cannot bypass the first full day of observation. +BOOST_FIXTURE_TEST_CASE(twap_warmup_is_enforced, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), change(200, 1)); + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1)); + const auto initial = bytes(); + const auto until_boundary = uint64_t(math::default_twap_window_sec) * math::micros_per_sec - + elapsed() - config::block_interval_us; + produce_block(fc::microseconds(until_boundary)); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == initial); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_GT(paid(), 0); +} + +/// Governance defaults to one day, accepts one minute, and rejects every shorter window atomically. +BOOST_FIXTURE_TEST_CASE(twap_window_config_validation, kicker_tester) { + BOOST_CHECK_EQUAL(expected_default_twap_window_sec, configuration()[kicker::field::twap_window_sec].as_uint64()); + BOOST_CHECK_EQUAL(expected_min_twap_window_sec, math::min_twap_window_sec); + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1, SYSIO_ACCOUNT, math::min_twap_window_sec)); + BOOST_CHECK_EQUAL(expected_min_twap_window_sec, configuration()[kicker::field::twap_window_sec].as_uint64()); + for (const uint32_t window : {below_min_twap_window_sec, disabled_twap_window_sec}) { + BOOST_CHECK(mentions(configure(budget, 1, SYSIO_ACCOUNT, window), invalid_twap_window_message)); + BOOST_CHECK_EQUAL(expected_min_twap_window_sec, configuration()[kicker::field::twap_window_sec].as_uint64()); + } +} + +/// The minimum configured window pays at its inclusive boundary and starts a fresh observation on payment. +BOOST_FIXTURE_TEST_CASE(short_twap_window_pays, kicker_tester) { + constexpr uint32_t window = math::min_twap_window_sec; + BOOST_REQUIRE_EQUAL(success(), change(200, 1)); + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1, SYSIO_ACCOUNT, window)); + const auto initial = bytes(); + produce_block(fc::microseconds(uint64_t(window) * math::micros_per_sec - elapsed() - config::block_interval_us)); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == initial); + const auto gift = expected(); + BOOST_REQUIRE_GT(gift, 0); + const auto now = control->pending_block_time(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); + BOOST_CHECK(pool()["price_time"].as() == now); + const auto after = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == after); +} + +/// Lowering the window preserves all existing history and can make an in-flight observation immediately payable. +BOOST_FIXTURE_TEST_CASE(lower_twap_window_keeps_open_observation, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), change(200, 1)); + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1)); + wait(math::min_twap_window_sec); + const auto initial = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == initial); + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1, SYSIO_ACCOUNT, math::min_twap_window_sec)); + BOOST_CHECK(bytes() == initial); + const auto gift = expected(); + BOOST_REQUIRE_GT(gift, 0); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); +} + +/// Raising the window delays the same in-flight observation, retaining its timestamp, snapshot and accrual clock. +BOOST_FIXTURE_TEST_CASE(raise_twap_window_keeps_open_observation, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), change(200, 1)); + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1, SYSIO_ACCOUNT, math::min_twap_window_sec)); + wait(math::min_twap_window_sec); + BOOST_REQUIRE_GT(expected(), 0); + const auto initial = bytes(); + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1)); + BOOST_CHECK(bytes() == initial); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == initial); + produce_block(fc::microseconds(uint64_t(math::default_twap_window_sec) * math::micros_per_sec - elapsed())); + const auto gift = expected(); + BOOST_REQUIRE_GT(gift, 0); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); +} + +/// The default ceiling limits a payable high-supply pool even with ample treasury and budget. +BOOST_FIXTURE_TEST_CASE(default_daily_cap_is_enforced, kicker_tester) { + constexpr uint64_t extra_supply = 25'000'000 * UNIT; + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, extra_supply)); + reopen(); // checkpoint the minted supply; minted after the checkpoint, it would size nothing until the next one + wait(math::day_sec); + BOOST_REQUIRE_GT(expected(), math::max_daily_gift); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(math::max_daily_gift, paid()); + BOOST_CHECK_EQUAL(math::max_daily_gift, pool()["spent_today"].as_uint128()); + const auto before = paid(); + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + BOOST_REQUIRE_EQUAL(success(), change(200, 1)); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(before, paid()); +} + +/// Funding shortages record the latest deficit without losing the open interval; funding later pays all. +BOOST_FIXTURE_TEST_CASE(budget_shortfall_then_refund, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), configure(1)); + wait(math::day_sec); + const auto clock = kick_clock(); + const auto gift = expected(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK(clock == kick_clock()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + const auto catchup = expected(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(catchup, paid()); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); +} + +/// Reserve the next pay epoch as well as pending and claims; the old guard would strand advance. +BOOST_FIXTURE_TEST_CASE(reserve_guard_and_emissions_gate, kicker_tester) { + wait(86'400); + constexpr uint64_t pending = 100 * UNIT, claims = 50 * UNIT, ceiling = 5 * UNIT, available = UNIT; + auto cfg = mvo(treasury("emitcfg"_n, "emission_config").get_object()); + cfg("t5_distributable", 125'000'000 * UNIT + pending + ceiling); + write_global("emitcfg"_n, "emission_config", cfg); + reserves(pending, claims); + // The gate sizes the share on net headroom, and pending sits inside the gross headroom, so it is held twice. + const auto balance = wire_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, "carol"_n, + balance - 2 * pending - claims - ceiling - available)); + const auto gift = expected(); + // The old reserve allows this entire available gift, leaving less than the next emission. + BOOST_REQUIRE_LT(gift, ceiling + available); + BOOST_REQUIRE_GT(gift, available); + // Independent control applies the old permitted transfer and observes the fatal readiness block. + kicker_tester old; + old.wait(86'400); + auto old_cfg = mvo(old.treasury("emitcfg"_n, "emission_config").get_object()); + old_cfg("t5_distributable", 125'000'000 * UNIT + pending + ceiling); + old.write_global("emitcfg"_n, "emission_config", old_cfg); + old.reserves(pending, claims); + const auto old_gift = old.expected(); + const auto old_balance = old.wire_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(old.success(), old.transfer_wire(SYSIO_ACCOUNT, "carol"_n, + old_balance - pending - claims - ceiling - available + old_gift)); + BOOST_REQUIRE_EQUAL(old.success(), old.push(EPOCH, old.epoch_ser, EPOCH, "advance"_n, mvo())); + const auto blocked = old.decode(old.epoch_ser, "epoch_state", + old.get_row_by_id(EPOCH, EPOCH, "epochstate"_n, "epochstate"_n.value)); + BOOST_CHECK(blocked.is_null()); + const auto log = old.decode(old.epoch_ser, "blocklog_entry", + old.get_row_by_id(EPOCH, EPOCH, "blocklog"_n, 1)); + EmissionsBlockReason reason; + BOOST_REQUIRE(EmissionsBlockReason_Parse(log["reason"].as_string(), &reason)); + BOOST_CHECK_EQUAL(EMISSIONS_BLOCK_REASON_BALANCE_INSUFFICIENT, reason); + const auto span = std::min(elapsed(), math::max_catchup_us); + const auto base = control->pending_block_time() - fc::microseconds(span); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(available, paid()); + BOOST_CHECK_EQUAL(2 * pending + claims + ceiling, wire_balance(SYSIO_ACCOUNT)); + BOOST_CHECK_EQUAL(static_cast(wide(span) * available / gift), + (kick_clock() - base).count()); + BOOST_REQUIRE_EQUAL(success(), push(EPOCH, epoch_ser, EPOCH, "advance"_n, mvo())); + const auto epoch = decode(epoch_ser, "epoch_state", + get_row_by_id(EPOCH, EPOCH, "epochstate"_n, "epochstate"_n.value)); + BOOST_CHECK_EQUAL(1, epoch["current_epoch_index"].as_uint64()); + BOOST_CHECK_EQUAL(0, treasury("t5state"_n, "t5_state")["pending_emission_amount"].as_int64()); +} + +/// Wide reserves fail closed; an exhausted ceiling clamps at zero without arithmetic wrap. +BOOST_FIXTURE_TEST_CASE(reserve_bounds_and_exhausted_ceiling, kicker_tester) { + wait(86'400); + reserves(math::max_amount, std::numeric_limits::max()); + const auto clock = kick_clock(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK(kick_clock() == clock); + auto state = mvo(treasury("t5state"_n, "t5_state").get_object()); + state("pending_emission_amount", 0)("total_distributed", 250'000'001 * UNIT); + write_global("t5state"_n, "t5_state", state); + write_global("payclaimtot"_n, "pay_claim_total", mvo()("outstanding", 0)); + const auto balance = wire_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, "carol"_n, balance - 2 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(2 * UNIT, paid()); + BOOST_CHECK_EQUAL(0, wire_balance(SYSIO_ACCOUNT)); +} + +/// Andon holds every clock; clearing pays only the bounded stopped span. +BOOST_FIXTURE_TEST_CASE(andon_hold_then_pay, kicker_tester) { + namespace cord = sysio_system::test_support::andon; + abi_serializer ser; + cord::deploy(*this, ser, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL(success(), cord::pull(*this, ser)); + const auto initial = bytes(); + wait(2 * math::day_sec); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == initial); + BOOST_REQUIRE_EQUAL(success(), cord::clear(*this, ser)); + const auto gift = expected(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); +} + +/// Ordinary holders can kick, while all four governance actions require sysio. +BOOST_FIXTURE_TEST_CASE(auth_validation_and_rmpool, kicker_tester) { + BOOST_CHECK(mentions(configure(budget, 3600, "alice"_n), "missing authority of sysio")); + BOOST_CHECK(mentions(add(LIQSOL_SYM, 200, UNIT, 0, "alice"_n), "missing authority of sysio")); + BOOST_CHECK(mentions(change(200, UNIT, 0, LIQSOL_SYM, "alice"_n), "missing authority of sysio")); + BOOST_CHECK(mentions(push(KICKER, kicker_ser, "alice"_n, "rmpool"_n, + mvo()("sym", LIQSOL_SYM.to_symbol_code())), "missing authority of sysio")); + BOOST_CHECK(mentions(add(), "already configured")); + BOOST_CHECK(mentions(change(10001, UNIT), "rate exceeds")); + BOOST_CHECK(mentions(change(200, 0), "invalid minimum")); + BOOST_CHECK(mentions(change(200, UNIT, UNIT - 1), "invalid daily cap")); + BOOST_CHECK(mentions(change(200, UNIT, 0), "invalid daily cap")); + BOOST_CHECK(mentions(add(LIQETH_SYM, 200, UNIT, 0), "invalid daily cap")); + BOOST_CHECK(mentions(change(200, UNIT, math::max_daily_gift + 1), "invalid daily cap")); + BOOST_CHECK(mentions(configure(budget, 0), "interval must be positive")); + BOOST_CHECK(mentions(configure(math::max_amount + 1), "budget exceeds")); + BOOST_REQUIRE_EQUAL(success(), push(KICKER, kicker_ser, SYSIO_ACCOUNT, "rmpool"_n, + mvo()("sym", LIQSOL_SYM.to_symbol_code()))); + BOOST_CHECK(pool().is_null()); + BOOST_CHECK(mentions(kick(), "unknown kicker pool")); + wait(math::day_sec); + BOOST_REQUIRE_EQUAL(success(), add()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK_LT(elapsed(), 3600 * math::micros_per_sec); +} + +/// A second transaction in the same block and calls before min_interval make no second gift. +BOOST_FIXTURE_TEST_CASE(min_interval_and_same_block, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), change(200, 1)); + const auto initial = bytes(); + wait(60); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == initial); + wait(math::day_sec); + base_tester::push_action(KICKER, "kick"_n, "alice"_n, mvo()("sym", LIQSOL_SYM.to_symbol_code())); + const auto first = bytes(); + base_tester::push_action(KICKER, "kick"_n, "bob"_n, mvo()("sym", LIQSOL_SYM.to_symbol_code())); + BOOST_CHECK(bytes() == first); + produce_block(); +} + +/// Equality with the minimum pays; a new rate applies retrospectively to the still-open interval. +BOOST_FIXTURE_TEST_CASE(minimum_boundary_and_rate_change, kicker_tester) { + wait(math::day_sec); + const auto old_clock = kick_clock(); + BOOST_REQUIRE_EQUAL(success(), change(400, 1)); + BOOST_CHECK(old_clock == kick_clock()); + // setpool itself produces one block; compute the boundary for the next kick's timestamp. + const auto future_elapsed = std::min(elapsed() + config::block_interval_us, math::max_catchup_us); + const auto boundary = reference(supply(), 400, future_elapsed, 1, 1); + BOOST_REQUIRE_EQUAL(success(), change(400, boundary)); + BOOST_REQUIRE_EQUAL(boundary, expected()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(boundary, paid()); +} + +/// A three-day outage pays the daily cap within bounded 72-hour accrual, retaining its floored pro-rata remainder +/// for later observations. +BOOST_FIXTURE_TEST_CASE(daily_cap_partial_catchup, kicker_tester) { + constexpr auto cap = 2 * UNIT; + BOOST_REQUIRE_EQUAL(success(), change(200, 1, cap)); + // Align the first payment to UTC noon so the one-hour retry cannot cross a day boundary. + const auto utc_seconds = control->pending_block_time().time_since_epoch().count() / math::micros_per_sec; + wait(3 * math::day_sec + (math::day_sec + math::day_sec / 2 - utc_seconds % math::day_sec) % math::day_sec); + const auto span = std::min(elapsed(), math::max_catchup_us); + const auto base = control->pending_block_time() - fc::microseconds(span); + const auto gift = expected(); + const auto now = control->pending_block_time(); + BOOST_REQUIRE_GT(gift, cap); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(cap, paid()); + BOOST_CHECK_EQUAL(gift - cap, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK(pool()["shortfall_time"].as() == now); + BOOST_CHECK_EQUAL(cap, pool()["spent_today"].as_uint128()); + const auto payment_day = now.time_since_epoch().count() / (math::day_sec * math::micros_per_sec); + BOOST_CHECK_EQUAL(payment_day, pool()["day"].as_uint64()); + const auto advanced = (kick_clock() - base).count(); + const wide numerator = wide(span) * cap; + BOOST_CHECK_EQUAL(static_cast(numerator / gift), advanced); + BOOST_CHECK(numerator % gift != 0); + BOOST_CHECK(pool()["price_time"].as() == now); + // The partial payment restarted the observation: an hourly retry is a TWAP warm-up skip that writes nothing. + wait(3600); + const auto held = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(held == bytes()); + BOOST_CHECK_EQUAL(cap, paid()); + // Each later UTC noon has a fresh day's ceiling and a mature observation; the remainder stays open. + for (unsigned day = 0; day < 4; ++day) { + const auto before = paid(); + wait(day == 0 ? math::day_sec - 3600 : math::day_sec); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_GT(paid(), before); + BOOST_CHECK_LE(paid() - before, cap); + } + BOOST_CHECK_GT(pool()["shortfall_amount"].as_uint64(), 0); + BOOST_CHECK(!pool()["gift_overflow"].as_bool()); +} + +/// Missing emission configuration fails closed even when T5 state and a payable gift exist. +BOOST_FIXTURE_TEST_CASE(missing_emitcfg_fails_closed, kicker_tester) { + // Advance before removing the row: a skipped block aborts pending state, including direct fixture edits. + wait(math::day_sec); + constexpr auto table = "emitcfg"_n; + char key[chain::kv_pri_key_size]; + chain::kv_encode_be64(key, table.value); + auto& db = const_cast(control->db()); + const auto& index = db.get_index(); + const auto it = index.find(boost::make_tuple(SYSIO_ACCOUNT, chain::compute_table_id(table.value), + std::string_view(key, sizeof(key)))); + BOOST_REQUIRE(it != index.end()); + db.remove(*it); + BOOST_REQUIRE(treasury(table, "emission_config").is_null()); + BOOST_REQUIRE(!treasury("t5state"_n, "t5_state").is_null()); + const auto gift = expected(); + BOOST_REQUIRE_GE(gift, pool()["min_gift"].as_uint64()); + const auto clock = kick_clock(); + const auto balance = wire_balance(SYSIO_ACCOUNT); + const auto yield_before = pot(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK_EQUAL(balance, wire_balance(SYSIO_ACCOUNT)); + BOOST_CHECK_EQUAL(0, wire_balance(KICKER)); + BOOST_CHECK_EQUAL(yield_before, pot()); + BOOST_CHECK(clock == kick_clock()); + BOOST_CHECK_EQUAL(gift, pool()["shortfall_amount"].as_uint64()); +} + +/// A budget shortage pays what it can and advances exactly the floored paid fraction. +BOOST_FIXTURE_TEST_CASE(budget_partial_pro_rata, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), configure(2 * UNIT)); + wait(86'400); + const auto span = std::min(elapsed(), math::max_catchup_us); + const auto base = control->pending_block_time() - fc::microseconds(span); + const auto gift = expected(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(2 * UNIT, paid()); + BOOST_CHECK_EQUAL(static_cast(wide(span) * (2 * UNIT) / gift), + (kick_clock() - base).count()); + BOOST_CHECK_EQUAL(gift - 2 * UNIT, pool()["shortfall_amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + // The payment restarted the observation, so refunding does not reprice the remainder at once. + wait(3600); + const auto held = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(held == bytes()); + wait(math::day_sec - 3600); + const auto remaining = expected(); + BOOST_CHECK_EQUAL(reference(supply(), 200, std::min(elapsed(), math::max_catchup_us), 1, 1), remaining); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(2 * UNIT + remaining, paid()); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); +} + +/// Review of #675: a spike that funds a capped partial payment must not price the remainder again after the price +/// unwinds. 100,000 LIQ at 2% for 72 hours, observed at 61 WIRE/LIQ, is a gift above the 1,000 WIRE cap; the cap is +/// paid. A day later, back at 1 WIRE/LIQ, the next payment must be the remainder's and the new day's accrual at the +/// unwound price (about 5.5 WIRE). Reusing the spike's observation paid about 250 WIRE: one excursion funded two +/// capped payouts. +BOOST_FIXTURE_TEST_CASE(spike_capped_partial_then_unwind, kicker_tester) { + constexpr uint64_t spike = 61; + // The injected spike leaves spot at 1 against a TWAP of 61 (98.4% away): this case isolates the observation + // checkpoint, so it runs the spot guard at its widest setting. + BOOST_REQUIRE_EQUAL(success(), configure(budget, math::default_min_interval_sec, SYSIO_ACCOUNT, + math::default_twap_window_sec, uint16_t{10'000})); + wait(3 * math::day_sec); + BOOST_REQUIRE_EQUAL(100'000 * UNIT, supply()); + BOOST_REQUIRE_GE(elapsed(), math::max_catchup_us); + price_history(LIQSOL_SYM, POOL_SYM, spike * UNIT, UNIT, 3 * math::day_sec); + const auto spiked = reference(supply(), 200, math::max_catchup_us, spike, 1); + BOOST_REQUIRE_EQUAL(spiked, expected()); + BOOST_REQUIRE_GT(spiked, math::max_daily_gift); + const auto first_at = control->pending_block_time(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_REQUIRE_EQUAL(math::max_daily_gift, paid()); + BOOST_CHECK_EQUAL(spiked - math::max_daily_gift, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK(pool()["price_time"].as() == first_at); + + // The spot price never moved (the spike is injected history), so the next day observes 1 WIRE/LIQ throughout. + wait(math::day_sec); + const auto unwound = reference(supply(), 200, std::min(elapsed(), math::max_catchup_us), 1, 1); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(unwound, paid() - math::max_daily_gift); + BOOST_CHECK_LT(paid() - math::max_daily_gift, 10 * UNIT); +} + +/// A year of downtime pays 72 hours' interest and closes the old backlog; a retry cannot recover it. +BOOST_FIXTURE_TEST_CASE(year_outage_is_bounded, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), change(200, 1)); + const auto start = kick_clock(); + produce_block(fc::microseconds(31'557'600'000'000LL - elapsed())); + BOOST_REQUIRE_EQUAL(31'557'600'000'000LL, elapsed()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(reference(supply(), 200, math::max_catchup_us, 1, 1), paid()); + BOOST_CHECK_EQUAL(31'557'600'000'000LL, (kick_clock() - start).count()); + const auto first = paid(); + wait(3600); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(first, paid()); +} + +/// Each new LIQ token is configured through existing registries and addpool; clocks and rates are independent. +BOOST_FIXTURE_TEST_CASE(per_token_rates_and_third_token, kicker_tester) { + const auto third = symbol::from_string("9,LIQALT"); + const auto eth_pool = symbol::from_string("9,ETHPOOL"); + const auto third_pool = symbol::from_string("9,ALTPOOL"); + BOOST_REQUIRE_EQUAL(success(), create(LIQETH_SYM, ETH, LIQETH)); + BOOST_CHECK(mentions(add(LIQETH_SYM), "no yield pool")); + BOOST_REQUIRE_EQUAL(success(), regliqpool(ETH, LIQETH, eth_pool, seed, seed)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, holder, LIQETH)); + BOOST_REQUIRE_EQUAL(success(), add(LIQETH_SYM, 300)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, "LIQALT", ChainKind::CHAIN_KIND_EVM, ETH)); + BOOST_REQUIRE_EQUAL(success(), create(third, ETH, "LIQALT")); + BOOST_REQUIRE_EQUAL(success(), regliqpool(ETH, "LIQALT", third_pool, seed, seed)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, holder, "LIQALT")); + BOOST_REQUIRE_EQUAL(success(), add(third, 400)); + const auto sol_clock = kick_clock(); + const auto eth_clock = kick_clock(LIQETH_SYM); + wait(math::day_sec); + const auto third_gift = expected(third); + BOOST_REQUIRE_EQUAL(success(), kick(third)); + BOOST_CHECK_EQUAL(third_gift, paid(third)); + BOOST_CHECK(sol_clock == kick_clock()); + BOOST_CHECK(eth_clock == kick_clock(LIQETH_SYM)); + for (const auto sym : {LIQSOL_SYM, LIQETH_SYM}) { + const auto gift = expected(sym); + BOOST_REQUIRE_EQUAL(success(), kick(sym)); + BOOST_CHECK_EQUAL(gift, paid(sym)); + } + BOOST_CHECK_GT(paid(third), paid(LIQETH_SYM)); + BOOST_CHECK_GT(paid(LIQETH_SYM), paid()); +} + +/// The real action never wraps an asset-sized gift; it records overflow and leaves the debt window open. +BOOST_FIXTURE_TEST_CASE(action_overflow_holds_clock, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, math::max_amount - supply())); + reopen(); + BOOST_REQUIRE_EQUAL(success(), change(10000, 1)); + wait(2 * math::year_sec); + price_history(LIQSOL_SYM, POOL_SYM, math::max_amount, 1); + const auto clock = kick_clock(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(pool()["gift_overflow"].as_bool()); + BOOST_CHECK(clock == kick_clock()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_REQUIRE_EQUAL(success(), change(0, 1)); + BOOST_REQUIRE_EQUAL(success(), kick()); + // Below-minimum kick writes nothing: rate 0 intentionally does not clear the prior overflow flag. + BOOST_CHECK(pool()["gift_overflow"].as_bool()); + BOOST_REQUIRE_EQUAL(success(), change(1, 1)); + // Lower supply enough to make a later full payment representable and funded. + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, SYND_ACCOUNT, math::max_amount - seed)); + BOOST_REQUIRE_EQUAL(success(), push_liq(SYND_ACCOUNT, "burn"_n, + mvo()("token_code", codename_mvo(LIQSOL))("amount", math::max_amount - seed))); + price_history(LIQSOL_SYM, POOL_SYM, 1, 1); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_GT(paid(), 0); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK(!pool()["gift_overflow"].as_bool()); + // Sized on the burned-down supply, the payment was full and checkpoints it. + BOOST_CHECK_EQUAL(supply(), pool()["supply_snapshot"].as_int64()); +} + +/// Zero supply clears an actual overflow observation as well as its timestamp. +BOOST_FIXTURE_TEST_CASE(zero_supply_clears_overflow, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, math::max_amount - supply())); + reopen(); + BOOST_REQUIRE_EQUAL(success(), change(10000, 1)); + wait(2 * 31'557'600); + price_history(LIQSOL_SYM, POOL_SYM, math::max_amount, 1); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_REQUIRE(pool()["gift_overflow"].as_bool()); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, SYND_ACCOUNT, math::max_amount - seed)); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow(SWAP_ACCOUNT, SYND_ACCOUNT, seed)); + BOOST_REQUIRE_EQUAL(success(), push_liq(SYND_ACCOUNT, "burn"_n, + mvo()("token_code", codename_mvo(LIQSOL))("amount", math::max_amount))); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK_EQUAL(0, pool()["shortfall_time"].as().time_since_epoch().count()); + BOOST_CHECK(!pool()["gift_overflow"].as_bool()); + BOOST_CHECK_EQUAL(0, pool()["supply_snapshot"].as_int64()); +} + +/// Empty supply advances only its own clock; no inline addyield can be emitted without holders. +BOOST_FIXTURE_TEST_CASE(zero_supply_resets_clock_without_payment, kicker_tester) { + BOOST_REQUIRE_EQUAL(success(), configure(1)); + wait(86'400); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_REQUIRE_GT(pool()["shortfall_amount"].as_uint64(), 0); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, SYND_ACCOUNT, holder)); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow(SWAP_ACCOUNT, SYND_ACCOUNT, seed)); + BOOST_REQUIRE_EQUAL(success(), push_liq(SYND_ACCOUNT, "burn"_n, + mvo()("token_code", codename_mvo(LIQSOL))("amount", seed + holder))); + BOOST_REQUIRE_EQUAL(0, supply()); + wait(math::day_sec); + const auto before = wire_balance(SYSIO_ACCOUNT); + const auto clock = kick_clock(); + namespace cord = sysio_system::test_support::andon; + abi_serializer ser; + cord::deploy(*this, ser, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL(success(), cord::pull(*this, ser)); + const auto held = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == held); + BOOST_REQUIRE_EQUAL(success(), cord::clear(*this, ser)); + const auto trace = base_tester::push_action(KICKER, "kick"_n, "bob"_n, + mvo()("sym", LIQSOL_SYM.to_symbol_code())); + BOOST_REQUIRE(trace && !trace->except); + BOOST_CHECK(clock < kick_clock()); + BOOST_CHECK_EQUAL(before, wire_balance(SYSIO_ACCOUNT)); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK_EQUAL(0, pool()["shortfall_time"].as().time_since_epoch().count()); + BOOST_CHECK(!pool()["gift_overflow"].as_bool()); + BOOST_CHECK_EQUAL(0, pool()["supply_snapshot"].as_int64()); + BOOST_CHECK_EQUAL(0, paid()); + for (const auto& action : trace->action_traces) + BOOST_CHECK(action.act.name != "addyield"_n); + produce_block(); +} + +/// Budget equality pays exactly; funding bookkeeping and all transfers roll back on inline failure. +BOOST_FIXTURE_TEST_CASE(exact_budget_and_atomic_inline_failure, kicker_tester) { + wait(math::day_sec); + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + // Configure consumes a half-second block; equality is measured at the ensuing kick time. + const auto exact = reference(supply(), 200, + std::min(elapsed() + config::block_interval_us, math::max_catchup_us), 1, 1); + BOOST_REQUIRE_EQUAL(success(), configure(exact)); + BOOST_REQUIRE_EQUAL(exact, expected()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(exact, paid()); + const auto cfg = decode(kicker_ser, "kick_config", get_row_by_id(KICKER, KICKER, "kickcfg"_n, "kickcfg"_n.value)); + BOOST_CHECK_EQUAL(0, cfg["budget_remaining"].as_uint64()); + wait(math::day_sec); + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + const auto clock = bytes(); + const auto balance = wire_balance(SYSIO_ACCOUNT); + base_tester::push_action(SYSIO_ACCOUNT, "setpriv"_n, SYSIO_ACCOUNT, + mvo()("account", KICKER)("is_priv", 0)); + const auto failure = kick(); + BOOST_CHECK(failure != success()); + BOOST_CHECK(bytes() == clock); + BOOST_CHECK_EQUAL(balance, wire_balance(SYSIO_ACCOUNT)); + set_privileged(KICKER); + // Restore sufficient funding because the failed push produced another block. + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_GT(paid(), exact); +} + +/// A quarter holder claims the exact floored quarter gift when the existing yield-index scale divides supply. +BOOST_FIXTURE_TEST_CASE(quarter_holder_claims_floored_gift, kicker_tester) { + constexpr uint64_t small_seed = 750 * UNIT, small_holder = 250 * UNIT; + const auto pair = symbol::from_string("9,ETHPOOL"); + BOOST_REQUIRE_EQUAL(success(), create(LIQETH_SYM, ETH, LIQETH)); + BOOST_REQUIRE_EQUAL(success(), regliqpool(ETH, LIQETH, pair, small_seed, small_seed)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, small_holder, LIQETH)); + BOOST_REQUIRE_EQUAL(success(), add(LIQETH_SYM, math::default_rate_bps, 1)); + wait(math::year_sec); + const auto gift = expected(LIQETH_SYM); + BOOST_REQUIRE_EQUAL(success(), kick(LIQETH_SYM)); + const auto before = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n, LIQETH_SYM)); + BOOST_CHECK_EQUAL(before + gift / 4, wire_balance("alice"_n)); +} + +/// A deployed but unconfigured kicker gives a stable actionable diagnostic. +BOOST_AUTO_TEST_CASE(missing_config_diagnostic) { + sysio_liq_tester t; + t.create_accounts({kicker_tester::KICKER}); + abi_serializer ser; + t.deploy(kicker_tester::KICKER, contracts::kicker_wasm(), contracts::kicker_abi(), ser, true); + BOOST_CHECK(t.mentions(t.push(kicker_tester::KICKER, ser, "bob"_n, "kick"_n, + mvo()("sym", t.LIQSOL_SYM.to_symbol_code())), "kicker not configured")); +} + +/// The accepted residual: a one-block spike buys TWAP area, and a partial unwind to the inflated TWAP, the kick and +/// the rest of the unwind in one block pass the guard even at its tightest 1 bps setting, because same-block trades +/// carry no TWAP weight. The kick pays the spike-inflated gift in full under the daily cap, and the cap above it. +BOOST_AUTO_TEST_CASE(one_block_spike_cashed_in_one_block_pays_the_inflated_gift) { + constexpr int64_t pump = 10'000'000 * sysio_liq_tester::UNIT; + constexpr uint16_t tightest = 1; + constexpr unsigned max_rounds = 6; + /// Default ceiling, then one below the inflated gift (about 0.58 WIRE). + for (const uint64_t cap : {math::max_daily_gift, uint64_t{sysio_liq_tester::UNIT / 10}}) { + BOOST_TEST_CONTEXT("max_gift_per_day=" << cap) { + kicker_tester t; + BOOST_REQUIRE_EQUAL(t.success(), t.change(200, 1, cap)); + BOOST_REQUIRE_EQUAL(t.success(), t.configure(t.budget, 1, t.SYSIO_ACCOUNT, math::min_twap_window_sec, + tightest)); + BOOST_REQUIRE_EQUAL(t.success(), t.openext("bob"_n, "bob"_n, extended_symbol{t.WIRE_SYM, t.TOKEN_ACCOUNT})); + BOOST_REQUIRE_EQUAL(t.success(), t.openext("bob"_n, "bob"_n, extended_symbol{t.LIQSOL_SYM, t.LIQ_ACCOUNT})); + BOOST_REQUIRE_EQUAL(t.success(), t.transfer_wire(t.SYSIO_ACCOUNT, "bob"_n, pump)); + BOOST_REQUIRE_EQUAL(t.success(), t.transfer_wire("bob"_n, t.SWAP_ACCOUNT, pump)); + t.wait(math::min_twap_window_sec); + const auto honest = t.expected(); + BOOST_REQUIRE_GT(honest, 0); + const auto reserves = [&] { + const auto pair = t.swap_pool_row(t.POOL_SYM); + return std::pair{pair["pool2"]["quantity"].as().get_amount(), + pair["pool1"]["quantity"].as().get_amount()}; + }; + // One block at the pumped price. + const auto liq_before = reserves().second; + BOOST_REQUIRE_EQUAL(t.success(), t.exchange("bob"_n, t.POOL_SYM, + extended_asset{asset(pump, t.WIRE_SYM), t.TOKEN_ACCOUNT}, asset(1, t.LIQSOL_SYM))); + const auto liq_out = liq_before - reserves().second; + // Next block, no block produced in between: trade spot onto the inflated TWAP within 1 bps (the swap fee + // makes one constant-product step land slightly off, so step until it is in), kick, then sell the rest. + const auto trade = [&](int64_t amount, bool sell_liq) { + const auto in = sell_liq ? extended_asset{asset(amount, t.LIQSOL_SYM), t.LIQ_ACCOUNT} + : extended_asset{asset(amount, t.WIRE_SYM), t.TOKEN_ACCOUNT}; + t.push_action(t.SWAP_ACCOUNT, "exchange"_n, "bob"_n, mvo() + ("user", "bob"_n)("pair_token", t.POOL_SYM.to_symbol_code())("ext_asset_in", in) + ("min_expected", sell_liq ? asset(1, t.WIRE_SYM) : asset(1, t.LIQSOL_SYM))); + }; + const auto twap = static_cast(t.twap()); + const double target = double(t.twap()) / 18446744073709551616.0; // Q64.64 to WIRE per LIQ + BOOST_REQUIRE_GT(target, 100.0); + int64_t sold = 0; + unsigned rounds = 0; + for (; rounds < max_rounds; ++rounds) { + const auto [wire, liq] = reserves(); + if (!math::spot_deviates(opp::twap::price_fp(wire, liq), twap, tightest)) break; + const double product = double(wire) * double(liq); + if (double(wire) / double(liq) > target) { + const auto amount = std::max(1, int64_t(std::sqrt(product / target)) - liq); + trade(amount, true); + sold += amount; + } else { + trade(std::max(1, int64_t(std::sqrt(product * target)) - wire), false); + } + } + BOOST_REQUIRE_LT(rounds, max_rounds); + BOOST_REQUIRE(sold > 0 && sold < liq_out); + const auto inflated = t.expected(); // same-block trades leave the TWAP as it was + BOOST_REQUIRE_GT(inflated, 100 * honest); + if (cap != math::max_daily_gift) BOOST_REQUIRE_GT(inflated, cap); + t.push_action(t.KICKER, "kick"_n, "bob"_n, mvo()("sym", t.LIQSOL_SYM.to_symbol_code())); + trade(liq_out - sold, true); + t.produce_block(); + BOOST_CHECK(!t.decode_pool().deviation); + if (inflated <= cap) { + BOOST_CHECK_EQUAL(inflated, t.paid()); + } else { + BOOST_CHECK_EQUAL(cap, t.paid()); + BOOST_CHECK_EQUAL(inflated - cap, t.decode_pool().shortfall); + } + } + } +} + +/// The guard compares spot with the window TWAP at the configured share of the TWAP, inclusively. Spot stays at the +/// fixture's 1 WIRE/LIQ while the injected history prices the window at 2: |1 - 2| / 2 is exactly 5,000 bps. +BOOST_AUTO_TEST_CASE(spot_deviation_threshold_is_inclusive) { + for (const uint16_t deviation : {uint16_t{5'000}, uint16_t{4'999}}) { + BOOST_TEST_CONTEXT("max_spot_deviation_bps=" << deviation) { + kicker_tester t; + BOOST_REQUIRE_EQUAL(t.success(), t.configure(t.budget, 1, t.SYSIO_ACCOUNT, math::default_twap_window_sec, deviation)); + t.wait(math::day_sec); + t.price_history(t.LIQSOL_SYM, t.POOL_SYM, 2 * t.UNIT, t.UNIT); + const auto gift = t.expected(); + BOOST_REQUIRE_GT(gift, 0); + const auto observation = t.decode_pool(); + BOOST_REQUIRE_EQUAL(t.success(), t.kick()); + const auto after = t.decode_pool(); + if (deviation == 5'000) { + BOOST_CHECK_EQUAL(gift, t.paid()); + BOOST_CHECK(!after.deviation); + } else { + BOOST_CHECK_EQUAL(0, t.paid()); + BOOST_CHECK(after.deviation); + BOOST_CHECK_EQUAL(gift, after.shortfall); + BOOST_CHECK(after.observed == t.control->head_block_time()); + // The clock and the observation are held. + BOOST_CHECK(after.clock == observation.clock); + BOOST_CHECK(after.price_time == observation.price_time); + BOOST_CHECK(after.snapshot_lo == observation.snapshot_lo && after.snapshot_hi == observation.snapshot_hi); + } + } + } +} + +/// Governance must keep the guard on: 1 to 10,000 bps; a rejected change leaves the configuration as it was. +BOOST_FIXTURE_TEST_CASE(spot_deviation_config_validation, kicker_tester) { + BOOST_CHECK_EQUAL(math::default_max_spot_deviation_bps, + configuration()[kicker::field::max_spot_deviation_bps].as_uint64()); + BOOST_CHECK_EQUAL(5'000, math::default_max_spot_deviation_bps); + for (const uint16_t accepted : {uint16_t{1}, uint16_t{10'000}}) { + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1, SYSIO_ACCOUNT, math::default_twap_window_sec, accepted)); + BOOST_CHECK_EQUAL(accepted, configuration()[kicker::field::max_spot_deviation_bps].as_uint64()); + } + for (const uint16_t refused : {uint16_t{0}, uint16_t{10'001}}) { + BOOST_CHECK(mentions(configure(budget, 1, SYSIO_ACCOUNT, math::default_twap_window_sec, refused), + invalid_spot_deviation_message)); + BOOST_CHECK_EQUAL(10'000, configuration()[kicker::field::max_spot_deviation_bps].as_uint64()); + } +} + +/// Review of #675: a pump held across a payment checkpoint. With a 60 s window, 100,000 LIQ at 200 bps, the +/// attacker pumps in the block before `kick`, holds for one more 500 ms block, then unwinds: each half-second +/// lands in a different observation, one each side of a UTC midnight. Run against the contract before the guard, +/// this test saw both kicks pay the capped 50 WIRE (100 WIRE in all). The pool's daily cap is 50 WIRE here: the fixture's treasury cannot move this pool's spot to the +/// review's 31.5 M WIRE/LIQ, and the construction scales with the cap. The spot-vs-TWAP guard refuses both: spot is +/// far from the window TWAP at the first kick, and the unrestarted observation still carries the pump at the +/// second. +BOOST_FIXTURE_TEST_CASE(pump_straddling_a_checkpoint_funds_no_payout, kicker_tester) { + constexpr uint32_t window = math::min_twap_window_sec; + constexpr int64_t pump = 500'000'000 * UNIT; + constexpr uint64_t cap = 50 * UNIT; + const auto block = fc::microseconds(config::block_interval_us); + const auto advance_to = [&](time_point target) { + BOOST_REQUIRE(control->pending_block_time() <= target); + if (control->pending_block_time() < target) produce_block(target - control->pending_block_time()); + BOOST_REQUIRE(control->pending_block_time() == target); + }; + BOOST_REQUIRE_EQUAL(success(), configure(budget, 1, SYSIO_ACCOUNT, window)); + BOOST_REQUIRE_EQUAL(100'000 * UNIT, supply()); + BOOST_REQUIRE_EQUAL(success(), openext("bob"_n, "bob"_n, extended_symbol{WIRE_SYM, TOKEN_ACCOUNT})); + BOOST_REQUIRE_EQUAL(success(), openext("bob"_n, "bob"_n, extended_symbol{LIQSOL_SYM, LIQ_ACCOUNT})); + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, "bob"_n, pump)); + BOOST_REQUIRE_EQUAL(success(), transfer_wire("bob"_n, SWAP_ACCOUNT, pump)); + + // The first kick lands ten seconds before a UTC midnight, the observation starting one window earlier. + constexpr int64_t day_us = int64_t(math::day_sec) * math::micros_per_sec; + const int64_t pending_us = control->pending_block_time().time_since_epoch().count(); + int64_t midnight_us = (pending_us / day_us + 1) * day_us; + if (midnight_us - pending_us < 120 * int64_t(math::micros_per_sec)) midnight_us += day_us; + const time_point first(fc::microseconds(midnight_us - 10 * int64_t(math::micros_per_sec))); + const time_point start = first - fc::seconds(window); + // Each push produces its block, so the next action lands one block later. + advance_to(start - block); + BOOST_REQUIRE_EQUAL(success(), push(KICKER, kicker_ser, SYSIO_ACCOUNT, "rmpool"_n, + mvo()("sym", LIQSOL_SYM.to_symbol_code()))); + BOOST_REQUIRE_EQUAL(success(), add(LIQSOL_SYM, math::default_rate_bps, math::default_min_gift, cap)); + BOOST_REQUIRE(decode_pool().price_time == start); + + // Pump in the block before the kick. + advance_to(first - block); + const auto liq_before = swap_pool_row(POOL_SYM)["pool1"]["quantity"].as().get_amount(); + BOOST_REQUIRE_EQUAL(success(), exchange("bob"_n, POOL_SYM, extended_asset{asset(pump, WIRE_SYM), TOKEN_ACCOUNT}, + asset(1, LIQSOL_SYM))); + const auto liq_out = liq_before - swap_pool_row(POOL_SYM)["pool1"]["quantity"].as().get_amount(); + advance_to(first); + BOOST_REQUIRE_EQUAL(success(), kick()); + const auto first_paid = paid(); + // Hold one more block, then unwind. + advance_to(first + block); + BOOST_REQUIRE_EQUAL(success(), exchange("bob"_n, POOL_SYM, extended_asset{asset(liq_out, LIQSOL_SYM), LIQ_ACCOUNT}, + asset(1, WIRE_SYM))); + advance_to(first + fc::seconds(window)); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_TEST_MESSAGE("straddle: first kick paid " << first_paid << ", both kicks paid " << paid()); + BOOST_CHECK_EQUAL(0, first_paid); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK(pool()["spot_deviation"].as_bool()); + BOOST_CHECK(decode_pool().clock == start); + BOOST_CHECK(decode_pool().price_time == start); +} + +/** Review of #675 (PRRT_kwDOMnX85s6vxcVQ): LIQ minted immediately before a mature kick must not be paid for the + * interval it did not hold. 100,000 LIQ accrue for exactly one day at price 1; 18,162,500 LIQ are minted in the + * kick's block. A gift sized on the current supply would be 182.625 times the honest one and pay the 1,000 WIRE + * daily cap; sized on the checkpointed supply it pays the honest 5.475701574 WIRE, and the full payment then + * checkpoints the grown supply. + */ +BOOST_FIXTURE_TEST_CASE(mint_immediately_before_mature_kick, kicker_tester) { + BOOST_REQUIRE_EQUAL(review_supply, supply()); + BOOST_REQUIRE_EQUAL(review_honest_gift, + reference(review_supply, math::default_rate_bps, day_us, unit_price, unit_price)); + // Each pushed action seals its own block, so the mint lands in the block just before the kick's. + advance_elapsed(*this, day_us - config::block_interval_us); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, review_mint)); + // Read through the deployed ABI, so the payment assertion below also runs against an older row layout. + BOOST_REQUIRE(control->pending_block_time() == pool()["last_kick"].as() + fc::microseconds(day_us)); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(review_honest_gift, paid()); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK_EQUAL(supply(), pool()["supply_snapshot"].as_int64()); + // The correctly sized gift is still shared across the current supply, the new LIQ included. + const auto distribution = yield_reference::distribute(paid(), supply(), 0); + BOOST_CHECK_EQUAL(static_cast(distribution.carry), index_row()["carry"].as_uint64()); +} + +/// A partial payment keeps the supply it priced: LIQ minted before it is not sized into the open remainder, and only +/// the full payment that settles the remainder checkpoints the grown supply. +BOOST_FIXTURE_TEST_CASE(partial_payment_keeps_priced_supply, kicker_tester) { + constexpr uint64_t partial_budget = 2 * UNIT; + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, review_mint)); + BOOST_REQUIRE_EQUAL(success(), configure(partial_budget)); + advance_elapsed(*this, day_us); + BOOST_REQUIRE_EQUAL(review_honest_gift, expected()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(partial_budget, paid()); + BOOST_CHECK_EQUAL(review_honest_gift - partial_budget, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK_EQUAL(review_supply, pool()["supply_snapshot"].as_int64()); + BOOST_REQUIRE_EQUAL(success(), configure(budget)); + wait(math::day_sec); + const auto remainder = reference(review_supply, math::default_rate_bps, std::min(elapsed(), math::max_catchup_us), + unit_price, unit_price); + BOOST_REQUIRE_EQUAL(remainder, expected()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(partial_budget + remainder, paid()); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); + BOOST_CHECK_EQUAL(supply(), pool()["supply_snapshot"].as_int64()); +} + +/// A zero snapshot with nonzero supply only checkpoints: LIQ minted after the supply emptied starts accruing at the +/// next kick, which pays nothing and keeps the price observation, and the kick after it pays that supply's interval. +BOOST_FIXTURE_TEST_CASE(zero_snapshot_checkpoints_then_pays, kicker_tester) { + burn_to(*this, 0); + wait(math::default_min_interval_sec); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_REQUIRE_EQUAL(0, pool()["supply_snapshot"].as_int64()); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, holder)); + wait(math::day_sec); + const auto observed = decode_pool().price_time; + const auto now = control->pending_block_time(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK(kick_clock() == now); + BOOST_CHECK_EQUAL(int64_t(holder), pool()["supply_snapshot"].as_int64()); + BOOST_CHECK(decode_pool().price_time == observed); + advance_elapsed(*this, day_us); + const auto gift = reference(holder, math::default_rate_bps, day_us, unit_price, unit_price); + BOOST_REQUIRE_EQUAL(gift, expected()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); + BOOST_CHECK_EQUAL(supply(), pool()["supply_snapshot"].as_int64()); +} + +/// A snapshot too small to reach min_gift cannot hold a grown supply unpaid forever: once the 72-hour catch-up is +/// saturated, a below-minimum kick forfeits the interval and checkpoints the current supply, which the next mature +/// kick pays for. +BOOST_FIXTURE_TEST_CASE(saturated_tiny_snapshot_resets_to_grown_supply, kicker_tester) { + burn_to(*this, tiny_supply); + reopen(); + BOOST_REQUIRE_EQUAL(tiny_supply, pool()["supply_snapshot"].as_int64()); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, holder)); + // Below the catch-up bound the skip writes nothing. + wait(math::day_sec); + const auto held = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == held); + // Saturated: forfeit and checkpoint at the current supply, keeping the observation. + wait(2 * math::day_sec); + BOOST_REQUIRE_GE(elapsed(), math::max_catchup_us); + const auto observed = decode_pool().price_time; + const auto now = control->pending_block_time(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK(kick_clock() == now); + BOOST_CHECK_EQUAL(supply(), pool()["supply_snapshot"].as_int64()); + BOOST_CHECK(decode_pool().price_time == observed); + BOOST_CHECK_EQUAL(0, pool()["shortfall_amount"].as_uint64()); + advance_elapsed(*this, day_us); + const auto gift = reference(uint64_t(supply()), math::default_rate_bps, day_us, unit_price, unit_price); + BOOST_REQUIRE_EQUAL(gift, expected()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); +} + +/// Without growth there is nothing to reset to: a saturated below-minimum snapshot that equals the supply writes +/// nothing, however long the interval. +BOOST_FIXTURE_TEST_CASE(saturated_tiny_snapshot_without_growth_holds, kicker_tester) { + burn_to(*this, tiny_supply); + reopen(); + wait(3 * math::day_sec); + BOOST_REQUIRE_GE(elapsed(), math::max_catchup_us); + const auto held = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == held); + BOOST_CHECK_EQUAL(0, paid()); +} + +/// A price dip is not a small snapshot: with the 72 hours saturated and the supply grown past the snapshot, a window +/// TWAP that sinks the gift of the current supply below min_gift too leaves the kick holding (writing nothing), and +/// the interval still pays at the recovered price. +BOOST_FIXTURE_TEST_CASE(price_dip_below_minimum_does_not_forfeit, kicker_tester) { + burn_to(*this, holder); + reopen(); + const auto snapshot = pool()["supply_snapshot"].as_int64(); + BOOST_REQUIRE_EQUAL(int64_t(holder), snapshot); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, holder)); + BOOST_REQUIRE_GT(supply(), snapshot); + wait(3 * math::day_sec); + BOOST_REQUIRE_GE(elapsed(), math::max_catchup_us); + // A tenth of the price: the snapshot's gift AND the grown supply's gift fall under the one-WIRE minimum, while + // the snapshot's gift at the recovered price clears it. + price_history(LIQSOL_SYM, POOL_SYM, UNIT, 10 * UNIT, 3 * math::day_sec); + const auto min_gift = pool()["min_gift"].as_uint64(); + BOOST_REQUIRE_LT(reference(uint64_t(snapshot), 200, math::max_catchup_us, 1, 10), min_gift); + BOOST_REQUIRE_LT(reference(supply(), 200, math::max_catchup_us, 1, 10), min_gift); + BOOST_REQUIRE_GE(reference(uint64_t(snapshot), 200, math::max_catchup_us, 1, 1), min_gift); + const auto held = bytes(); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK(bytes() == held); + BOOST_CHECK_EQUAL(0, paid()); + BOOST_CHECK_EQUAL(snapshot, pool()["supply_snapshot"].as_int64()); + // The price recovers: the same open interval pays on the snapshot's supply. + price_history(LIQSOL_SYM, POOL_SYM, UNIT, UNIT, 3 * math::day_sec); + const auto gift = reference(uint64_t(snapshot), 200, math::max_catchup_us, 1, 1); + BOOST_REQUIRE_EQUAL(gift, expected()); + BOOST_REQUIRE_EQUAL(success(), kick()); + BOOST_CHECK_EQUAL(gift, paid()); + BOOST_CHECK_EQUAL(supply(), pool()["supply_snapshot"].as_int64()); +} + +namespace { +/// What a partial-unwind straddle measured: both kicks' prices and payments, and the honest value of the second. +struct straddle_outcome { + wide twap1, twap2; + uint64_t gift1 = 0, paid1 = 0, gift2 = 0, honest2 = 0, paid2 = 0; + /// The contract's recorded shortfall after each kick: its own gift less what it paid. + uint64_t shortfall1 = 0, shortfall2 = 0; + int64_t advance1 = 0; + uint64_t elapsed2 = 0; + /// The second kick's guard verdict, and the pool row's clock and observation either side of it. + bool deviation2 = false; + kicker_tester::pool_state before2, after2; +}; + +/** A straddle whose first kick follows a PARTIAL unwind, with the swap history injected (the fixture's WIRE supply + * cannot reach the peak; pricing, guard and payment are the contract's). Default 86,400 s window, 5,000 bps guard + * and 1,000 WIRE cap; `supply` LIQ at 200 bps. The observation holds spot 1 for a day except its last block, where a + * one-block peak of 2,985,984,001 lifts the TWAP to 17,281. In that block the attacker unwinds to spot 17,281 and + * kicks: the guard sees no deviation and the payment stores the checkpoint at that spot. Spot stays 17,281 for + * `hold_blocks`, then unwinds fully to 1, and the second kick, one window after the first and on the next UTC day, + * prices at the seeded TWAP 1 + 17,280 * hold / window. A nonzero `mint_between` mints that much LIQ right after the + * first kick; the honest value of the second stays that of `supply_target`, the supply that held the interval. + */ +straddle_outcome run_partial_unwind_straddle(int64_t supply_target, unsigned hold_blocks, uint64_t mint_between = 0) { + constexpr uint64_t peak = 2'985'984'001; + constexpr uint64_t partial = 17'281; + kicker_tester t; + const auto& unit = t.UNIT; + const int64_t liq_reserve = 75'000 * unit; + const int64_t day_us = int64_t(math::day_sec) * math::micros_per_sec; + const auto block = fc::microseconds(config::block_interval_us); + const auto advance_to = [&](time_point target) { + BOOST_REQUIRE(t.control->pending_block_time() <= target); + if (t.control->pending_block_time() < target) t.produce_block(target - t.control->pending_block_time()); + BOOST_REQUIRE(t.control->pending_block_time() == target); + }; + const auto pair_code = t.POOL_SYM.to_symbol_code().value; + const auto set_spot = [&](uint64_t wire_per_liq) { + auto pair = mvo(t.swap_pool_row(t.POOL_SYM).get_object()); + pair("pool1", extended_asset(asset(liq_reserve, t.LIQSOL_SYM), t.LIQ_ACCOUNT)) + ("pool2", extended_asset(asset(liq_reserve * int64_t(wire_per_liq), t.WIRE_SYM), t.TOKEN_ACCOUNT)); + t.write_row(t.SWAP_ACCOUNT, t.swap_abi_ser, "stat"_n, pair_code, "currency_stats", pair); + }; + const wide one_q = wide(1) << 64; + const auto set_accumulator = [&](const wide& cumulative, time_point at) { + const wide mask = (wide(1) << 128) - 1; + const auto zero = mvo()("lo", fc::uint128_t{0})("hi", fc::uint128_t{0}); + t.write_row(t.SWAP_ACCOUNT, t.swap_abi_ser, "priceaccum"_n, pair_code, "price_accumulator", mvo() + ("price1", mvo()("lo", static_cast(cumulative & mask)) + ("hi", static_cast(cumulative >> 128))) + ("price2", zero)("last_update", at)); + }; + + BOOST_REQUIRE_EQUAL(t.success(), t.mint("alice"_n, uint64_t(supply_target - t.supply()))); + BOOST_REQUIRE_EQUAL(supply_target, t.supply()); + // The first kick lands ten seconds before a UTC midnight, the pool's clock and observation one day earlier. + const int64_t pending_us = t.control->pending_block_time().time_since_epoch().count(); + const int64_t midnight_us = (pending_us / day_us + 2) * day_us; + const time_point first(fc::microseconds(midnight_us - 10 * int64_t(math::micros_per_sec))); + const time_point start = first - fc::microseconds(day_us); + advance_to(start - block); + BOOST_REQUIRE_EQUAL(t.success(), t.push(t.KICKER, t.kicker_ser, t.SYSIO_ACCOUNT, "rmpool"_n, + mvo()("sym", t.LIQSOL_SYM.to_symbol_code()))); + BOOST_REQUIRE_EQUAL(t.success(), t.add()); + BOOST_REQUIRE(t.decode_pool().clock == start && t.decode_pool().price_time == start); + + // Kick 1, in the block that ends the peak: the day at spot 1 bar the last block at the peak, observed from zero. + advance_to(first); + const wide area1 = one_q * (day_us - block.count()) + one_q * peak * block.count(); + set_accumulator(area1, first); + auto row = t.get_row_by_id(t.KICKER, t.KICKER, "kickpools"_n, t.LIQSOL_SYM.to_symbol_code().value); + const auto observation = fc::raw::pack(std::make_tuple(fc::uint128_t{0}, fc::uint128_t{0}, start)); + std::copy(observation.begin(), observation.end(), row.end() - observation.size()); + t.write_raw_row(t.KICKER, "kickpools"_n, t.LIQSOL_SYM.to_symbol_code().value, row); + set_spot(partial); // the partial unwind, in the same block + straddle_outcome out; + out.twap1 = t.twap(); + out.gift1 = t.expected(); + BOOST_REQUIRE_EQUAL(t.success(), t.kick()); + out.paid1 = t.paid(); + out.shortfall1 = t.decode_pool().shortfall; + out.advance1 = (t.decode_pool().clock - start).count(); + if (mint_between) BOOST_REQUIRE_EQUAL(t.success(), t.mint("alice"_n, mint_between)); + + // Hold spot 17,281 for `hold_blocks`, then unwind fully to 1. + const time_point unwind = first + fc::microseconds(block.count() * hold_blocks); + advance_to(unwind); + set_accumulator(area1 + one_q * partial * (block.count() * hold_blocks), unwind); + set_spot(1); + t.produce_block(); + + // Kick 2, one window after the checkpoint and on the next UTC day. + advance_to(first + fc::microseconds(day_us)); + out.twap2 = t.twap(); + out.elapsed2 = std::min(t.elapsed(), math::max_catchup_us); + out.gift2 = t.expected(); + out.before2 = t.decode_pool(); + out.honest2 = reference(uint64_t(supply_target), 200, out.elapsed2, 1, 1); + BOOST_REQUIRE_EQUAL(t.success(), t.kick()); + out.paid2 = t.paid() - out.paid1; + out.after2 = t.decode_pool(); + out.shortfall2 = out.after2.shortfall; + out.deviation2 = out.after2.deviation; + const auto ratio = [&](const wide& q) { return double(q) / double(one_q); }; + BOOST_TEST_MESSAGE("partial-unwind straddle supply=" << supply_target << " hold_blocks=" << hold_blocks + << ": twap1=" << ratio(out.twap1) << " gift1=" << out.gift1 << " paid1=" << out.paid1 + << " clock advance (us)=" << out.advance1 << " twap2=" << ratio(out.twap2) << " elapsed2 (us)=" + << out.elapsed2 << " gift2=" << out.gift2 << " honest2=" << out.honest2 << " paid2=" << out.paid2 + << " excess2=" << int64_t(out.paid2) - int64_t(out.honest2)); + return out; +} +} // namespace + +/** Review of #675 (third pass, PRRT_kwDOMnX85s6vo-1F): one price excursion can fund one capped payout plus, on the + * next payout, at most `max_spot_deviation_bps * cap / 10000` of excess over its honest value (500 WIRE at the + * default). The guard bounds the next price at spot / (1 - d), so the excess is min(cap, honest / (1 - d)) - honest, + * largest when the honest gift is cap * (1 - d). + * + * Measured, 8,675,000 LIQ with a one-block hold (the review's construction): TWAP 17,281 then 1.1; the first kick pays + * the 1,000 WIRE cap and advances the clock 10.525327 s; the second's gift is 1,044.973991726 WIRE against an honest + * 949.976356115 WIRE, and it pays the cap: 50.023643885 WIRE of excess. + * + * Measured at the bound's worst point, 4,566,000 LIQ held ten blocks (5 s): TWAP 17,281 then exactly 2, the guard's + * inclusive edge at spot 1. The first kick pays the cap and advances the clock 19.9972 s; the second gift is + * 999.966401480 WIRE, twice an honest 499.983200740 WIRE, just under the cap, so it pays in full: 499.983200740 WIRE + * of excess against the 500 WIRE bound. + * + * Past the edge, 2,283,000 LIQ held thirty blocks (15 s): TWAP 17,281 then 4, 7,500 bps from spot 1. The guard refuses + * kick 2, so the case fails if the guard is removed or loosened past 7,500 bps. + */ +BOOST_AUTO_TEST_CASE(partial_unwind_straddle_overpays_at_most_deviation_times_cap) { + const wide one_q = wide(1) << 64; + const uint64_t cap = math::max_daily_gift; + const uint64_t bound = uint64_t(math::default_max_spot_deviation_bps) * cap / math::bps_scale; + BOOST_REQUIRE_EQUAL(500 * sysio_liq_tester::UNIT, bound); + struct scenario { int64_t supply; unsigned hold_blocks; }; + for (const auto [supply, hold_blocks] : {scenario{8'675'000 * sysio_liq_tester::UNIT, 1}, + scenario{4'566'000 * sysio_liq_tester::UNIT, 10}, + scenario{2'283'000 * sysio_liq_tester::UNIT, 30}}) { + BOOST_TEST_CONTEXT("supply=" << supply << " hold_blocks=" << hold_blocks) { + const auto out = run_partial_unwind_straddle(supply, hold_blocks); + // The contract priced kick 1 at TWAP 17,281: its recorded shortfall is the oracle's gift less the cap. + BOOST_CHECK(out.twap1 == one_q * 17'281); + BOOST_CHECK_EQUAL(cap, out.paid1); + BOOST_CHECK_EQUAL(out.gift1 - cap, out.shortfall1); + if (hold_blocks == 30) { + // Past the guard's inclusive edge: TWAP 4 against spot 1 is 7,500 bps. Kick 2 pays nothing, records the + // trip with its gift as the shortfall, and holds the clock and the observation. + BOOST_CHECK(out.twap2 == one_q * 4); + BOOST_CHECK(out.deviation2); + BOOST_CHECK_EQUAL(0, out.paid2); + BOOST_CHECK_EQUAL(out.gift2, out.shortfall2); + BOOST_CHECK(out.after2.clock == out.before2.clock); + BOOST_CHECK(out.after2.price_time == out.before2.price_time); + BOOST_CHECK(out.after2.snapshot_lo == out.before2.snapshot_lo && + out.after2.snapshot_hi == out.before2.snapshot_hi); + continue; + } + BOOST_CHECK(!out.deviation2); + BOOST_CHECK_EQUAL(std::min(out.gift2, cap), out.paid2); + BOOST_CHECK_EQUAL(out.gift2 - out.paid2, out.shortfall2); // the contract's own kick-2 pricing + BOOST_REQUIRE_LE(out.honest2, out.paid2); + BOOST_CHECK_LE(out.paid2 - out.honest2, bound); + if (hold_blocks == 1) { + BOOST_CHECK(out.twap2 == one_q * 11 / 10); + BOOST_CHECK_EQUAL(cap, out.paid2); // the second payout reaches the cap + } else { + BOOST_CHECK(out.twap2 == one_q * 2); + BOOST_CHECK_GT(out.paid2 - out.honest2, bound * 99 / 100); // the bound is nearly reached here + } + } + } +} + +/** The bound holds with LIQ minted inside the straddle. At the bound's worst point (4,566,000 LIQ held ten blocks, + * kick 2 at TWAP exactly 2), minting ten times the supply after kick 1 would, sized on the current supply, lift kick 2 + * to the 1,000 WIRE cap: about 500.017 WIRE of excess, past the 500 WIRE bound. Sized on the supply kick 1 priced, + * kick 2 pays its 999.966401480 WIRE gift and the excess stays inside the bound. + */ +BOOST_AUTO_TEST_CASE(partial_unwind_straddle_with_mint_stays_within_bound) { + constexpr int64_t straddle_supply = 4'566'000 * sysio_liq_tester::UNIT; + constexpr unsigned straddle_hold_blocks = 10; + constexpr uint64_t straddle_mint = 10 * uint64_t(straddle_supply); + const uint64_t cap = math::max_daily_gift; + const uint64_t bound = uint64_t(math::default_max_spot_deviation_bps) * cap / math::bps_scale; + const auto out = run_partial_unwind_straddle(straddle_supply, straddle_hold_blocks, straddle_mint); + BOOST_CHECK_EQUAL(cap, out.paid1); + BOOST_CHECK(!out.deviation2); + BOOST_CHECK(out.twap2 == (wide(2) << 64)); + BOOST_CHECK_EQUAL(out.gift2, out.paid2); + BOOST_CHECK_LT(out.paid2, cap); + BOOST_REQUIRE_LE(out.honest2, out.paid2); + BOOST_CHECK_LE(out.paid2 - out.honest2, bound); + BOOST_CHECK_EQUAL(straddle_supply + int64_t(straddle_mint), out.after2.supply_snapshot); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.liq_tests.cpp b/contracts/tests/sysio.liq_tests.cpp index 410ab73d9e..f9d41f212b 100644 --- a/contracts/tests/sysio.liq_tests.cpp +++ b/contracts/tests/sysio.liq_tests.cpp @@ -1,276 +1,4 @@ -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "contracts.hpp" -#include "contract_test_support.hpp" -#include "shadow_yield_reference.hpp" - -#include - -using namespace sysio::testing; -using namespace sysio; -using namespace sysio::chain; -using namespace sysio::opp::types; -using namespace fc; - -using mvo = fc::mutable_variant_object; -using sysio_system::test_support::codename_mvo; - -/// sysio.liq end to end on the depot: the registries it validates against, the -/// swap it feeds, and sysio.synd as the signer of every mint and burn (an account -/// only; sysio.msgch is an account too, a signer that is not sysio.synd). The -/// treasury supply of WIRE sits on `sysio`, as the bootstrap leaves it. -class sysio_liq_tester : public tester { -public: - static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; - static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; - static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; - static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; - static constexpr auto SWAP_ACCOUNT = "sysio.swap"_n; - static constexpr auto SYND_ACCOUNT = "sysio.synd"_n; - static constexpr auto SYSIO_ACCOUNT = "sysio"_n; - - static inline const symbol WIRE_SYM = symbol::from_string("9,WIRE"); - static inline const symbol LIQSOL_SYM = symbol::from_string("9,LIQSOL"); - static inline const symbol LIQETH_SYM = symbol::from_string("9,LIQETH"); - static inline const symbol POOL_SYM = symbol::from_string("9,LIQPOOL"); - static constexpr int64_t UNIT = 1'000'000'000; // one whole token of either symbol, in subunits - - static constexpr std::string_view SOLANA = "SOLANA"; - static constexpr std::string_view ETH = "ETH"; - static constexpr std::string_view LIQSOL = "LIQSOL"; - static constexpr std::string_view LIQETH = "LIQETH"; - - /// Registers the two outposts, their liq tokens and the LIQSOL shadow every case starts from. - sysio_liq_tester() { - produce_blocks(2); - // sysio.synd is the ledger's only minter; an account is enough to sign as it here. - create_accounts({ LIQ_ACCOUNT, MSGCH_ACCOUNT, TOKEN_ACCOUNT, TOKENS_ACCOUNT, CHAINS_ACCOUNT, SWAP_ACCOUNT, - SYND_ACCOUNT, "alice"_n, "bob"_n, "carol"_n, "dave"_n }); - produce_blocks(2); - - deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser, true); - deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser, true); - deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi_ser, true); - deploy(SWAP_ACCOUNT, contracts::swap_wasm(), contracts::swap_abi(), swap_abi_ser, false); - deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser, true); - - // WIRE: the treasury supply on sysio, and working balances for the funders. - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, TOKEN_ACCOUNT, "create"_n, mvo() - ("issuer", SYSIO_ACCOUNT)("maximum_supply", asset(1'000'000'000 * UNIT, WIRE_SYM)))); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, SYSIO_ACCOUNT, "issue"_n, mvo() - ("to", SYSIO_ACCOUNT)("quantity", asset(1'000'000'000 * UNIT, WIRE_SYM))("memo", ""))); - for (auto funder : { "alice"_n, "bob"_n, "carol"_n }) - BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, funder, 1'000'000 * UNIT)); - - // The swap: governance is sysio, the system token WIRE. - BOOST_REQUIRE_EQUAL(success(), push(SWAP_ACCOUNT, swap_abi_ser, SWAP_ACCOUNT, "setconfig"_n, mvo() - ("fee_authority", SYSIO_ACCOUNT)("system_token", extended_symbol{ WIRE_SYM, TOKEN_ACCOUNT }))); - - // Two outposts, each with an active liq token, plus a plain SPL token. - BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, SOLANA, 2)); - BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, ETH, 1)); - BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQSOL, ChainKind::CHAIN_KIND_SVM, SOLANA)); - BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQETH, ChainKind::CHAIN_KIND_EVM, ETH)); - BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_SPL, "USDCSOL", ChainKind::CHAIN_KIND_SVM, SOLANA)); - - BOOST_REQUIRE_EQUAL(success(), create(LIQSOL_SYM, SOLANA, LIQSOL)); - } - - // --- deployment and pushing --- - - void deploy(name account, const std::vector& wasm, const std::vector& abi, - abi_serializer& ser, bool privileged) { - set_code(account, wasm); - set_abi(account, abi.data()); - if (privileged) set_privileged(account); - produce_blocks(); - sysio_system::test_support::load_account_abi(*this, account, ser); - } - - action_result push(name code, abi_serializer& ser, name signer, name action_name, const variant_object& data, - std::vector authorization = {}) { - return sysio_system::test_support::push_contract_action_and_produce_block(*this, code, ser, signer, - action_name, data, - std::move(authorization)); - } - action_result push_liq(name signer, name action_name, const variant_object& data) { - return push(LIQ_ACCOUNT, liq_abi_ser, signer, action_name, data); - } - static bool mentions(const action_result& r, std::string_view text) { - return r.find(text) != std::string::npos; - } - - // --- registries --- - - // Registrations inside the epoch-0 bootstrap window land ACTIVE, as the launch bootstrap's do. - action_result regchain(ChainKind kind, std::string_view code, uint32_t external_chain_id) { - return push(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", kind)("code", codename_mvo(code))("external_chain_id", external_chain_id) - ("name", std::string("outpost"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo())); - } - /// Register a token and bind it to its chain, as the bootstrap does from a `TokenSpec`. - action_result regtoken(TokenKind kind, std::string_view code, uint32_t precision, ChainKind chain_kind, - std::string_view chain_code, const std::vector& address) { - auto r = push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regtoken"_n, mvo() - ("kind", kind)("code", codename_mvo(code))("symbol_name", std::string(code))("description", std::string{}) - ("precision", precision)("address", mvo()("kind", chain_kind)("address", address))); - if (r != success()) return r; - return push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regctok"_n, mvo() - ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(code))("contract_addr", address)("is_native", false)); - } - /// A 9-decimal token at a placeholder address of the chain family's width. - action_result regtoken(TokenKind kind, std::string_view code, ChainKind chain_kind, std::string_view chain_code) { - return regtoken(kind, code, 9, chain_kind, chain_code, - std::vector(chain_kind == ChainKind::CHAIN_KIND_SVM ? 32 : 20, char(0x5a))); - } - - // --- sysio.liq actions --- - - action_result create(symbol sym, std::string_view chain_code, std::string_view token_code, name signer = LIQ_ACCOUNT) { - return push_liq(signer, "create"_n, mvo()("sym", sym)("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))); - } - /// Mint `amount` of the shadow of `token_code` (LIQSOL by default) to `account`, signed as - /// sysio.synd, the only minter. - action_result mint(name account, uint64_t amount, std::string_view token_code = LIQSOL, - name signer = SYND_ACCOUNT) { - return push_liq(signer, "mint"_n, mvo()("to", account)("token_code", codename_mvo(token_code))("amount", amount)); - } - /// LIQ_YIELD released by sysio.synd: `amount` into the pending balance of `token_code`'s shadow. - action_result mintyield(std::string_view chain_code, std::string_view token_code, uint64_t amount, - name signer = SYND_ACCOUNT) { - return push_liq(signer, "mintyield"_n, mvo()("chain_code", codename_mvo(chain_code)) - ("token_code", codename_mvo(token_code))("amount", amount)); - } - action_result queueyield(symbol sym, name signer = "alice"_n) { - return push_liq(signer, "queueyield"_n, mvo()("sym", sym.to_symbol_code())); - } - action_result transfer_shadow(name from, name to, int64_t amount, const std::string& memo = "") { - return push_liq(from, "transfer"_n, mvo()("from", from)("to", to)("quantity", asset(amount, LIQSOL_SYM))("memo", memo)); - } - action_result open(name owner, symbol sym, name ram_payer) { - return push_liq(ram_payer, "open"_n, mvo()("owner", owner)("symbol", sym)("ram_payer", ram_payer)); - } - action_result close(name owner, symbol sym) { - return push_liq(owner, "close"_n, mvo()("owner", owner)("symbol", sym)); - } - action_result claim(name holder, symbol sym = LIQSOL_SYM) { - return push_liq(holder, "claim"_n, mvo()("holder", holder)("sym", sym.to_symbol_code())); - } - action_result addyield(name from, int64_t wire_amount, symbol target = LIQSOL_SYM) { - return push_liq(from, "addyield"_n, mvo()("from", from)("quantity", asset(wire_amount, WIRE_SYM)) - ("target", target.to_symbol_code())); - } - action_result recredit(name holder, int64_t amount, name signer = LIQ_ACCOUNT) { - return push_liq(signer, "recredit"_n, mvo()("holder", holder)("quantity", asset(amount, LIQSOL_SYM))); - } - action_result regliqpool(std::string_view chain_code, std::string_view token_code, symbol pair_symbol, - uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee = 30, - int64_t locked_shares = 0, uint32_t horizon_sec = 86400, uint32_t depth_cap_bps = 300, - int64_t clip_floor = 1000, name signer = LIQ_ACCOUNT) { - return push_liq(signer, "regliqpool"_n, mvo()("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) - ("pair_symbol", pair_symbol)("initial_chain_amount", initial_chain_amount) - ("initial_wire_amount", initial_wire_amount)("fee", fee)("locked_shares", locked_shares) - ("conversion_horizon_sec", horizon_sec)("depth_cap_bps", depth_cap_bps)("clip_floor", clip_floor)); - } - // --- other contracts --- - - action_result transfer_wire(name from, name to, int64_t amount) { - return push(TOKEN_ACCOUNT, token_abi_ser, from, "transfer"_n, mvo() - ("from", from)("to", to)("quantity", asset(amount, WIRE_SYM))("memo", "")); - } - action_result tickyield(symbol pair = POOL_SYM, name signer = "alice"_n) { - return push(SWAP_ACCOUNT, swap_abi_ser, signer, "tickyield"_n, mvo()("pair_token", pair.to_symbol_code())); - } - // The swap is unprivileged and bills a user's rows to the user, so these carry the - // user's `sysio.payer` beside `active`, as the swap suite's own pushes do. - /// A user's deposit row on the swap for one token, RAM billed to `payer`. - action_result openext(name user, name payer, const extended_symbol& ext_symbol) { - return push(SWAP_ACCOUNT, swap_abi_ser, payer, "openext"_n, mvo() - ("user", user)("payer", payer)("ext_symbol", ext_symbol), - sysio_system::test_support::payer_authorization(payer)); - } - /// Sell `ext_asset_in` from the user's deposit into `pair` for at least `min_expected`. - action_result exchange(name user, symbol pair, const extended_asset& ext_asset_in, const asset& min_expected) { - return push(SWAP_ACCOUNT, swap_abi_ser, user, "exchange"_n, mvo() - ("user", user)("pair_token", pair.to_symbol_code())("ext_asset_in", ext_asset_in)("min_expected", min_expected), - sysio_system::test_support::payer_authorization(user)); - } - /// Move `to_withdraw` from the user's deposit on the swap to `to`'s wallet. - action_result withdraw(name user, name to, const extended_asset& to_withdraw) { - return push(SWAP_ACCOUNT, swap_abi_ser, user, "withdraw"_n, mvo() - ("user", user)("to", to)("to_withdraw", to_withdraw)("memo", ""), - sysio_system::test_support::payer_authorization(user)); - } - - // --- rows --- - - fc::variant decode(abi_serializer& ser, const char* type, const std::vector& data) { - return data.empty() ? fc::variant() - : ser.binary_to_variant(type, data, fixture_abi_yield_function()); - } - fc::variant liq_row(name table, const char* type, name scope, uint64_t id) { - return decode(liq_abi_ser, type, get_row_by_id(LIQ_ACCOUNT, scope, table, id)); - } - fc::variant account_row(name holder, symbol sym = LIQSOL_SYM) { - return liq_row("accounts"_n, "account", holder, sym.to_symbol_code().value); - } - fc::variant index_row(symbol sym = LIQSOL_SYM) { - return liq_row("yieldidx"_n, "yield_index", LIQ_ACCOUNT, sym.to_symbol_code().value); - } - fc::variant stat_row(symbol sym = LIQSOL_SYM) { - return liq_row("stat"_n, "currency_stats", LIQ_ACCOUNT, sym.to_symbol_code().value); - } - fc::variant pending_row(symbol sym = LIQSOL_SYM) { - return liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, sym.to_symbol_code().value); - } - int64_t wire_balance(name holder) { - const auto row = decode(token_abi_ser, "account", - get_row_by_id(TOKEN_ACCOUNT, holder, "accounts"_n, WIRE_SYM.to_symbol_code().value)); - return row.is_null() ? 0 : row["balance"].as().get_amount(); - } - int64_t shadow_balance(name holder) { - const auto row = account_row(holder); - return row.is_null() ? 0 : row["balance"].as().get_amount(); - } - int64_t supply() { return stat_row()["supply"].as().get_amount(); } - fc::uint128_t index() { - const auto row = index_row(); - return row.is_null() ? fc::uint128_t{ 0 } : row["index"].as_uint128(); - } - uint64_t pot() { - const auto row = index_row(); - return row.is_null() ? 0 : row["pot"].as_uint64(); - } - /// What the token's spec says `holder` is owed now. - int64_t owed(name holder) { - const auto row = account_row(holder); - if (row.is_null()) return 0; - return yield_reference::owed(row["balance"].as().get_amount(), index(), - row["index_checkpoint"].as_uint128(), row["owed_wire"].as_uint64()); - } - fc::variant swap_row(name table, const char* type, name scope, uint64_t id) { - return decode(swap_abi_ser, type, get_row_by_id(SWAP_ACCOUNT, scope, table, id)); - } - int64_t reservoir() { - const auto row = swap_row("reservoirs"_n, "reservoir", SWAP_ACCOUNT, POOL_SYM.to_symbol_code().value); - return row.is_null() ? -1 : row["balance"]["quantity"].as().get_amount(); - } - /// The swap's `stat` row of a pair: `pool1` is the shadow leg, `pool2` the WIRE leg. - fc::variant swap_pool_row(symbol pair) { - return swap_row("stat"_n, "currency_stats", SWAP_ACCOUNT, pair.to_symbol_code().value); - } - abi_serializer liq_abi_ser, token_abi_ser, tokens_abi_ser, chains_abi_ser, swap_abi_ser; -}; +#include "sysio_liq_tester.hpp" BOOST_AUTO_TEST_SUITE(sysio_liq_tests) diff --git a/contracts/tests/sysio_liq_tester.hpp b/contracts/tests/sysio_liq_tester.hpp new file mode 100644 index 0000000000..c555a9267e --- /dev/null +++ b/contracts/tests/sysio_liq_tester.hpp @@ -0,0 +1,277 @@ +#pragma once +/** @file sysio_liq_tester.hpp + * @brief Shared depot LIQ/swap fixture for yield and treasury gift integration suites. + */ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "contracts.hpp" +#include "contract_test_support.hpp" +#include "shadow_yield_reference.hpp" + +#include + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::opp::types; +using namespace fc; + +using mvo = fc::mutable_variant_object; +using sysio_system::test_support::codename_mvo; + +/// sysio.liq end to end on the depot: the registries it validates against, the +/// swap it feeds, and sysio.synd as the signer of every mint and burn (an account +/// only; sysio.msgch is an account too, a signer that is not sysio.synd). The +/// treasury supply of WIRE sits on `sysio`, as the bootstrap leaves it. +class sysio_liq_tester : public tester { +public: + static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; + static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; + static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; + static constexpr auto SWAP_ACCOUNT = "sysio.swap"_n; + static constexpr auto SYND_ACCOUNT = "sysio.synd"_n; + static constexpr auto SYSIO_ACCOUNT = "sysio"_n; + + static inline const symbol WIRE_SYM = symbol::from_string("9,WIRE"); + static inline const symbol LIQSOL_SYM = symbol::from_string("9,LIQSOL"); + static inline const symbol LIQETH_SYM = symbol::from_string("9,LIQETH"); + static inline const symbol POOL_SYM = symbol::from_string("9,LIQPOOL"); + static constexpr int64_t UNIT = 1'000'000'000; // one whole token of either symbol, in subunits + + static constexpr std::string_view SOLANA = "SOLANA"; + static constexpr std::string_view ETH = "ETH"; + static constexpr std::string_view LIQSOL = "LIQSOL"; + static constexpr std::string_view LIQETH = "LIQETH"; + + /// Registers the two outposts, their liq tokens and the LIQSOL shadow every case starts from. + sysio_liq_tester() { + produce_blocks(2); + // sysio.synd is the ledger's only minter; an account is enough to sign as it here. + create_accounts({ LIQ_ACCOUNT, MSGCH_ACCOUNT, TOKEN_ACCOUNT, TOKENS_ACCOUNT, CHAINS_ACCOUNT, SWAP_ACCOUNT, + SYND_ACCOUNT, "alice"_n, "bob"_n, "carol"_n, "dave"_n }); + produce_blocks(2); + + deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser, true); + deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser, true); + deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi_ser, true); + deploy(SWAP_ACCOUNT, contracts::swap_wasm(), contracts::swap_abi(), swap_abi_ser, false); + deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser, true); + + // WIRE: the treasury supply on sysio, and working balances for the funders. + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, TOKEN_ACCOUNT, "create"_n, mvo() + ("issuer", SYSIO_ACCOUNT)("maximum_supply", asset(1'000'000'000 * UNIT, WIRE_SYM)))); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, SYSIO_ACCOUNT, "issue"_n, mvo() + ("to", SYSIO_ACCOUNT)("quantity", asset(1'000'000'000 * UNIT, WIRE_SYM))("memo", ""))); + for (auto funder : { "alice"_n, "bob"_n, "carol"_n }) + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, funder, 1'000'000 * UNIT)); + + // The swap: governance is sysio, the system token WIRE. + BOOST_REQUIRE_EQUAL(success(), push(SWAP_ACCOUNT, swap_abi_ser, SWAP_ACCOUNT, "setconfig"_n, mvo() + ("fee_authority", SYSIO_ACCOUNT)("system_token", extended_symbol{ WIRE_SYM, TOKEN_ACCOUNT }))); + + // Two outposts, each with an active liq token, plus a plain SPL token. + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, SOLANA, 2)); + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, ETH, 1)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQSOL, ChainKind::CHAIN_KIND_SVM, SOLANA)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQETH, ChainKind::CHAIN_KIND_EVM, ETH)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_SPL, "USDCSOL", ChainKind::CHAIN_KIND_SVM, SOLANA)); + + BOOST_REQUIRE_EQUAL(success(), create(LIQSOL_SYM, SOLANA, LIQSOL)); + } + + // --- deployment and pushing --- + + void deploy(name account, const std::vector& wasm, const std::vector& abi, + abi_serializer& ser, bool privileged) { + set_code(account, wasm); + set_abi(account, abi.data()); + if (privileged) set_privileged(account); + produce_blocks(); + sysio_system::test_support::load_account_abi(*this, account, ser); + } + + action_result push(name code, abi_serializer& ser, name signer, name action_name, const variant_object& data, + std::vector authorization = {}) { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, code, ser, signer, + action_name, data, + std::move(authorization)); + } + action_result push_liq(name signer, name action_name, const variant_object& data) { + return push(LIQ_ACCOUNT, liq_abi_ser, signer, action_name, data); + } + static bool mentions(const action_result& r, std::string_view text) { + return r.find(text) != std::string::npos; + } + + // --- registries --- + + // Registrations inside the epoch-0 bootstrap window land ACTIVE, as the launch bootstrap's do. + action_result regchain(ChainKind kind, std::string_view code, uint32_t external_chain_id) { + return push(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, "regchain"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("external_chain_id", external_chain_id) + ("name", std::string("outpost"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo())); + } + /// Register a token and bind it to its chain, as the bootstrap does from a `TokenSpec`. + action_result regtoken(TokenKind kind, std::string_view code, uint32_t precision, ChainKind chain_kind, + std::string_view chain_code, const std::vector& address) { + auto r = push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regtoken"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("symbol_name", std::string(code))("description", std::string{}) + ("precision", precision)("address", mvo()("kind", chain_kind)("address", address))); + if (r != success()) return r; + return push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regctok"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(code))("contract_addr", address)("is_native", false)); + } + /// A 9-decimal token at a placeholder address of the chain family's width. + action_result regtoken(TokenKind kind, std::string_view code, ChainKind chain_kind, std::string_view chain_code) { + return regtoken(kind, code, 9, chain_kind, chain_code, + std::vector(chain_kind == ChainKind::CHAIN_KIND_SVM ? 32 : 20, char(0x5a))); + } + + // --- sysio.liq actions --- + + action_result create(symbol sym, std::string_view chain_code, std::string_view token_code, name signer = LIQ_ACCOUNT) { + return push_liq(signer, "create"_n, mvo()("sym", sym)("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))); + } + /// Mint `amount` of the shadow of `token_code` (LIQSOL by default) to `account`, signed as + /// sysio.synd, the only minter. + action_result mint(name account, uint64_t amount, std::string_view token_code = LIQSOL, + name signer = SYND_ACCOUNT) { + return push_liq(signer, "mint"_n, mvo()("to", account)("token_code", codename_mvo(token_code))("amount", amount)); + } + /// LIQ_YIELD released by sysio.synd: `amount` into the pending balance of `token_code`'s shadow. + action_result mintyield(std::string_view chain_code, std::string_view token_code, uint64_t amount, + name signer = SYND_ACCOUNT) { + return push_liq(signer, "mintyield"_n, mvo()("chain_code", codename_mvo(chain_code)) + ("token_code", codename_mvo(token_code))("amount", amount)); + } + action_result queueyield(symbol sym, name signer = "alice"_n) { + return push_liq(signer, "queueyield"_n, mvo()("sym", sym.to_symbol_code())); + } + action_result transfer_shadow(name from, name to, int64_t amount, const std::string& memo = "") { + return push_liq(from, "transfer"_n, mvo()("from", from)("to", to)("quantity", asset(amount, LIQSOL_SYM))("memo", memo)); + } + action_result open(name owner, symbol sym, name ram_payer) { + return push_liq(ram_payer, "open"_n, mvo()("owner", owner)("symbol", sym)("ram_payer", ram_payer)); + } + action_result close(name owner, symbol sym) { + return push_liq(owner, "close"_n, mvo()("owner", owner)("symbol", sym)); + } + action_result claim(name holder, symbol sym = LIQSOL_SYM) { + return push_liq(holder, "claim"_n, mvo()("holder", holder)("sym", sym.to_symbol_code())); + } + action_result addyield(name from, int64_t wire_amount, symbol target = LIQSOL_SYM) { + return push_liq(from, "addyield"_n, mvo()("from", from)("quantity", asset(wire_amount, WIRE_SYM)) + ("target", target.to_symbol_code())); + } + action_result recredit(name holder, int64_t amount, name signer = LIQ_ACCOUNT) { + return push_liq(signer, "recredit"_n, mvo()("holder", holder)("quantity", asset(amount, LIQSOL_SYM))); + } + action_result regliqpool(std::string_view chain_code, std::string_view token_code, symbol pair_symbol, + uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee = 30, + int64_t locked_shares = 0, uint32_t horizon_sec = 86400, uint32_t depth_cap_bps = 300, + int64_t clip_floor = 1000, name signer = LIQ_ACCOUNT) { + return push_liq(signer, "regliqpool"_n, mvo()("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) + ("pair_symbol", pair_symbol)("initial_chain_amount", initial_chain_amount) + ("initial_wire_amount", initial_wire_amount)("fee", fee)("locked_shares", locked_shares) + ("conversion_horizon_sec", horizon_sec)("depth_cap_bps", depth_cap_bps)("clip_floor", clip_floor)); + } + // --- other contracts --- + + action_result transfer_wire(name from, name to, int64_t amount) { + return push(TOKEN_ACCOUNT, token_abi_ser, from, "transfer"_n, mvo() + ("from", from)("to", to)("quantity", asset(amount, WIRE_SYM))("memo", "")); + } + action_result tickyield(symbol pair = POOL_SYM, name signer = "alice"_n) { + return push(SWAP_ACCOUNT, swap_abi_ser, signer, "tickyield"_n, mvo()("pair_token", pair.to_symbol_code())); + } + // The swap is unprivileged and bills a user's rows to the user, so these carry the + // user's `sysio.payer` beside `active`, as the swap suite's own pushes do. + /// A user's deposit row on the swap for one token, RAM billed to `payer`. + action_result openext(name user, name payer, const extended_symbol& ext_symbol) { + return push(SWAP_ACCOUNT, swap_abi_ser, payer, "openext"_n, mvo() + ("user", user)("payer", payer)("ext_symbol", ext_symbol), + sysio_system::test_support::payer_authorization(payer)); + } + /// Sell `ext_asset_in` from the user's deposit into `pair` for at least `min_expected`. + action_result exchange(name user, symbol pair, const extended_asset& ext_asset_in, const asset& min_expected) { + return push(SWAP_ACCOUNT, swap_abi_ser, user, "exchange"_n, mvo() + ("user", user)("pair_token", pair.to_symbol_code())("ext_asset_in", ext_asset_in)("min_expected", min_expected), + sysio_system::test_support::payer_authorization(user)); + } + /// Move `to_withdraw` from the user's deposit on the swap to `to`'s wallet. + action_result withdraw(name user, name to, const extended_asset& to_withdraw) { + return push(SWAP_ACCOUNT, swap_abi_ser, user, "withdraw"_n, mvo() + ("user", user)("to", to)("to_withdraw", to_withdraw)("memo", ""), + sysio_system::test_support::payer_authorization(user)); + } + + // --- rows --- + + fc::variant decode(abi_serializer& ser, const char* type, const std::vector& data) { + return data.empty() ? fc::variant() + : ser.binary_to_variant(type, data, fixture_abi_yield_function()); + } + fc::variant liq_row(name table, const char* type, name scope, uint64_t id) { + return decode(liq_abi_ser, type, get_row_by_id(LIQ_ACCOUNT, scope, table, id)); + } + fc::variant account_row(name holder, symbol sym = LIQSOL_SYM) { + return liq_row("accounts"_n, "account", holder, sym.to_symbol_code().value); + } + fc::variant index_row(symbol sym = LIQSOL_SYM) { + return liq_row("yieldidx"_n, "yield_index", LIQ_ACCOUNT, sym.to_symbol_code().value); + } + fc::variant stat_row(symbol sym = LIQSOL_SYM) { + return liq_row("stat"_n, "currency_stats", LIQ_ACCOUNT, sym.to_symbol_code().value); + } + fc::variant pending_row(symbol sym = LIQSOL_SYM) { + return liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, sym.to_symbol_code().value); + } + int64_t wire_balance(name holder) { + const auto row = decode(token_abi_ser, "account", + get_row_by_id(TOKEN_ACCOUNT, holder, "accounts"_n, WIRE_SYM.to_symbol_code().value)); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + int64_t shadow_balance(name holder) { + const auto row = account_row(holder); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + int64_t supply() { return stat_row()["supply"].as().get_amount(); } + fc::uint128_t index() { + const auto row = index_row(); + return row.is_null() ? fc::uint128_t{ 0 } : row["index"].as_uint128(); + } + uint64_t pot() { + const auto row = index_row(); + return row.is_null() ? 0 : row["pot"].as_uint64(); + } + /// What the token's spec says `holder` is owed now. + int64_t owed(name holder) { + const auto row = account_row(holder); + if (row.is_null()) return 0; + return yield_reference::owed(row["balance"].as().get_amount(), index(), + row["index_checkpoint"].as_uint128(), row["owed_wire"].as_uint64()); + } + fc::variant swap_row(name table, const char* type, name scope, uint64_t id) { + return decode(swap_abi_ser, type, get_row_by_id(SWAP_ACCOUNT, scope, table, id)); + } + int64_t reservoir() { + const auto row = swap_row("reservoirs"_n, "reservoir", SWAP_ACCOUNT, POOL_SYM.to_symbol_code().value); + return row.is_null() ? -1 : row["balance"]["quantity"].as().get_amount(); + } + /// The swap's `stat` row of a pair: `pool1` is the shadow leg, `pool2` the WIRE leg. + fc::variant swap_pool_row(symbol pair) { + return swap_row("stat"_n, "currency_stats", SWAP_ACCOUNT, pair.to_symbol_code().value); + } + abi_serializer liq_abi_ser, token_abi_ser, tokens_abi_ser, chains_abi_ser, swap_abi_ser; +}; diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 5af5d5bddc..d82607f8ad 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -129,7 +129,9 @@ callee of `sysio.epoch`. Independently of inlines, the emissions readiness gate in `sysio.epoch` **reads** `sysio.system`'s `emitcfg`, `t5state` and `payclaimtot`, and `sysio.token`'s -`accounts`. +`accounts`. `sysio.kicker::kick` reads the same four tables to size the treasury +reserve it never draws into, plus `sysio.swap`'s `stat` and `priceaccum` and +`sysio.liq`'s `stat` to price and size each gift. WIRE-352 adds separate inline edges whose failure behavior depends on the caller: @@ -146,7 +148,7 @@ WIRE-352 adds separate inline edges whose failure behavior depends on the caller | `sysio.synd` queue step (`closeenv`, `crank`) | `sysio.bond::request`, `sysio.bond::claim`, `sysio.liq::transfer`, `sysio.liq::mintyield`, `sysio.liq::burn`, `sysio.liq::creditowed` | Deploy `sysio.bond` before or with `sysio.synd`. | With no code on `sysio.bond` the step leaves each envelope WAITING and prints why; a later step requests it. `sysio.synd` checks every condition `request` checks before sending it, so the current `sysio.bond` never aborts the envelope path, but a `sysio.bond` without that action would abort `closeenv` and with it the inbound envelope. The current `sysio.bond` bills the request and escrow rows of a privileged issuer to the `sysio` RAM pool; an older build would bill them to `sysio.synd`, which has no RAM quota of its own, and abort `closeenv` the same way. | | `sysio.synd` queue step, an envelope ruled INVALID or VALID after a challenge | `sysio.bond::claim` | Deploy `sysio.bond` before or with `sysio.synd`. | The step reads the `requests` and `escrows` rows first and sends `claim` only when `sysio.bond` records a forfeit or an award for `sysio.synd`, so the current `sysio.bond` never refuses it with `nothing to claim`. A `sysio.bond` whose rows or claim differ would abort `closeenv` and with it the inbound envelope; the two are one compatibility-coupled set. | | `sysio.synd::challenge` | `sysio.liq::transfer`, `sysio.bond::hold` | Deploy `sysio.bond` before or with `sysio.synd`. | A signed action: a missing or refusing callee fails only the challenge, which the challenger can retry. | -| Andon custody readers: swap, liq, bond, synd | Three-field `cord` row | Deploy all five from the same revision on a fresh deployment; configure native pull/clear permissions. | Existing cord rows use an incompatible layout; no in-place migration is supplied. | +| Andon custody readers: swap, liq, bond, synd, kicker | Three-field `cord` row | Deploy all six from the same revision on a fresh deployment; configure native pull/clear permissions. | Existing cord rows use an incompatible layout; no in-place migration is supplied. | | Syndication custody shortfall | Andon `pull(reason)` with `sysio.andon@active` | Deploy Andon before syndication carries traffic. Privileged syndication needs no custom pull permission. | Missing account/permission or incompatible code fails the transaction atomically. | | `sysio.synd` reading its own `mismatch` rows | its own table: `mismatch` is new | Deploy this `sysio.synd` fresh, as the row below requires. | One latest incident per chain/token; a new shortfall overwrites it. Snapshot evidence externally before governance reconciliation erases it. | | Syndication envelope and bucket rows | Current ABI layouts | Deploy fresh without rows from earlier layouts. | Buckets now store level and last epoch only; no frozen-epoch marker or migration is included. | @@ -256,7 +258,7 @@ changes. For the edges on `advance`'s path today: |---|---| | `sysio.opreg`, `sysio.chalg`, `sysio.msgch` before `sysio.epoch`, when the release adds or changes an action `advance` inlines into them | Call edge: the new `advance` would otherwise reach a callee that cannot dispatch the action, and every advance aborts. | | `sysio` (`sysio.system`) before `sysio.epoch`, when the release adds or changes `accrueepoch` / `rcrdbatch` / `payepoch` | Call edge — but an action `sysio.system` does not implement is silently ignored rather than asserted, so this skew drops the effect instead of stalling. | -| `sysio.epoch` before `sysio.system`, when `sysio.system` gains state the emissions gate must reserve (as `payclaimtot` reserves unclaimed `payclaims`) | Table edge: the old gate counts the new backing as spendable, so a later pay period can double-commit it and leave credited claims underfunded. | +| `sysio.epoch` and `sysio.kicker` before `sysio.system`, when `sysio.system` gains state the emissions gate must reserve (as `payclaimtot` reserves unclaimed `payclaims`) | Table edge: the old gate counts the new backing as spendable, so a later pay period can double-commit it and leave credited claims underfunded; an old kicker counts it as available treasury and `kick` can draw it. | | `sysio.epoch` before `sysio.msgch`, when the release adds or changes an action `chkcons` inlines into `sysio.epoch` | Call edge in the other direction. | When a release changes both directions of one pair, the two contracts are a diff --git a/docs/emergency-stop-playbook.md b/docs/emergency-stop-playbook.md index 8df9edd011..d17489c1fd 100644 --- a/docs/emergency-stop-playbook.md +++ b/docs/emergency-stop-playbook.md @@ -67,9 +67,9 @@ into protocol custody remain allowed. Each item is a deploy or upgrade step. An item not done is a freeze that cannot be pulled. -1. **Depot.** `sysio.andon` is deployed privileged before `sysio.swap`, `sysio.liq`, `sysio.bond` and - `sysio.synd` ([`contract-upgrade-order.md`](contract-upgrade-order.md)); until it exists every - reader sees a clear cord. Fund permission storage through a RAM-only ROA policy +1. **Depot.** `sysio.andon` is deployed privileged before `sysio.swap`, `sysio.liq`, `sysio.bond`, + `sysio.synd` and `sysio.kicker` ([`contract-upgrade-order.md`](contract-upgrade-order.md)); until it + exists every reader sees a clear cord. Fund permission storage through a RAM-only ROA policy from an existing registered node owner (`ROA_ISSUER`), then delegate and link the actions: ```bash clio -u "$DEPOT_URL" push action sysio.roa addpolicy "[\"sysio.andon\",\"$ROA_ISSUER\",\"0.0000 SYS\",\"0.0000 SYS\",\"0.0100 SYS\",0,0]" -p "$ROA_ISSUER@active" @@ -111,6 +111,7 @@ Each item is a deploy or upgrade step. An item not done is a freeze that cannot | `sysio.swap` | every action that moves tokens (among them `exchange`, `addliquidity`, `remliquidity`, `withdraw`, `closeext` of a funded deposit, `tickyield` and `accrueyield` with yield owed, `inittoken`) | reads, and actions that move nothing (`sync`, `changefee`, `openext`, `setyield`) | | `sysio.liq` | `transfer` to anything but a custody contract (`sysio.bond`, `sysio.synd`, `sysio.opreg`), so holders cannot move shadow LIQ between themselves; `claim` by anyone but a custody contract; `queueyield` | `mint`; transfers into a custody contract; `claim` by a custody contract (it moves WIRE into custody) | | `sysio.bond` | `claim` | `request`, `addbounty`, `accept`, `hold`, `approve`, `rslvvalid`, `rslvinvalid`, `sweepyield`, `prune` | +| `sysio.kicker` | `kick` returns without paying and without writing: `last_kick` stays where it was, so accrual keeps running and the first paying `kick` after the clear covers it, within the 72-hour catch-up | `setconfig`, `addpool`, `setpool`, `rmpool` | | `sysio.synd` | the queue step's releases, delivery from `parked`, burns and the forward of a hold share (deferred to the first step after the clear); `desyndicate`, `sweep`, `dropenv`, `sweepyield` (refused: `the andon cord is pulled: funds cannot leave custody`); `linkswept` delivers nothing and returns | intake (`onsynd`, `onyield`, `closeenv`); the queue step's refresh, outcome recording and request issuing; `challenge`; `setconfig`; `importsynd` | | `sysio.msgch`, `sysio.epoch` | nothing | everything | @@ -119,8 +120,9 @@ wait for the clear` and spends none of its budget. On the first tick after clear buckets refill for all elapsed epochs, including the freeze, up to their burst cap. Challenge windows keep counting (they do not extend). -**What the freeze does not cover.** The depot freeze covers syndication and swap custody. Operator -collateral and the other depot payouts are a separate concern and are not frozen: +**What the freeze does not cover.** The depot freeze covers syndication and swap custody and the +`sysio.kicker` gifts. Operator collateral and the other depot payouts listed below are a separate concern +and are not frozen: - `sysio.opreg::claimremit(account, WIRE)` pays through `sysio.token` and runs during a freeze. - `sysio.opreg::claimremit(account, token_code)` for a shadow LIQ `token_code` pays through `sysio.liq::transfer` to the @@ -700,8 +702,8 @@ solana -u "$SOL_RPC" balance "$RELAY_SIGNER" its separate yield-index layout marker is unsupported. Check the deployed program/IDL and marker; follow the program's yield-index upgrade procedure if that marker is unsupported. Configure and verify the panic account and freeze controls before admitting traffic. -- **Depot.** Deploy `sysio.andon` and configure native pull/clear permissions before the four contracts that read the cord; - redeploy all five together whenever the `cord` row layout changes +- **Depot.** Deploy `sysio.andon` and configure native pull/clear permissions before the five contracts that read the cord + (swap, liq, bond, synd, kicker); redeploy all six together whenever the `cord` row layout changes ([`contract-upgrade-order.md`](contract-upgrade-order.md)). - **Ethereum.** Run the permissions steps above with `panicAccount` set. diff --git a/docs/platform-bootstrap-config.md b/docs/platform-bootstrap-config.md index 8df9f2c903..560f8d9821 100644 --- a/docs/platform-bootstrap-config.md +++ b/docs/platform-bootstrap-config.md @@ -83,8 +83,8 @@ pubkey has linked through `sysio.authex`, or parks it until the link is made. `ReserveSpec`, `UwritConfig`, `reserves`, `uwrit` and `t5_reserve_allocation` are removed from the pre-launch schema. Authored JSON uses schema version 1; strict parsing rejects removed keys. -The DEX earmark alone is subtracted from the T5 allotment. Regenerate matching OPP models and -bootstrap clients for a fresh deployment. +The schema carries only the DEX earmark. Subtract any separately configured kicker earmark +as described below. Regenerate matching OPP models and bootstrap clients for a fresh deployment. ## T5 DEX earmark @@ -111,6 +111,59 @@ count WIRE that has physically left the treasury — the readiness gate blocks a launch scale, and effective emissions headroom silently shrinks by `W`. Hence the outside-the-pool earmark. +## Kicker deployment and earmark + +Deploy `sysio.kicker` privileged with `sysio.roa::setsyscode`, alongside LIQ and swap. +After `setemitcfg`/`initt5` and each `regliqpool`, governance (`sysio`) calls +`sysio.kicker::setconfig({budget_remaining, min_interval_sec, twap_window_sec, max_spot_deviation_bps})`, then +`addpool(sym, rate_bps, min_gift, max_gift_per_day)` for each LIQ token. The +C++ defaults are 200 bps and 1,000,000,000 WIRE subunits; all action fields must +be supplied by ABI clients. The daily cap defaults to 1,000 WIRE, must be positive, +and cannot exceed 1,000 WIRE per pool. A third LIQ +requires these same configuration calls and no kicker code change. Any keeper +may call `kick(sym)`, and every ACTIVE batch operator does once a pool could pay; `setpool` changes the whole open interval's rate and +`rmpool` stops accrual. Re-adding starts a new clock and pays no history. + +The kicker earmark `K` uses a governance action, **not a new bootstrap proto field**. +Set `t5_distributable = A - D - K` and retain enough WIRE to back both earmarks. +`setconfig` replaces the remaining budget; it is not an additive top-up and does +not validate the off-chain earmark arithmetic. Governance must back every replacement +with treasury funds outside emissions. The reserve guard protects pending emissions, +claims, and the remaining emission ceiling; it does not reserve node-owner vest. Partial payments advance +a floored pro-rata clock within at most 72 hours of accrual; payment forfeits older +accrual. Pricing uses swap's cumulative-price TWAP with an observation since registration +or the last payment of at least `kickcfg.twap_window_sec` seconds. This governance +setting defaults to **86,400 seconds (one day)** and must be at least **60 seconds**. +Production keeps one day; test clusters may use a shorter window, with `min_interval_sec` +and `min_gift` low enough to permit payment. A change applies on the next `kick` to the +in-flight observation without resetting `price_time`, `price_snapshot` or the accrual +clock. Lowering the window can make that observation immediately eligible; raising it +makes the same observation wait until the new window elapses. +Monitor actual kicks: the batch-operator +crank is the schedule, the minimum interval is a floor, and TWAP warm-up also gates payment. + +A one-block price spike in a shallow pool contributes its duration weight to the +arithmetic TWAP. A large trade held across one block can push one kick's gift to the daily +cap, cashed by a partial unwind, the kick and the rest of the unwind in one block. A holder +of most of the LIQ supply, who receives most of that gift, can then profit from the round +trip. This residual risk is accepted. + +A kick pays only while the pool's spot is within `max_spot_deviation_bps` of the window +TWAP (default **5,000 bps**, set from 1 to 10,000); otherwise it pays nothing and holds the +clock and the observation, because restarting it would let one atomic trade delay payment by +a full window. This bounds the straddle, a pump raised before one kick and unwound after +it: **one price excursion funds one capped payout plus at most `d * cap` of excess on the +next payout**, where `d` is the threshold as a fraction (500 WIRE at the default; see +`contracts/sysio.system/EMISSIONS.md`). Governance registers only pools with deep liquidity +and sizes `max_gift_per_day` so that `(1 + d) * max_gift_per_day` stays well under the +cheaper of holding that pool's price for a full window and a one-block spike. The threshold is a liveness and +griefing knob, not a manipulation-cost one: tighter holds payment through more honest +volatility and makes a griefing spike cheaper, wider does the reverse and loosens the +straddle bound (see `contracts/sysio.system/EMISSIONS.md`). + +The legacy Python boot tool does not deploy the LIQ/swap subsystem; the platform +bootstrap client must create/deploy/configure the kicker in its LIQ flow. + ## Validation Parsing is **strict**: unknown / misspelled keys are rejected, not dropped, diff --git a/docs/sysio-synd.md b/docs/sysio-synd.md index b9f4109858..482f650ca6 100644 --- a/docs/sysio-synd.md +++ b/docs/sysio-synd.md @@ -554,7 +554,7 @@ desyndication. The row must exist, with the buckets set, before the pair carries - Deploy `sysio.andon` privileged before `sysio.synd` carries traffic and configure its native pull/clear permissions (see the emergency-stop playbook). Its active permission exists from account creation and is declared by syndication's privileged inline pull. Redeploy Andon, - swap, liq, bond and synd together when the shared cord layout changes. The simplified cord + swap, liq, bond, synd and kicker together when the shared cord layout changes. The simplified cord and bucket layouts require a fresh deployment; no in-place state migration is included. ## Misconfigurations that stall a pair for good diff --git a/plugins/batch_operator_plugin/CMakeLists.txt b/plugins/batch_operator_plugin/CMakeLists.txt index f10119a680..3d89000e5c 100644 --- a/plugins/batch_operator_plugin/CMakeLists.txt +++ b/plugins/batch_operator_plugin/CMakeLists.txt @@ -12,3 +12,10 @@ plugin_target( fc opp sysio_chain) + +# The kick crank's tests decode table rows with the committed contract ABIs, the way the node renders them. +if(TARGET test_batch_operator_plugin) + target_compile_definitions(test_batch_operator_plugin PRIVATE + KICKER_ABI_PATH="${CMAKE_SOURCE_DIR}/contracts/sysio.kicker/sysio.kicker.abi" + LIQ_ABI_PATH="${CMAKE_SOURCE_DIR}/contracts/sysio.liq/sysio.liq.abi") +endif() diff --git a/plugins/batch_operator_plugin/README.md b/plugins/batch_operator_plugin/README.md index dd1d95884f..101039c73b 100644 --- a/plugins/batch_operator_plugin/README.md +++ b/plugins/batch_operator_plugin/README.md @@ -41,10 +41,28 @@ that is offline, and each is a cheap no-op once its work is done: | `sysio.chalg::chkdispute(dispute_id)` | every OPEN envelope dispute | a dispute pauses `sysio.epoch::advance`, so no inline poke can reach it | | `sysio.swap::tickyield(pair_token)` | every yield pool whose reservoir has a queued balance, at most once per `--batch-yield-tick-interval-ms` per pool from this operator | the reservoir is sold into the pool as time passes; only a tick moves the clock | | `sysio.liq::queueyield(sym)` | every shadow with yield pending from an outpost `LIQ_YIELD` report | the report lands in `sysio.liq`'s pending balance; queuing it into the swap is a separate, permissionless step | +| `sysio.kicker::kick(sym)` | every kicker pool that could pay, at most ten pools per poll from the crank's own cron job, resuming the next page each poll; an attempt that does not pay is retried after `--batch-kick-interval-ms`, doubling to an hour | only a kick pays accrued gifts; TWAP, catch-up and daily caps are enforced on chain (`contracts/sysio.system/EMISSIONS.md`) | The two yield cranks stay idle, without logging a read failure per poll, until both -`sysio.swap` and `sysio.liq` are deployed on the depot. `sysio.synd::crank` is pushed -by the underwriter every poll (see [Underwriter](#underwriter)). +`sysio.swap` and `sysio.liq` are deployed on the depot; the kick crank likewise until +`sysio.kicker` is deployed, and it pushes nothing before `setconfig`. The kick crank +pushes only when the rows say `kick` could pay: the pool's `min_interval_sec` has passed +since `last_kick`, the token's `sysio.liq` supply is not zero, its price observation +(restarted by every payment) spans `twap_window_sec`, and `budget_remaining` and the pool's unspent UTC-day ceiling each cover +`min_gift`. For the supplies it looks up the `sysio.liq::stat` row of each token on the +page, one exact read each, so the page bounds every read in a poll. A failed read of +`kickcfg` or of a supply ends the poll without pushing. Between payments it reads the pools and pushes nothing. A kick that passes those +checks and still does not pay — an Andon hold, a gift below `min_gift`, a treasury reserve, +spot past the contract's spot-versus-TWAP guard — +leaves `last_kick` where it was; the next attempt waits `--batch-kick-interval-ms`, and the +wait doubles per unpaid attempt up to an hour, starting over once `last_kick` moves. The +wait is spent on every attempt, failed or not. The crank runs as its own cron job, so its +pushes, each of which can wait out `--batch-delivery-timeout-ms`, never delay the epoch tick. +A failed pool-page read ends the poll and is retried from the same page; the plugin prunes +removed symbols from the retry state only at the end of a complete sweep. +`--batch-kick-crank=false` turns the kick crank off on this node, and the relay's +permission then need not be linked for `sysio.kicker::kick`. +`sysio.synd::crank` is pushed by the underwriter every poll (see [Underwriter](#underwriter)). ## Configuration @@ -56,6 +74,8 @@ by the underwriter every poll (see [Underwriter](#underwriter)). | `--batch-yield-tick-interval-ms` | 60000 | Minimum spacing between this operator's `sysio.swap::tickyield` pushes per yield pool (ms) | | `--batch-underwriter-account` | none | `account` or `account@permission` (default `active`) the underwriter bonds from. Configuring it enables the underwriter | | `--batch-underwriter-max-exposure` | none | Most the underwriter may have bonded at once in one shadow token, as an asset (`100.000000000 LIQETH`). Repeat per token; a token without one is not underwritten | +| `--batch-kick-crank` | true | Push `sysio.kicker::kick` for every kicker pool that could pay. Off, the relay's permission need not authorize `kick` | +| `--batch-kick-interval-ms` | 60000 | First wait before this operator retries a `sysio.kicker::kick` that did not pay (ms); doubles per unpaid attempt, up to an hour. Must be 1 to 3600000; startup fails otherwise | There is no separate enable flag: the relay runs when `--batch-operator-account` is configured, the way `producer_plugin` keys off `--producer-name`, and the diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index 0f191aa70c..1ba981a4be 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -60,6 +60,16 @@ namespace { constexpr uint32_t whole_milliseconds(fc::microseconds d) { return static_cast(d.count() / fc::milliseconds(1).count()); } + /// First retry spacing of this operator's `sysio.kicker::kick` pushes per LIQ + /// token (`--batch-kick-interval-ms`). `kick_due` already withholds every push + /// the rows show cannot pay; this bounds the retries of one that still did not + /// (an Andon hold, a gift below `min_gift`), doubling per unpaid attempt. + constexpr fc::microseconds KICK_INTERVAL = fc::minutes(1); + /// Longest wait between this operator's unpaid kick attempts on one pool. + constexpr fc::microseconds KICK_RETRY_CEILING = batch_operator_detail::kicker::max_retry_wait; + /// `--batch-kick-crank` default: on, like every other depot crank, which + /// runs whenever its contract is deployed. + constexpr bool KICK_CRANK_ENABLED = true; /// Minimum private cron-service thread count even when 0 outposts are /// discovered at startup — keeps `epoch_tick` viable so a cold-sync node @@ -71,6 +81,8 @@ namespace { constexpr std::size_t EPOCH_TICK_CRON_JOBS = 1; /// The underwriter's polling cron entry. constexpr std::size_t UNDERWRITER_CRON_JOBS = 1; + /// The kick crank's polling cron entry. + constexpr std::size_t KICK_CRON_JOBS = 1; /// Exact secondary-index lookups should return at most the matching row. constexpr uint32_t EXACT_LOOKUP_LIMIT = 1; /// How long past the head block's time a pushed transaction stays valid. @@ -89,6 +101,8 @@ namespace { constexpr fc::microseconds UNDERWRITER_IDLE_CRANK_INTERVAL = fc::minutes(5); /// What a signer signs at startup to show that its signatures recover to its own key. constexpr std::string_view SIGNER_PROBE = "batch_operator_plugin signer probe"; + /// A `kv::global` singleton table holds exactly one row. + constexpr uint32_t SINGLETON_ROW_LIMIT = 1; // ── WIRE contract identifiers (actions, tables, indexes, field names) ── // Centralised so a contract rename/refactor shows up as one search hit, @@ -160,13 +174,10 @@ namespace { namespace uw = batch_operator_detail::underwriter; - /// A depot action a role pushes. - struct pushed_action { - const char* contract; - const char* action; - }; + using batch_operator_detail::pushed_action; - /// Every action the relay pushes: `@active` must be allowed to declare each one (`linkauth`). + /// Every action the relay pushes whatever its options: `@active` must be allowed to declare each one + /// (`linkauth`). constexpr std::array RELAY_ACTIONS{ pushed_action{msgch::account, msgch::action_deliver}, pushed_action{msgch::account, msgch::action_chkcons}, @@ -174,6 +185,9 @@ namespace { pushed_action{batch_operator_detail::swap::account, batch_operator_detail::swap::action_tickyield}, pushed_action{batch_operator_detail::liq::account, batch_operator_detail::liq::action_queueyield}, }; + /// What the relay pushes only while the kick crank is on (`--batch-kick-crank`). + constexpr pushed_action KICK_ACTION{batch_operator_detail::kicker::account, + batch_operator_detail::kicker::action_kick}; /// Every action the underwriter pushes: its permission must be allowed to declare each one (`linkauth`). constexpr std::array UNDERWRITER_ACTIONS{ @@ -320,8 +334,12 @@ struct batch_operator_plugin::impl { std::set underwriter_bonded; /// Which forfeit stops bonding (`underwriter::forfeit_watch`). uw::forfeit_watch underwriter_forfeits; + /// `--batch-kick-crank`: whether this operator pushes `sysio.kicker::kick`. + bool kick_crank_enabled = KICK_CRANK_ENABLED; + /// `--batch-kick-interval-ms`: the first retry spacing of an unpaid kick (`kick_backoff`). + fc::microseconds kick_interval = KICK_INTERVAL; - // Yield cranks -- see `crank_yield`. + // Yield cranks -- see `crank_yield` and `kick_tick`. /// Whether `sysio.swap` and `sysio.liq` run code, refreshed off the read-only /// executor queue every poll (a chainbase read belongs in a read window). The /// cranks act on the last reading rather than wait for a fresh one: one poll @@ -329,6 +347,12 @@ struct batch_operator_plugin::impl { /// off chainbase. std::atomic yield_contracts_deployed{false}; batch_operator_detail::crank_spacing yield_tick_spacing; + /// Whether `sysio.kicker` runs code, refreshed by the kick crank's own job. It, `kick_backoff` and `kick_scan` + /// are touched only from that job. + std::atomic kicker_deployed{false}; + batch_operator_detail::kick_backoff kick_backoff; + /// Fair bounded traversal of kicker pools across polls. + batch_operator_detail::kick_pool_scan kick_scan; // Epoch state tracked across polls uint32_t current_epoch = 0; @@ -345,7 +369,8 @@ struct batch_operator_plugin::impl { // would be wasted CPU on the WIRE chain (msgch's deliver action will reject // since the operator no longer holds bond) AND a TERMINATED operator's // bond is already remitted, so any continued participation is misleading. - bool is_active = true; + /// Written by the epoch tick, read by the kick crank's own cron job. + std::atomic is_active{true}; // Plugin references chain_plugin* chain_plug = nullptr; @@ -666,6 +691,20 @@ struct batch_operator_plugin::impl { }); } + /// Refresh `kicker_deployed` from a read window, for the same reason as `refresh_yield_contract_presence`. + void refresh_kicker_presence() { + app().executor().post(appbase::priority::low, appbase::exec_queue::read_only, [this] { + using namespace batch_operator_detail; + if (shutting_down) return; + const bool kicker_runs = runs_code(chain_plug->chain(), kicker::account); + if (kicker_deployed.exchange(kicker_runs) != kicker_runs) { + ilog("batch_operator: kick crank {}: {} is {}", + kicker_runs ? "active" : "idle", kicker::account, + kicker_runs ? "deployed" : "not deployed"); + } + }); + } + /** * Crank the two permissionless yield actions nothing on chain schedules: * `sysio.swap::tickyield` for every yield pool whose reservoir has a queued @@ -762,6 +801,105 @@ struct batch_operator_plugin::impl { } } + /** + * One pass of the kick crank, run by its own cron job (`batch_operator_kick`) so that its pushes -- each may + * wait out a full `delivery_timeout` -- never hold up the epoch tick and the election state the per-outpost jobs + * read. Pushes only for an ACTIVE operator whose relay key still signs, like the epoch tick's own cranks, and + * idles until `sysio.kicker` runs code. + */ + void kick_tick() { + if (shutting_down || !enabled || !kick_crank_enabled) return; + if (!is_active || !relay_signer_valid) return; + refresh_kicker_presence(); + if (!kicker_deployed) return; + try { + crank_kicks(); + } FC_LOG_AND_DROP(); + } + + /** + * Crank `sysio.kicker::kick(sym)` for every kicker pool `kick_due` says could pay. + * + * Read at most ten pools per poll and retain the next-key cursor, so one pass + * stays bounded as governance adds pools. A page whose read fails ends the pass + * without moving the cursor: it is not the end of a sweep, and treating it as one + * would restart from the first page and prune the retry markers of every pool + * past it. Complete sweeps prune removed symbols from the retry markers. + * + * Like `crank_yield`, NOT gated on `is_elected`: the elected operator may be + * the one that is offline. `kick_due` withholds every push the rows show would + * not pay -- the clock check, a zero LIQ supply, TWAP warm-up after each payment, + * an exhausted budget, a spent daily ceiling -- so the node spends nothing between + * payments. A kick + * that passes them and still does not pay (an Andon hold, a gift below `min_gift`, + * a treasury reserve, spot past the spot guard, a broken swap pair) is retried on `kick_backoff`, starting at + * `batch-kick-interval-ms` and doubling to `KICK_RETRY_CEILING` while `last_kick` + * does not move. The backoff is marked on every attempt, failed or not. + */ + void crank_kicks() { + using namespace batch_operator_detail; + // The config singleton: without it `kick` asserts "kicker not configured". + sysio::chain_apis::read_only::get_table_rows_params config; + config.code = chain::name(kicker::account); + config.scope = kicker::account; + config.table = kicker::table_kickcfg; + config.limit = SINGLETON_ROW_LIMIT; + config.values_only = true; + const auto config_rows = read_table_checked(std::move(config)); + if (!config_rows || config_rows->rows.empty() || !config_rows->rows.front().is_object()) return; + const auto settings = read_kick_settings(config_rows->rows.front().get_object()); + if (!settings) return; + + // One page of pools, keyed by the LIQ symbol code `kick` takes, then the `sysio.liq::stat` row of each + // token on it: `kick` pays nothing on a zero supply, and only restarts the clock. Every read is bounded + // (a page of at most ten pools, an exact lookup per token), so the page bounds the whole poll. + kick_page(kick_scan, kick_backoff, *settings, kick_interval, KICK_RETRY_CEILING, + [this](const std::string& lower_bound) { + sysio::chain_apis::read_only::get_table_rows_params pools; + pools.code = chain::name(kicker::account); + pools.scope = kicker::account; + pools.table = kicker::table_kickpools; + pools.limit = kicker::pools_per_poll; + pools.lower_bound = lower_bound; + return read_table_checked(std::move(pools)); + }, + [this](chain::symbol_code code) { return read_liq_stat(code); }, + [this](chain::symbol_code code) { push_kick(code); }, [] { return fc::time_point::now(); }, + [this] { return shutting_down.load(); }); + } + + /// The `sysio.liq::stat` value of the LIQ token `code` (none when it has no row), by an exact key lookup; nullopt + /// when the read failed. + std::optional read_liq_stat(chain::symbol_code code) { + using namespace batch_operator_detail; + sysio::chain_apis::read_only::get_table_rows_params stat; + stat.code = chain::name(liq::account); + stat.scope = liq::account; + stat.table = liq::table_stat; + stat.find = fc::json::to_string(fc::mutable_variant_object()(liq::field::symbol_code, code.value), + fc::json::yield_function_t{}); + stat.limit = EXACT_LOOKUP_LIMIT; + stat.values_only = true; + std::optional rows = read_table_checked(std::move(stat)); + if (!rows) return std::nullopt; + return std::move(rows->rows); + } + + /// Push `sysio.kicker::kick(code)` as the relay. A refusal is logged by the push callback; a throw here is + /// logged and dropped. + void push_kick(chain::symbol_code code) { + using namespace batch_operator_detail; + try { + push_action(kicker::account, kicker::action_kick, *relay_signer, + fc::mutable_variant_object()(kicker::field::sym, code)); + } catch (const fc::exception& e) { + // Expected-transient: governance removed the pool between the scan and + // the push. Persistent while the pool's LIQ/WIRE swap pair is missing or + // invalid, which only governance can repair. + dlog("batch_operator: kick({}): {}", symbol_code_name(code), e.to_string()); + } + } + /** * Refresh `is_active` from `sysio.opreg::operators[operator_account]`. * @@ -1210,14 +1348,16 @@ struct batch_operator_plugin::impl { /// of `actions`. Call on the main thread, after the sync gate. std::optional resolve_signer(std::string_view role, const chain::permission_level& level, std::span actions) { - const chain::controller& chain = chain_plug->chain(); - bool linked = true; - for (const auto& [contract, action] : actions) { - if (permission_satisfies_link(chain, level, chain::name(contract), chain::name(action))) continue; + const chain::controller& chain = chain_plug->chain(); + const std::vector unlinked = batch_operator_detail::unlinked_actions( + actions, [&](const pushed_action& a) { + return permission_satisfies_link(chain, level, chain::name(a.contract), chain::name(a.action)); + }); + for (const auto& [contract, action] : unlinked) { elog("batch_operator: {} declares {}@{}, which may not authorize {}::{}: link that action to it", role, level.actor.to_string(), level.permission.to_string(), contract, action); - linked = false; } + const bool linked = unlinked.empty(); signature_provider_manager_plugin& sig_plug = app().get_plugin(); const batch_operator_detail::signer_choice choice = batch_operator_detail::choose_signer( @@ -1255,7 +1395,10 @@ struct batch_operator_plugin::impl { bool resolve_signers() { bool resolved = true; if (enabled) { - relay_signer = resolve_signer("the relay", {operator_account, chain::config::active_name}, RELAY_ACTIONS); + // A disabled crank's action is not required: its link cannot stop the node. + const std::vector relay_actions = batch_operator_detail::required_actions( + RELAY_ACTIONS, std::array{batch_operator_detail::optional_action{KICK_ACTION, kick_crank_enabled}}); + relay_signer = resolve_signer("the relay", {operator_account, chain::config::active_name}, relay_actions); resolved = relay_signer.has_value() && resolved; } if (underwriter_enabled) { @@ -1756,11 +1899,17 @@ struct batch_operator_plugin::impl { } } - /// Whether `future` is ready by `deadline`. + /// Whether `future` is ready by `deadline`. Waits in short slices and gives up once the node is shutting down, + /// so a push in flight does not hold a cron thread, and with it shutdown, for the rest of its deadline. template - static bool ready_by(const std::future& future, fc::time_point deadline) { - const fc::microseconds left = std::max(deadline - fc::time_point::now(), fc::microseconds(0)); - return future.wait_for(std::chrono::microseconds(left.count())) == std::future_status::ready; + bool ready_by(const std::future& future, fc::time_point deadline) const { + constexpr auto slice = std::chrono::milliseconds(200); + while (true) { + const fc::microseconds left = std::max(deadline - fc::time_point::now(), fc::microseconds(0)); + const auto wait = std::min(std::chrono::microseconds(left.count()), slice); + if (future.wait_for(wait) == std::future_status::ready) return true; + if (left.count() == 0 || shutting_down) return false; + } } /// Build, sign and submit a depot action, declaring `by.auth` and signed by `by.provider`. True when the node @@ -1794,7 +1943,8 @@ struct batch_operator_plugin::impl { built->set_value(build_unsigned(contract, action_name, auth, data)); }); if (!ready_by(built_future, deadline)) { - elog("batch_operator: push {}::{} timed out before signing", contract, action_name); + if (shutting_down) wlog("batch_operator: push {}::{} abandoned on shutdown", contract, action_name); + else elog("batch_operator: push {}::{} timed out before signing", contract, action_name); return false; } trx = built_future.get(); @@ -1818,7 +1968,8 @@ struct batch_operator_plugin::impl { }); } if (!ready_by(future, deadline)) { - elog("batch_operator: push {}::{} timed out", contract, action_name); + if (shutting_down) wlog("batch_operator: push {}::{} abandoned on shutdown", contract, action_name); + else elog("batch_operator: push {}::{} timed out", contract, action_name); return false; } return completion->succeeded(); @@ -1864,7 +2015,9 @@ struct batch_operator_plugin::impl { // when no outposts are known yet. const std::size_t outpost_count = opp_jobs.size(); const std::size_t relay_jobs = - enabled ? outpost_count * OPP_CRON_JOBS_PER_OUTPOST + EPOCH_TICK_CRON_JOBS : 0; + enabled ? outpost_count * OPP_CRON_JOBS_PER_OUTPOST + EPOCH_TICK_CRON_JOBS + + (kick_crank_enabled ? KICK_CRON_JOBS : 0) + : 0; const std::size_t underwriter_jobs = underwriter_enabled ? UNDERWRITER_CRON_JOBS : 0; const std::size_t thread_count = std::max(relay_jobs + underwriter_jobs, MIN_CRON_THREADS); @@ -1893,6 +2046,9 @@ struct batch_operator_plugin::impl { // and `within_epoch_window` accurate for every per-outpost job. if (enabled) schedule_poll("batch_operator_epoch_tick", [this]() { poll_epoch_state(); }); if (underwriter_enabled) schedule_poll("batch_operator_underwriter", [this]() { underwriter_tick(); }); + // The kick crank pushes serially and each push may wait out `delivery_timeout`: its own job keeps that off + // the epoch tick. + if (enabled && kick_crank_enabled) schedule_poll("batch_operator_kick", [this]() { kick_tick(); }); if (enabled) schedule_opp_jobs(); } @@ -1973,6 +2129,11 @@ void batch_operator_plugin::set_program_options(options_description& cli, opts("batch-underwriter-max-exposure", bpo::value>()->composing(), "Most the underwriter may have bonded at once in one shadow token, as an asset such as " "100.000000000 LIQETH. Repeat for each token; requests in a token without one are not bonded."); + opts("batch-kick-crank", bpo::value()->default_value(KICK_CRANK_ENABLED), + "Push sysio.kicker::kick for every kicker pool that could pay"); + opts("batch-kick-interval-ms", bpo::value()->default_value(whole_milliseconds(KICK_INTERVAL)), + "First spacing between this operator's sysio.kicker::kick retries of a pool that did not pay (ms); " + "doubles per unpaid attempt, up to an hour. Must be 1 to 3600000"); } void batch_operator_plugin::plugin_initialize(const variables_map& options) { @@ -2001,6 +2162,12 @@ void batch_operator_plugin::plugin_initialize(const variables_map& options) { if (!_impl->underwriter_enabled && !_impl->underwriter_caps.empty()) { wlog("batch_operator_plugin: batch-underwriter-max-exposure has no effect without batch-underwriter-account"); } + _impl->kick_crank_enabled = options["batch-kick-crank"].as(); + const uint32_t kick_interval_ms = options["batch-kick-interval-ms"].as(); + FC_ASSERT(batch_operator_detail::kick_interval_valid(kick_interval_ms), + "batch-kick-interval-ms must be between 1 and {} (the retry ceiling), got {}", + whole_milliseconds(KICK_RETRY_CEILING), kick_interval_ms); + _impl->kick_interval = fc::milliseconds(kick_interval_ms); _impl->chain_plug = &app().get_plugin(); _impl->cron_plug = &app().get_plugin(); _impl->eth_plug = &app().get_plugin(); diff --git a/plugins/batch_operator_plugin/src/role_config.hpp b/plugins/batch_operator_plugin/src/role_config.hpp index b3402e91dd..4bf27c66da 100644 --- a/plugins/batch_operator_plugin/src/role_config.hpp +++ b/plugins/batch_operator_plugin/src/role_config.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -44,6 +45,39 @@ inline std::map parse_exposure_caps(const std: return caps; } +/// A depot action a role pushes. +struct pushed_action { + const char* contract; + const char* action; +}; + +/// An action a role pushes only while one of its options is on. +struct optional_action { + pushed_action action; + bool enabled = false; +}; + +/// The actions a role's permission must be linked for: every action it always pushes, and each optional one only +/// while enabled. An action the role never pushes cannot fail its startup. +inline std::vector required_actions(std::span always, + std::span optional) { + std::vector out(always.begin(), always.end()); + for (const optional_action& o : optional) { + if (o.enabled) out.push_back(o.action); + } + return out; +} + +/// The `actions` that `linked` refuses: those the role's permission may not declare, which fail the role's startup. +template +std::vector unlinked_actions(std::span actions, Linked&& linked) { + std::vector out; + for (const pushed_action& a : actions) { + if (!linked(a)) out.push_back(a); + } + return out; +} + /// The provider a role signs with, and how many qualified. template struct signer_choice { diff --git a/plugins/batch_operator_plugin/src/yield_cranks.hpp b/plugins/batch_operator_plugin/src/yield_cranks.hpp index c1498fa858..499b612dea 100644 --- a/plugins/batch_operator_plugin/src/yield_cranks.hpp +++ b/plugins/batch_operator_plugin/src/yield_cranks.hpp @@ -1,19 +1,25 @@ #pragma once /** * @file yield_cranks.hpp - * @brief The decisions behind the depot's two yield cranks -- which `sysio.swap` + * @brief The decisions behind the depot's yield cranks -- which `sysio.swap` * yield pools are worth a `tickyield`, which `sysio.liq` shadows are worth a - * `queueyield` -- and the per-key spacing that keeps one operator from - * cranking the same pool every poll. Pure functions over decoded rows, so - * the plugin's tests drive them without a chain. + * `queueyield`, which `sysio.kicker` pools are worth a `kick` -- and the + * per-key spacing that keeps one operator from cranking the same pool every + * poll. Pure functions over decoded rows, so the plugin's tests drive them + * without a chain. */ +#include #include +#include #include #include +#include #include +#include #include +#include #include #include #include @@ -59,6 +65,33 @@ namespace liq { } } +/// `sysio.kicker` identifiers the kick crank touches. +namespace kicker { + /// Bound the pool page, its supply lookups and the serial transaction attempts in one poll. + inline constexpr uint32_t pools_per_poll = 10; + /// Longest wait between one operator's unpaid kick attempts on one pool, and the most + /// `--batch-kick-interval-ms` may ask for as the first. + inline constexpr fc::microseconds max_retry_wait = fc::hours(1); + constexpr auto account = "sysio.kicker"; + constexpr auto table_kickcfg = "kickcfg"; ///< the `kv::global` config singleton + constexpr auto table_kickpools = "kickpools"; ///< one row per LIQ token + constexpr auto action_kick = "kick"; + /// One UTC day, the period of a pool's `max_gift_per_day` ceiling. + inline constexpr int64_t day_us = int64_t{86'400} * 1'000'000; + namespace field { + constexpr auto budget_remaining = "budget_remaining"; + constexpr auto min_interval_sec = "min_interval_sec"; + constexpr auto twap_window_sec = "twap_window_sec"; + constexpr auto last_kick = "last_kick"; + constexpr auto min_gift = "min_gift"; + constexpr auto max_gift_per_day = "max_gift_per_day"; + constexpr auto day = "day"; + constexpr auto spent_today = "spent_today"; + constexpr auto price_time = "price_time"; + constexpr auto sym = "sym"; ///< the pool row's token and `kick`'s argument + } +} + /// The kv row wrapper `get_table_rows` returns when `values_only` is off. namespace kv_row { constexpr auto key = "key"; @@ -118,6 +151,210 @@ inline bool is_tickable_pool(const fc::variant_object& stat_row) { positive(swap::field::clip_floor); } +/// What the kick crank reads from `sysio.kicker::kickcfg`. +struct kick_settings { + fc::microseconds min_interval; ///< `min_interval_sec`: unpaid accrual before an attempt + fc::microseconds twap_window; ///< `twap_window_sec`: the price observation a payment needs + uint64_t budget_remaining = 0; +}; + +namespace kick_detail { + /// `field` of `row` as an unsigned integer that fits 64 bits, or nullopt when absent, unreadable or wider. + /// Read through `as_uint128`: a uint128 column (`spent_today`) reaches an in-process reader as a uint128 + /// variant, on which `as_uint64` throws, and the HTTP API renders the same column as a decimal string; both read + /// here, as do the uint32/uint64 columns. + inline std::optional unsigned_field(const fc::variant_object& row, const char* field) { + auto it = row.find(field); + if (it == row.end()) return std::nullopt; + try { + const fc::uint128 value = it->value().as_uint128(); + if (value > std::numeric_limits::max()) return std::nullopt; + return static_cast(value); + } catch (const fc::exception&) { + return std::nullopt; + } + } + /// `field` of `row` as a time point, or nullopt when absent or unreadable. + inline std::optional time_field(const fc::variant_object& row, const char* field) { + auto it = row.find(field); + if (it == row.end()) return std::nullopt; + try { + return it->value().as(); + } catch (const fc::exception&) { + return std::nullopt; + } + } +} + +/// The settings of a `sysio.kicker::kickcfg` value, or nullopt when a field is absent, unreadable, or a value +/// `setconfig` refuses (a zero interval, a window under a minute): a row the crank cannot reason about, so it +/// pushes nothing. +inline std::optional read_kick_settings(const fc::variant_object& config) { + constexpr uint64_t min_twap_window_sec = 60; + const auto interval = kick_detail::unsigned_field(config, kicker::field::min_interval_sec); + const auto window = kick_detail::unsigned_field(config, kicker::field::twap_window_sec); + const auto budget = kick_detail::unsigned_field(config, kicker::field::budget_remaining); + if (!interval || *interval == 0 || !window || *window < min_twap_window_sec || !budget) return std::nullopt; + return kick_settings{.min_interval = fc::seconds(static_cast(*interval)), + .twap_window = fc::seconds(static_cast(*window)), + .budget_remaining = *budget}; +} + +/// The `last_kick` of a `sysio.kicker::kickpools` value, or nullopt when the field +/// is absent or does not parse. +inline std::optional kick_last_kick(const fc::variant_object& pool) { + return kick_detail::time_field(pool, kicker::field::last_kick); +} + +/// Each LIQ token's current `sysio.liq::stat` supply, in subunits, keyed by the symbol name `kick` takes. +using liq_supplies = std::map; + +/// The supplies of the `sysio.liq::stat` values in `rows`; a row whose supply does not parse is left out. +inline liq_supplies read_liq_supplies(const fc::variants& rows) { + liq_supplies out; + for (const fc::variant& row : rows) { + if (!row.is_object()) continue; + auto it = row.get_object().find(liq::field::supply); + if (it == row.get_object().end() || !it->value().is_string()) continue; + try { + const auto supply = chain::asset::from_string(it->value().as_string()); + out[supply.get_symbol().name()] = supply.get_amount(); + } catch (const fc::exception&) { + } + } + return out; +} + +/// Whether `--batch-kick-interval-ms` is a usable first retry wait: positive, since `kick_backoff` doubles it and a +/// zero would retry an unpaid kick every poll forever, and no longer than `kicker::max_retry_wait`, the ceiling the +/// backoff doubles up to. +inline bool kick_interval_valid(uint32_t interval_ms) { + return interval_ms > 0 && fc::milliseconds(interval_ms) <= kicker::max_retry_wait; +} + +/// The symbol codes of the `kickpools` rows on a page, in page order. +inline std::vector page_symbol_codes(const fc::variants& rows) { + std::vector out; + for (const fc::variant& r : rows) { + if (!r.is_object()) continue; + if (const auto code = row_symbol_code(r.get_object())) out.push_back(*code); + } + return out; +} + +/// The supplies of the LIQ tokens on a page, each read with `read_stat(code)`: the matching `sysio.liq::stat` +/// values (empty when the token has no row), or nullopt when the read failed. Any failed read fails the whole +/// lookup: the crank does not act on part of the view. +template +std::optional read_page_supplies(const fc::variants& rows, ReadStat&& read_stat) { + fc::variants stats; + for (const chain::symbol_code code : page_symbol_codes(rows)) { + const std::optional found = read_stat(code); + if (!found) return std::nullopt; + stats.insert(stats.end(), found->begin(), found->end()); + } + return read_liq_supplies(stats); +} + +/// True iff `kick` at `now` could pay: every early return and every nonpaying outcome the crank can see from the +/// rows is ruled out. The action returns without paying while +/// - less than `min_interval` has passed since `last_kick` (its clock check), +/// - the token's LIQ supply is zero (it then only restarts the clock, a write that pays nothing), +/// - the price observation since `price_time` is shorter than `twap_window` (TWAP warm-up; every payment restarts +/// the observation, so this alone keeps a paid pool quiet for a window), +/// - the remaining budget is below `min_gift`, or +/// - this UTC day's spending has left less than `min_gift` under `max_gift_per_day`. +/// A row with a zero `supply_snapshot` under a nonzero supply stays due: that kick only checkpoints the supply, +/// but accrual starts at that checkpoint, so it has to be sent. The contract sizes a gift on the smaller of +/// `supply_snapshot` and the current supply; the crank does not price gifts, so neither supply narrows it further. +/// A row missing any of these fields is never due, nor is a token without a supply. What the rows cannot show -- an Andon hold, a gift below +/// `min_gift`, a treasury reserve, spot past the contract's spot-versus-TWAP guard, a broken swap pair -- is bounded +/// by the caller's `kick_backoff`. +inline bool kick_due(const fc::variant_object& pool, const kick_settings& settings, const liq_supplies& supplies, + fc::time_point now) { + const auto last_kick = kick_last_kick(pool); + const auto price_time = kick_detail::time_field(pool, kicker::field::price_time); + const auto minimum = kick_detail::unsigned_field(pool, kicker::field::min_gift); + const auto cap = kick_detail::unsigned_field(pool, kicker::field::max_gift_per_day); + const auto day = kick_detail::unsigned_field(pool, kicker::field::day); + const auto spent = kick_detail::unsigned_field(pool, kicker::field::spent_today); + auto sym = pool.find(kicker::field::sym); + if (!last_kick || !price_time || !minimum || !cap || !day || !spent) return false; + if (sym == pool.end() || !sym->value().is_string()) return false; + const auto supply = supplies.find(sym->value().as_string()); + if (supply == supplies.end() || supply->second <= 0) return false; + if (now <= *last_kick || now - *last_kick < settings.min_interval) return false; + if (now <= *price_time || now - *price_time < settings.twap_window) return false; + if (settings.budget_remaining < *minimum) return false; + const auto today = static_cast(now.time_since_epoch().count() / kicker::day_us); + const uint64_t spent_now = *day == today ? *spent : 0; + const uint64_t allowance = *cap > spent_now ? *cap - spent_now : 0; + return allowance >= *minimum; +} + +/// Retry spacing for kicks that may not pay. The first attempt on a pool waits `base` before the next; each further +/// attempt that found `last_kick` where the previous one left it -- nothing was paid -- doubles the wait, up to +/// `ceiling`. A moved `last_kick` (a payment, by this operator or another) restarts at `base`. Andon holds, gifts +/// below `min_gift` and reserve shortfalls are not visible in the rows the crank reads, and every ACTIVE operator +/// cranks every pool: without the backoff each would push an accepted no-op kick once per `base` for as long as the +/// condition lasts. +class kick_backoff { +public: + bool due(const std::string& key, fc::time_point last_kick, fc::time_point now) const { + auto it = _attempts.find(key); + return it == _attempts.end() || it->second.last_kick != last_kick || now - it->second.at >= it->second.wait; + } + void mark(const std::string& key, fc::time_point last_kick, fc::time_point now, fc::microseconds base, + fc::microseconds ceiling) { + auto [it, added] = _attempts.try_emplace(key, attempt{now, last_kick, base}); + if (added) return; + auto& prior = it->second; + prior.wait = prior.last_kick == last_kick ? std::min(prior.wait + prior.wait, ceiling) : base; + prior.at = now; + prior.last_kick = last_kick; + } + /// The wait before `key` may be retried, for tests and logs; zero when it has no attempt. + fc::microseconds wait(const std::string& key) const { + auto it = _attempts.find(key); + return it == _attempts.end() ? fc::microseconds() : it->second.wait; + } + /// Drop retired symbols only after a complete, unfiltered table sweep. + void retain(const std::set& live) { + std::erase_if(_attempts, [&](const auto& entry) { return !live.contains(entry.first); }); + } + +private: + struct attempt { + fc::time_point at; + fc::time_point last_kick; + fc::microseconds wait; + }; + std::map _attempts; +}; + +/// One pass of the kick crank over `rows` (`kickpools` rows with their kv keys): for each pool `kick_due` passes +/// and `backoff` allows, mark the attempt and `push(code)`. Stops, returning false, as soon as `stopping()`. +template +bool kick_rows(const fc::variants& rows, const kick_settings& settings, const liq_supplies& supplies, + kick_backoff& backoff, fc::microseconds base, fc::microseconds ceiling, Push&& push, Now&& now, + Stopping&& stopping) { + for (const fc::variant& r : rows) { + if (stopping()) return false; + if (!r.is_object()) continue; + const auto code = row_symbol_code(r.get_object()); + const auto value = row_value(r.get_object()); + if (!code || !value) continue; + const auto at = now(); + if (!kick_due(*value, settings, supplies, at)) continue; + const auto pool = symbol_code_name(*code); + const auto last_kick = *kick_last_kick(*value); // kick_due parsed it + if (!backoff.due(pool, last_kick, at)) continue; + backoff.mark(pool, last_kick, at, base, ceiling); + push(*code); + } + return true; +} + /// One push per `interval` per key from this operator. `tickyield` is harmless to /// crank every block, but every ACTIVE operator polls every few seconds and a /// reservoir drains over a horizon of hours, so without spacing the whole group @@ -134,4 +371,44 @@ class crank_spacing { std::map _last_push; }; +/// Retain the chain's inclusive next-key cursor and prune spacing at the end of each sweep. +class kick_pool_scan { +public: + /// Feed directly to get_table_rows.lower_bound; empty starts the next sweep. + const std::string& lower_bound() const { return _next; } + /// Record every decoded symbol, including pools that are not due. + void observe(const std::string& symbol) { _live.insert(symbol); } + /// Call only after a successful page read. A failed read is not the end of a sweep: the caller skips this, so + /// the cursor stays on the unread page and no retry marker is pruned for a pool the sweep has not reached. + void finish_page(bool more, const std::string& next, kick_backoff& backoff) { + _next = more ? next : std::string{}; + if (!more) { + backoff.retain(_live); + _live.clear(); + } + } +private: + std::string _next; + std::set _live; +}; + +/// One poll of the paged kick crank: read the page at `scan`'s cursor with `read_page(lower_bound)` (nullopt when +/// the read failed), read the supplies of just that page's tokens with `read_stat` (see `read_page_supplies`), run +/// `kick_rows` over the page, then finish it. A failed read and a pass cut short by `stopping()` finish nothing: +/// the cursor stays on the unread page and no retry marker is pruned. Returns whether the page was finished. +template +bool kick_page(kick_pool_scan& scan, kick_backoff& backoff, const kick_settings& settings, fc::microseconds base, + fc::microseconds ceiling, ReadPage&& read_page, ReadStat&& read_stat, Push&& push, Now&& now, + Stopping&& stopping) { + const auto page = read_page(scan.lower_bound()); + if (!page) return false; + const std::optional supplies = read_page_supplies(page->rows, read_stat); + if (!supplies) return false; + if (!kick_rows(page->rows, settings, *supplies, backoff, base, ceiling, push, now, stopping)) return false; + // Only a finished page's symbols join the sweep's live set: an unfinished one is read again. + for (const chain::symbol_code code : page_symbol_codes(page->rows)) scan.observe(symbol_code_name(code)); + scan.finish_page(page->more, page->next_key, backoff); + return true; +} + } // namespace sysio::batch_operator_detail diff --git a/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp b/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp index 7d75d8b458..6cf0fdfbdf 100644 --- a/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/test/test_batch_operator_plugin.cpp @@ -56,6 +56,9 @@ BOOST_AUTO_TEST_CASE(plugin_options_are_registered) try { BOOST_CHECK(option_names.count("batch-operator-account") > 0); BOOST_CHECK(option_names.count("batch-epoch-poll-ms") > 0); BOOST_CHECK(option_names.count("batch-delivery-timeout-ms") > 0); + BOOST_CHECK(option_names.count("batch-yield-tick-interval-ms") > 0); + BOOST_CHECK(option_names.count("batch-kick-crank") > 0); + BOOST_CHECK(option_names.count("batch-kick-interval-ms") > 0); // Configuring batch-operator-account is what enables the relay. A separate // enable flag would let an operator set the account and still relay nothing, // which is indistinguishable from a healthy node until the group misses an epoch. @@ -80,6 +83,11 @@ BOOST_AUTO_TEST_CASE(default_options_are_correct) try { BOOST_CHECK_EQUAL(vm["batch-epoch-poll-ms"].as(), 15000u); BOOST_CHECK_EQUAL(vm["batch-delivery-timeout-ms"].as(), 15000u); + BOOST_CHECK_EQUAL(vm["batch-yield-tick-interval-ms"].as(), 60000u); + // The kick crank is on by default, like the other depot cranks, and retries a + // pool no faster than the tickyield crank ticks one. + BOOST_CHECK(vm["batch-kick-crank"].as()); + BOOST_CHECK_EQUAL(vm["batch-kick-interval-ms"].as(), 60000u); // No default account: an unconfigured node leaves the relay off. BOOST_CHECK_EQUAL(vm.count("batch-operator-account"), 0u); } FC_LOG_AND_RETHROW(); diff --git a/plugins/batch_operator_plugin/test/test_role_config.cpp b/plugins/batch_operator_plugin/test/test_role_config.cpp index 0f13b1d4c1..919ae00b55 100644 --- a/plugins/batch_operator_plugin/test/test_role_config.cpp +++ b/plugins/batch_operator_plugin/test/test_role_config.cpp @@ -2,7 +2,9 @@ #include +#include #include +#include #include using namespace sysio::batch_operator_detail; @@ -64,4 +66,24 @@ BOOST_AUTO_TEST_CASE(a_signer_is_chosen_only_when_exactly_one_provider_qualifies BOOST_CHECK_EQUAL(2u, two.matches); } +/// Review of #675: the relay required its permission to authorize `sysio.kicker::kick` even with +/// `--batch-kick-crank=false`, so an unsatisfied kick link stopped a node that would never push it. +BOOST_AUTO_TEST_CASE(a_disabled_crank_action_is_not_required_of_the_permission) { + constexpr std::array always{pushed_action{"sysio.msgch", "deliver"}, pushed_action{"sysio.msgch", "chkcons"}}; + constexpr pushed_action kick{"sysio.kicker", "kick"}; + // The relay's permission may declare every action except the kick. + const auto linked = [](const pushed_action& a) { return std::string_view(a.action) != "kick"; }; + + const auto disabled = required_actions(always, std::array{optional_action{kick, false}}); + BOOST_CHECK_EQUAL(always.size(), disabled.size()); + BOOST_CHECK(unlinked_actions(disabled, linked).empty()); // startup resolves the relay's signer + + const auto enabled = required_actions(always, std::array{optional_action{kick, true}}); + BOOST_REQUIRE_EQUAL(always.size() + 1, enabled.size()); + const auto unlinked = unlinked_actions(enabled, linked); + BOOST_REQUIRE_EQUAL(1u, unlinked.size()); // startup refuses, naming the action to link + BOOST_CHECK_EQUAL(std::string("sysio.kicker"), unlinked.front().contract); + BOOST_CHECK_EQUAL(std::string("kick"), unlinked.front().action); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/batch_operator_plugin/test/test_yield_cranks.cpp b/plugins/batch_operator_plugin/test/test_yield_cranks.cpp index 2a35bc800b..8e5b7aa39c 100644 --- a/plugins/batch_operator_plugin/test/test_yield_cranks.cpp +++ b/plugins/batch_operator_plugin/test/test_yield_cranks.cpp @@ -4,9 +4,13 @@ #include #include +#include +#include +#include #include #include +#include #include using namespace sysio::batch_operator_detail; @@ -14,9 +18,17 @@ using mvo = fc::mutable_variant_object; namespace { +/// Independent deployment fixtures: the production TWAP window and the shortest `setconfig` accepts. +constexpr uint32_t short_twap_window_sec = 60; +constexpr uint32_t production_twap_window_sec = 86'400; +constexpr uint32_t configured_min_interval_sec = 3600; +constexpr int64_t day_seconds = 86'400; + constexpr auto pool_code = "POOLB"; constexpr auto shadow_code = "LIQSOL"; constexpr int64_t one_token = 1'000'000'000; // one whole 9-decimal token, in subunits +/// The fixture token's LIQ supply, which its pools also checkpointed: gifts are sized on the whole of it. +constexpr int64_t fixture_supply = 100 * one_token; uint64_t raw_code(const char* code) { return sysio::chain::symbol(0, code).to_symbol_code().value; } @@ -36,6 +48,88 @@ mvo kv(const char* code, mvo value) { return mvo()("key", mvo()("symbol_code", raw_code(code)))("value", std::move(value)); } +/// The committed ABI at `path` (`KICKER_ABI_PATH` / `LIQ_ABI_PATH`, set by the test target). +sysio::chain::abi_serializer load_abi(const char* path) { + return sysio::chain::abi_serializer(fc::json::from_file(path).as(), + sysio::chain::abi_serializer::create_depth_yield_function()); +} +const sysio::chain::abi_serializer& kicker_abi() { + static const auto abi = load_abi(KICKER_ABI_PATH); + return abi; +} +const sysio::chain::abi_serializer& liq_abi() { + static const auto abi = load_abi(LIQ_ABI_PATH); + return abi; +} + +/// `value` as the node's own table reader hands it to the crank: packed with the contract's ABI and decoded by +/// `abi_serializer`, the path `read_table_rows_checked` takes. Its uint128 columns are uint128 variants there, not +/// the decimal strings the HTTP API prints. +mvo as_chain_renders(const sysio::chain::abi_serializer& abi, const char* type, const fc::variant& value) { + // Depth-bounded only, like the contract fixtures: a wall-clock budget would make the test host-dependent. + const auto yield = sysio::chain::abi_serializer::create_depth_yield_function(); + return mvo(abi.binary_to_variant(type, abi.variant_to_binary(type, value, yield), yield).get_object()); +} + +/// A `sysio.kicker::kickcfg` value as the chain renders it. +mvo kick_config_row(uint64_t budget, uint32_t window = production_twap_window_sec, + uint32_t interval = configured_min_interval_sec) { + return as_chain_renders(kicker_abi(), "kick_config", mvo() + ("budget_remaining", budget)("min_interval_sec", interval)("twap_window_sec", window) + ("max_spot_deviation_bps", 5'000)); // the contract's default +} + +/// `kick_config_row` read back as the crank reads it. +kick_settings settings(uint64_t budget = 1000 * one_token, uint32_t window = production_twap_window_sec) { + return read_kick_settings(kick_config_row(budget, window)).value(); +} + +/// A `sysio.kicker::kickpools` value as the chain renders it: the LIQ token, its rate, minimum and daily ceiling, +/// the accrual clock, the price observation and this UTC day's spending (a uint128 column). +mvo kick_pool_row(fc::time_point last_kick, fc::time_point price_time, uint64_t day = 0, uint64_t spent = 0, + uint64_t cap = 1000 * one_token, const char* sym = shadow_code) { + return as_chain_renders(kicker_abi(), "kick_pool", mvo() + ("sym", sym) + ("rate_bps", 200) + ("min_gift", one_token) + ("last_kick", last_kick) + ("gifted_total", fc::uint128(5)) + ("shortfall_amount", 0) + ("shortfall_time", fc::time_point()) + ("gift_overflow", false) + ("spot_deviation", false) + ("max_gift_per_day", cap) + ("day", day) + ("spent_today", fc::uint128(spent)) + ("supply_snapshot", fixture_supply) + ("price_snapshot", mvo()("lo", fc::uint128(0))("hi", fc::uint128(0))) + ("price_time", price_time)); +} + +/// A pool whose observation matured long ago, so only the clock, supply and budget decide. +mvo kick_pool_row(fc::time_point last_kick, const char* sym = shadow_code) { + return kick_pool_row(last_kick, last_kick - fc::days(2), 0, 0, 1000 * one_token, sym); +} + +/// The UTC day number of `t`, as `kick` records it. +uint64_t utc_day(fc::time_point t) { return static_cast(t.sec_since_epoch() / day_seconds); } + +/// A `sysio.liq::stat` value as the chain renders it. +fc::variant liq_stat_row(const std::string& supply) { + return fc::variant(as_chain_renders(liq_abi(), "currency_stats", mvo() + ("supply", supply)("chain_code", "SOLANA")("token_code", "SOL")("pair_symbol", pool_code))); +} + +/// `amount` subunits of the 9-decimal LIQ token `sym`, as an asset string. +std::string liq_asset(int64_t amount, const char* sym = shadow_code) { + return sysio::chain::asset(amount, sysio::chain::symbol(9, sym)).to_string(); +} + +/// The supplies the crank reads for the fixture's token. +liq_supplies supplies(int64_t amount = fixture_supply) { + return read_liq_supplies({liq_stat_row(liq_asset(amount))}); +} + } // namespace BOOST_AUTO_TEST_SUITE(yield_cranks_tests) @@ -91,6 +185,244 @@ BOOST_AUTO_TEST_CASE(row_value_unwraps_the_kv_wrapper) { BOOST_CHECK(!row_value(mvo()("key", mvo())).has_value()); } +BOOST_AUTO_TEST_CASE(kick_settings_read_the_config_and_refuse_what_setconfig_would) { + for (const uint32_t window : {short_twap_window_sec, production_twap_window_sec}) { + const auto read = read_kick_settings(kick_config_row(7, window)); + BOOST_REQUIRE(read.has_value()); + BOOST_CHECK_EQUAL(fc::seconds(configured_min_interval_sec).count(), read->min_interval.count()); + BOOST_CHECK_EQUAL(fc::seconds(window).count(), read->twap_window.count()); + BOOST_CHECK_EQUAL(7u, read->budget_remaining); + } + + BOOST_CHECK(!read_kick_settings(mvo()("budget_remaining", 0)).has_value()); // absent fields + BOOST_CHECK(!read_kick_settings(kick_config_row(0, production_twap_window_sec, 0)).has_value()); // refused 0 + BOOST_CHECK(!read_kick_settings(kick_config_row(0, short_twap_window_sec - 1)).has_value()); // refused < 60 + BOOST_CHECK(!read_kick_settings(mvo()("budget_remaining", 0)("min_interval_sec", "not a number") + ("twap_window_sec", 60)).has_value()); +} + +/// The rows reach the crank as `abi_serializer` decodes them in-process: a uint128 column is a uint128 variant, on +/// which `as_uint64` throws. The crank reads it, and the HTTP API's decimal-string rendering, alike. +BOOST_AUTO_TEST_CASE(kick_fields_read_as_the_node_renders_them) { + const auto pool = kick_pool_row(fc::time_point::from_iso_string("2026-10-10T12:00:00.000")); + BOOST_REQUIRE(pool["gifted_total"].is_uint128()); + BOOST_CHECK_THROW(pool["gifted_total"].as_uint64(), fc::exception); + BOOST_CHECK_EQUAL(5u, *kick_detail::unsigned_field(pool, "gifted_total")); + BOOST_CHECK_EQUAL(5u, *kick_detail::unsigned_field(mvo()("gifted_total", "5"), "gifted_total")); + // A value past 64 bits is refused, not truncated. + const fc::uint128 wide = fc::uint128(std::numeric_limits::max()) + 1; + BOOST_CHECK(!kick_detail::unsigned_field(mvo()("gifted_total", fc::variant(wide)), "gifted_total").has_value()); + BOOST_CHECK(!kick_detail::unsigned_field(mvo()("gifted_total", -1), "gifted_total").has_value()); + BOOST_CHECK_EQUAL(std::string(shadow_code), pool["sym"].as_string()); + + // Review of #675: `spent_today` reaches the crank the same way. Read with `as_uint64` it threw, every pool read + // as not due, and the crank never pushed a kick on a live node. + const auto at = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + const auto paid = kick_pool_row(at - fc::days(2), at - fc::days(2), utc_day(at), one_token); + BOOST_REQUIRE(paid["spent_today"].is_uint128()); + BOOST_CHECK_THROW(paid["spent_today"].as_uint64(), fc::exception); + BOOST_CHECK_EQUAL(static_cast(one_token), *kick_detail::unsigned_field(paid, "spent_today")); + BOOST_CHECK(kick_due(paid, settings(), supplies(), at)); +} + +BOOST_AUTO_TEST_CASE(kick_is_due_only_past_the_contract_clock_check) { + const auto last_kick = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + const auto min_interval = fc::seconds(configured_min_interval_sec); + const auto pool = kick_pool_row(last_kick); + const auto cfg = settings(); + + // `kick` returns without writing while `now - last_kick < min_interval`. + BOOST_CHECK(!kick_due(pool, cfg, supplies(), last_kick)); + BOOST_CHECK(!kick_due(pool, cfg, supplies(), last_kick + min_interval - fc::microseconds(1))); + BOOST_CHECK(kick_due(pool, cfg, supplies(), last_kick + min_interval)); + BOOST_CHECK(kick_due(pool, cfg, supplies(), last_kick + fc::days(3))); // a long-unpaid pool stays due + // A clock ahead of this node's (read from a later block) is never due. + BOOST_CHECK(!kick_due(pool, cfg, supplies(), last_kick - fc::seconds(1))); +} + +/// Review of #675: one hour after a payment the minimum interval had passed, but the contract could not pay for +/// the rest of the TWAP window -- every ACTIVE operator pushed an accepted no-op kick per pool per retry interval +/// (about 1,380 a day each at the defaults). The observation restarts at every payment, so `price_time` gates it. +BOOST_AUTO_TEST_CASE(kick_waits_for_the_twap_observation_after_a_payment) { + const auto paid_at = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + const auto pool = kick_pool_row(paid_at, paid_at, utc_day(paid_at), one_token); + const auto cfg = settings(); + const auto window = fc::seconds(production_twap_window_sec); + + BOOST_CHECK(!kick_due(pool, cfg, supplies(), paid_at + fc::seconds(configured_min_interval_sec))); + BOOST_CHECK(!kick_due(pool, cfg, supplies(), paid_at + window - fc::microseconds(1))); + BOOST_CHECK(kick_due(pool, cfg, supplies(), paid_at + window)); + // The window is read from the config, not assumed. + BOOST_CHECK(kick_due(pool, settings(1000 * one_token, short_twap_window_sec), supplies(), + paid_at + fc::seconds(configured_min_interval_sec))); + // A partial payment advances `last_kick` only pro rata, but restarts the observation all the same. + const auto partial = kick_pool_row(paid_at - fc::days(2), paid_at, utc_day(paid_at), 1000 * one_token); + BOOST_CHECK(!kick_due(partial, cfg, supplies(), paid_at + fc::hours(1))); +} + +BOOST_AUTO_TEST_CASE(kick_is_not_due_while_the_budget_or_the_day_cannot_pay_the_minimum) { + // Noon, so a few hours later is the same UTC day. + const auto start = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + const auto now = start + fc::hours(3); + const auto today = utc_day(now); + const auto old = start - fc::days(2); + + // The budget: `kick` pays nothing below `min_gift`. + BOOST_CHECK(kick_due(kick_pool_row(old, old), settings(one_token), supplies(), now)); + BOOST_CHECK(!kick_due(kick_pool_row(old, old), settings(one_token - 1), supplies(), now)); + BOOST_CHECK(!kick_due(kick_pool_row(old, old), settings(0), supplies(), now)); + + // This UTC day's ceiling: spent to within less than `min_gift`, nothing more is paid today ... + const uint64_t cap = 10 * one_token; + BOOST_CHECK(kick_due(kick_pool_row(old, old, today, cap - one_token, cap), settings(), supplies(), now)); + BOOST_CHECK(!kick_due(kick_pool_row(old, old, today, cap - one_token + 1, cap), settings(), supplies(), now)); + BOOST_CHECK(!kick_due(kick_pool_row(old, old, today, cap, cap), settings(), supplies(), now)); + // ... and spending recorded on an earlier day does not count. + BOOST_CHECK(kick_due(kick_pool_row(old, old, today - 1, cap, cap), settings(), supplies(), now)); + BOOST_CHECK(kick_due(kick_pool_row(old, old, today, cap, cap), settings(), supplies(), + fc::time_point(fc::seconds(static_cast(today + 1) * day_seconds)))); +} + +/// Review of #675: on a zero LIQ supply `kick` restarts the clock without paying, which looked to the crank like a +/// payment and restarted its backoff -- a write per pool per `min_interval_sec` per operator while nobody holds the +/// token. A zero (or unknown) supply is never due. +BOOST_AUTO_TEST_CASE(kick_is_not_due_while_the_token_has_no_supply) { + const auto old = fc::time_point::from_iso_string("2026-10-08T12:00:00.000"); + const auto now = old + fc::days(2); + BOOST_CHECK(kick_due(kick_pool_row(old), settings(), supplies(1), now)); + BOOST_CHECK(!kick_due(kick_pool_row(old), settings(), supplies(0), now)); + BOOST_CHECK(!kick_due(kick_pool_row(old), settings(), liq_supplies{}, now)); // no stat row + BOOST_CHECK(!kick_due(kick_pool_row(old, "LIQETH"), settings(), supplies(), now)); // another token's + + // A zero snapshot under a nonzero supply is different: that kick checkpoints the supply, and accrual starts only + // then, so the crank must still send it. + auto unchecked = kick_pool_row(old); + unchecked("supply_snapshot", 0); + BOOST_CHECK(kick_due(unchecked, settings(), supplies(), now)); + + // The supplies come from `sysio.liq::stat` rows as the node renders them; an unreadable row is left out. + const auto read = read_liq_supplies({liq_stat_row(liq_asset(2 * one_token)), liq_stat_row(liq_asset(0, "LIQETH")), + fc::variant(mvo()("supply", 7))}); + BOOST_CHECK_EQUAL(2u, read.size()); + BOOST_CHECK_EQUAL(2 * one_token, read.at(shadow_code)); + BOOST_CHECK_EQUAL(0, read.at("LIQETH")); +} + +BOOST_AUTO_TEST_CASE(kick_row_fields_parse_as_rendered_and_a_missing_one_is_never_due) { + const auto last_kick = fc::time_point::from_iso_string("2026-10-10T12:00:00.500"); + const auto parsed = kick_last_kick(kick_pool_row(last_kick)); + BOOST_REQUIRE(parsed.has_value()); + BOOST_CHECK(*parsed == last_kick); + const auto later = last_kick + fc::days(3); + BOOST_REQUIRE(kick_due(kick_pool_row(last_kick), settings(), supplies(), later)); + + for (const auto* field : {"last_kick", "price_time", "min_gift", "max_gift_per_day", "day", "spent_today", "sym"}) { + BOOST_TEST_CONTEXT(field) { + auto missing = kick_pool_row(last_kick); + missing.erase(field); + BOOST_CHECK(!kick_due(missing, settings(), supplies(), later)); + auto garbled = kick_pool_row(last_kick); + garbled.set(field, mvo()("not", "a value")); + BOOST_CHECK(!kick_due(garbled, settings(), supplies(), later)); + } + } + BOOST_CHECK(!kick_last_kick(mvo()).has_value()); +} + +BOOST_AUTO_TEST_CASE(a_due_kick_pool_is_keyed_by_its_symbol_code) { + // The crank reads `kickpools` with its kv keys: the key is the `kick` argument. + const auto last_kick = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + const auto row = kv(shadow_code, kick_pool_row(last_kick)); + const auto code = row_symbol_code(row); + const auto value = row_value(row); + BOOST_REQUIRE(code.has_value()); + BOOST_REQUIRE(value.has_value()); + BOOST_CHECK_EQUAL(std::string(shadow_code), symbol_code_name(*code)); + BOOST_CHECK(kick_due(*value, settings(), supplies(), last_kick + fc::days(1))); +} + +/// The crank's pass over the pool rows: due pools are pushed once, a pool that is not due or still backing off is +/// not, and a shutdown stops the pass. +BOOST_AUTO_TEST_CASE(kick_rows_pushes_each_due_pool_once_and_stops_on_shutdown) { + const auto last_kick = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + const auto now = last_kick + fc::days(1); + const fc::variants rows{fc::variant(kv(shadow_code, kick_pool_row(last_kick))), + fc::variant(kv("LIQETH", kick_pool_row(last_kick, "LIQETH"))), // no supply + fc::variant(kv(pool_code, kick_pool_row(now, pool_code)))}; // clock not due + const liq_supplies stocked{{shadow_code, one_token}, {pool_code, one_token}}; + kick_backoff backoff; + std::vector pushed; + const auto push = [&](sysio::chain::symbol_code code) { pushed.push_back(symbol_code_name(code)); }; + const auto clock = [&] { return now; }; + const auto base = fc::seconds(60), ceiling = fc::hours(1); + + BOOST_CHECK(kick_rows(rows, settings(), stocked, backoff, base, ceiling, push, clock, [] { return false; })); + BOOST_REQUIRE_EQUAL(1u, pushed.size()); + BOOST_CHECK_EQUAL(std::string(shadow_code), pushed.front()); + // The same poll again: the attempt is backing off. + BOOST_CHECK(kick_rows(rows, settings(), stocked, backoff, base, ceiling, push, clock, [] { return false; })); + BOOST_CHECK_EQUAL(1u, pushed.size()); + + kick_backoff fresh; + BOOST_CHECK(!kick_rows(rows, settings(), stocked, fresh, base, ceiling, push, clock, [] { return true; })); + BOOST_CHECK_EQUAL(1u, pushed.size()); + BOOST_CHECK(fresh.due(shadow_code, last_kick, now)); // nothing was marked +} + +/// What the rows cannot show (an Andon hold, a gift below `min_gift`) leaves `last_kick` alone: each unpaid +/// attempt doubles the wait to the ceiling, and a moved clock -- a payment by anyone -- starts over. +BOOST_AUTO_TEST_CASE(kick_backoff_doubles_while_unpaid_and_resets_when_the_clock_moves) { + const auto base = fc::seconds(60); + const auto ceiling = fc::hours(1); + const auto last_kick = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + auto now = last_kick + fc::days(1); + kick_backoff backoff; + + BOOST_CHECK(backoff.due(shadow_code, last_kick, now)); + backoff.mark(shadow_code, last_kick, now, base, ceiling); + BOOST_CHECK_EQUAL(base.count(), backoff.wait(shadow_code).count()); + BOOST_CHECK(!backoff.due(shadow_code, last_kick, now + base - fc::microseconds(1))); + BOOST_CHECK(backoff.due(pool_code, last_kick, now)); // keys are independent + + uint32_t attempts = 1; + auto expected = base; + while (expected < ceiling) { + now += backoff.wait(shadow_code); + BOOST_REQUIRE(backoff.due(shadow_code, last_kick, now)); + backoff.mark(shadow_code, last_kick, now, base, ceiling); + ++attempts; + expected = std::min(expected + expected, ceiling); + BOOST_CHECK_EQUAL(expected.count(), backoff.wait(shadow_code).count()); + } + BOOST_CHECK_EQUAL(7u, attempts); // waits of 1, 2, 4, 8, 16 and 32 minutes, then the hour + backoff.mark(shadow_code, last_kick, now + ceiling, base, ceiling); + BOOST_CHECK_EQUAL(ceiling.count(), backoff.wait(shadow_code).count()); + + // A payment moved `last_kick`: due at once, and the next unpaid attempt waits only `base` again. + const auto paid = last_kick + fc::days(1); + BOOST_CHECK(backoff.due(shadow_code, paid, now + ceiling + fc::seconds(1))); + backoff.mark(shadow_code, paid, now + ceiling + fc::seconds(1), base, ceiling); + BOOST_CHECK_EQUAL(base.count(), backoff.wait(shadow_code).count()); +} + +/// Persistent nonpayment for a day: at the old fixed one-minute spacing an operator pushed 1,440 kicks per pool; +/// the backoff pushes a few dozen. +BOOST_AUTO_TEST_CASE(kick_backoff_bounds_a_day_of_unpaid_attempts) { + const auto base = fc::seconds(60); + const auto ceiling = fc::hours(1); + const auto last_kick = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + const auto start = last_kick + fc::days(1); + kick_backoff backoff; + uint32_t pushes = 0; + for (auto now = start; now < start + fc::days(1); now += fc::seconds(15)) { // the default poll + if (!backoff.due(shadow_code, last_kick, now)) continue; + backoff.mark(shadow_code, last_kick, now, base, ceiling); + ++pushes; + } + BOOST_CHECK_LE(pushes, 32u); + BOOST_CHECK_GE(pushes, 24u); +} + BOOST_AUTO_TEST_CASE(crank_spacing_allows_one_push_per_interval_per_key) { crank_spacing spacing; const auto interval = fc::seconds(60); @@ -104,4 +436,191 @@ BOOST_AUTO_TEST_CASE(crank_spacing_allows_one_push_per_interval_per_key) { BOOST_CHECK(spacing.due(shadow_code, t0, interval)); } +/// A multi-page sweep retains all live pools and drops retired retry keys only on completion. +BOOST_AUTO_TEST_CASE(kick_scan_pages_fairly_and_prunes_removed_pools) { + static_assert(kicker::pools_per_poll == 10); + constexpr auto next_page = "{\"symbol_code\":123}"; + constexpr auto retired = "RETIRED"; + const auto now = fc::time_point::now(); + const auto last_kick = now - fc::days(1); + const auto interval = fc::seconds(60); + kick_backoff backoff; + kick_pool_scan scan; + backoff.mark(pool_code, last_kick, now, interval, interval); + backoff.mark(shadow_code, last_kick, now, interval, interval); + backoff.mark(retired, last_kick, now, interval, interval); + BOOST_CHECK(scan.lower_bound().empty()); + scan.observe(pool_code); + scan.finish_page(true, next_page, backoff); + BOOST_CHECK_EQUAL(next_page, scan.lower_bound()); + BOOST_CHECK(!backoff.due(retired, last_kick, now)); + scan.observe(shadow_code); + scan.finish_page(false, {}, backoff); + BOOST_CHECK(scan.lower_bound().empty()); + BOOST_CHECK(!backoff.due(pool_code, last_kick, now)); + BOOST_CHECK(!backoff.due(shadow_code, last_kick, now)); + BOOST_CHECK(backoff.due(retired, last_kick, now)); + // An empty table prunes everything; re-added symbols can be attempted immediately. + scan.finish_page(false, {}, backoff); + BOOST_CHECK(backoff.due(pool_code, last_kick, now)); + BOOST_CHECK(backoff.due(shadow_code, last_kick, now)); +} + +/// Review of #675: `read_table` turned a failed read into an empty last page, so finishing the page after a failure +/// restarted the sweep and pruned the retry markers of every pool past the failed page. The crank now skips +/// `finish_page` when the read fails; the next pass rereads the same page and the sweep completes from there. +BOOST_AUTO_TEST_CASE(kick_scan_survives_a_failed_middle_page) { + constexpr auto second_page = "{\"symbol_code\":2}"; + constexpr auto tail = "TAIL"; + const auto now = fc::time_point::now(); + const auto last_kick = now - fc::days(1); + const auto interval = fc::seconds(60); + + // What a failure taken as completion did: the cursor restarts and the tail pool's marker is gone. + { + kick_backoff backoff; + kick_pool_scan scan; + backoff.mark(tail, last_kick, now, interval, interval); + scan.observe(pool_code); + scan.finish_page(true, second_page, backoff); + scan.finish_page(false, {}, backoff); // the failed read's default result: no rows, no `more` + BOOST_CHECK(scan.lower_bound().empty()); + BOOST_CHECK(backoff.due(tail, last_kick, now)); + } + + kick_backoff backoff; + kick_pool_scan scan; + backoff.mark(tail, last_kick, now, interval, interval); + scan.observe(pool_code); + scan.finish_page(true, second_page, backoff); + // Page two fails: nothing is finished, so the cursor still names page two and the tail keeps its marker. + BOOST_CHECK_EQUAL(second_page, scan.lower_bound()); + BOOST_CHECK(!backoff.due(tail, last_kick, now)); + // The next pass reads page two and completes the sweep: pools on both pages survive the prune. + scan.observe(tail); + scan.finish_page(false, {}, backoff); + BOOST_CHECK(scan.lower_bound().empty()); + BOOST_CHECK(!backoff.due(tail, last_kick, now)); +} + +namespace { +/// A kickpools page as `read_table_checked` returns it. +struct page_t { + fc::variants rows; + bool more = false; + std::string next_key; +}; + +/// A two-page kickpools table (LIQSOL, then POOLB) behind the crank's two readers, with switchable failures. Each +/// stat lookup returns that token's row as the node renders it, and records which token was looked up. +struct paged_table { + static constexpr auto second_page = "{\"symbol_code\":2}"; + fc::time_point last_kick = fc::time_point::from_iso_string("2026-10-10T12:00:00.000"); + bool fail_page = false; + bool fail_stat = false; + std::vector reads, lookups; + + std::optional read_page(const std::string& lower_bound) { + reads.push_back(lower_bound); + if (fail_page) return std::nullopt; + if (lower_bound.empty()) + return page_t{{fc::variant(kv(shadow_code, kick_pool_row(last_kick)))}, true, second_page}; + return page_t{{fc::variant(kv(pool_code, kick_pool_row(last_kick, pool_code)))}, false, {}}; + } + std::optional read_stat(sysio::chain::symbol_code code) { + const auto name = symbol_code_name(code); + lookups.push_back(name); + if (fail_stat) return std::nullopt; + return fc::variants{liq_stat_row(liq_asset(one_token, name.c_str()))}; + } +}; +} // namespace + +/// The crank's paged poll: a failed page read finishes nothing and pushes nothing, and the next poll rereads that +/// page and completes the sweep. +BOOST_AUTO_TEST_CASE(kick_page_finishes_nothing_on_a_failed_read) { + paged_table table; + const auto now = table.last_kick + fc::days(1); + const auto base = fc::seconds(60), ceiling = fc::hours(1); + kick_backoff backoff; + kick_pool_scan scan; + backoff.mark("TAIL", table.last_kick, now, base, ceiling); + std::vector pushed; + const auto run = [&] { + return kick_page(scan, backoff, settings(), base, ceiling, + [&](const std::string& lower_bound) { return table.read_page(lower_bound); }, + [&](sysio::chain::symbol_code code) { return table.read_stat(code); }, + [&](sysio::chain::symbol_code code) { pushed.push_back(symbol_code_name(code)); }, + [&] { return now; }, [] { return false; }); + }; + + BOOST_CHECK(run()); // page one + BOOST_CHECK_EQUAL(paged_table::second_page, scan.lower_bound()); + table.fail_page = true; + BOOST_CHECK(!run()); // page two fails + BOOST_CHECK_EQUAL(paged_table::second_page, scan.lower_bound()); + BOOST_CHECK(!backoff.due("TAIL", table.last_kick, now)); // nothing pruned + BOOST_CHECK_EQUAL(1u, pushed.size()); + table.fail_page = false; + BOOST_CHECK(run()); // page two again: the sweep completes + BOOST_CHECK(std::vector({"", paged_table::second_page, paged_table::second_page}) == table.reads); + BOOST_CHECK(scan.lower_bound().empty()); + BOOST_CHECK(std::vector({shadow_code, pool_code}) == pushed); + BOOST_CHECK(backoff.due("TAIL", table.last_kick, now)); // retired only once the sweep was complete +} + +/// Review of #675: the supplies came from a full `sysio.liq::stat` scan every poll, unbounded by the ten-pool page. +/// Now only the page's own tokens are looked up, one exact read each, and a failed lookup ends the poll the way a +/// failed page read does: nothing pushed, nothing finished. +BOOST_AUTO_TEST_CASE(kick_page_looks_up_only_its_own_tokens_and_stops_on_a_failed_lookup) { + paged_table table; + const auto now = table.last_kick + fc::days(1); + const auto base = fc::seconds(60), ceiling = fc::hours(1); + kick_backoff backoff; + kick_pool_scan scan; + std::vector pushed; + const auto run = [&] { + return kick_page(scan, backoff, settings(), base, ceiling, + [&](const std::string& lower_bound) { return table.read_page(lower_bound); }, + [&](sysio::chain::symbol_code code) { return table.read_stat(code); }, + [&](sysio::chain::symbol_code code) { pushed.push_back(symbol_code_name(code)); }, + [&] { return now; }, [] { return false; }); + }; + + table.fail_stat = true; + BOOST_CHECK(!run()); + BOOST_CHECK(pushed.empty()); + BOOST_CHECK(scan.lower_bound().empty()); // page one again next poll + BOOST_CHECK(std::vector({shadow_code}) == table.lookups); + + table.fail_stat = false; + table.lookups.clear(); + BOOST_CHECK(run()); + BOOST_CHECK(std::vector({shadow_code}) == table.lookups); // page one's token only + BOOST_CHECK(run()); + BOOST_CHECK(std::vector({shadow_code, pool_code}) == table.lookups); + BOOST_CHECK(std::vector({shadow_code, pool_code}) == pushed); + + // A token with no stat row has no supply: it is never due, and the page still finishes. + const auto page = table.read_page({}); + BOOST_REQUIRE(page.has_value()); + const auto none = read_page_supplies(page->rows, [](sysio::chain::symbol_code) { + return std::optional(fc::variants{}); + }); + BOOST_REQUIRE(none.has_value()); + BOOST_CHECK(none->empty()); +} + +/// Review of #675: `--batch-kick-interval-ms` is the backoff's first wait. Zero would double to zero forever and +/// retry an unpaid kick every poll; more than the ceiling would exceed it on the first wait. +BOOST_AUTO_TEST_CASE(kick_interval_must_be_positive_and_within_the_retry_ceiling) { + const auto ceiling_ms = static_cast(kicker::max_retry_wait.count() / fc::milliseconds(1).count()); + BOOST_CHECK_EQUAL(3'600'000u, ceiling_ms); + BOOST_CHECK(!kick_interval_valid(0)); + BOOST_CHECK(kick_interval_valid(1)); + BOOST_CHECK(kick_interval_valid(60'000)); // the default + BOOST_CHECK(kick_interval_valid(ceiling_ms)); + BOOST_CHECK(!kick_interval_valid(ceiling_ms + 1)); +} + BOOST_AUTO_TEST_SUITE_END()