Skip to content

Add sysio.kicker: budgeted T5 WIRE yield gifts to LIQ holders - #675

Merged
valthon merged 6 commits into
masterfrom
feature/sysio-kicker
Oct 27, 2026
Merged

valthon merged 6 commits into
masterfrom
feature/sysio-kicker

Conversation

@valthon

@valthon valthon commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Adds sysio.kicker, which pays LIQ holders a WIRE yield from the T5 (sysio) treasury at a configured annual rate, 2 % by default.

Behaviour

  • Gift formula: kick(sym) can be called by anyone, at most once per min_interval_sec. It pays floor(min(supply_snapshot, stat.supply) × rate_bps × Δt / (10000 × 365.25 d)) LIQ priced at the sysio.swap cumulative-price TWAP (the existing twap.hpp), observed for at least kickcfg.twap_window_sec since registration or the last payment (default 24 h, minimum 60 s, set through setconfig). A trade in the same block carries zero weight. The gift is delivered through sysio.liq::addyield.
  • Every payment restarts the observation. Full or partial, a payment resets price_snapshot/price_time, so a price window cannot be reused for a second payment. A partial payment advances last_kick pro rata (elapsed × paid / gift, floored); the remainder keeps accruing and is priced by the next fresh window, never carried over at the old price.
  • Spot-vs-TWAP guard: when a kick would pay and the pool's spot differs from the window TWAP by more than kickcfg.max_spot_deviation_bps (1–10000, default 5,000), it pays nothing and holds last_kick, price_snapshot and price_time; kick_pool.spot_deviation records why. After a full unwind a pump straddling a payment can price the next kick at no more than spot/(1−threshold), so one price excursion funds one capped payout plus at most threshold × cap of excess on the next payout (min(cap, honest/(1−d)) − honest, 500 WIRE at the default; two capped payouts at 10,000 bps). partial_unwind_straddle_overpays_at_most_deviation_times_cap measures it: 50.02 WIRE excess in the review's construction, 499.98 WIRE at the bound's worst point, and a pump just past the guard's edge trips and pays nothing. A trip holds rather than restarting the observation, so one atomic trade cannot delay payment by a window. The threshold is a liveness and griefing knob: at 5,000 bps an honest ±50 % move clears at once and a doubling after half the observation length.
  • Supply checkpoint: a gift is sized on the smaller of the LIQ supply at the pool's last checkpoint (kick_pool.supply_snapshot) and the current supply, so it never exceeds the honest gift of the supply present at the last checkpoint and LIQ minted just before a mature kick earns nothing for the interval it missed. addpool and every full payment checkpoint the current supply; a partial payment keeps the supply it priced. A zero snapshot under a nonzero supply only checkpoints. With the 72 h catch-up saturated, a gift below min_gift forfeits the interval and checkpoints the current supply only when the same interval sized on the current supply reaches min_gift (the snapshot is binding); a price dip that sinks both holds. Residuals, documented in EMISSIONS.md: addyield still shares the gift across the current supply, and sizing sees the supply at the two ends of the interval, not inside it.
  • Simple accrual: it pays exactly the configured annual rate whatever the call cadence.
  • Per LIQ token: each LIQ token has its own row, clock and rate_bps, defaulting to 200. Adding a third LIQ token is configuration only.
  • Funding: a privileged draw from sysio, bounded by a governance budget (setconfig, kept outside total_distributed), a mandatory per-pool UTC-day cap (min_gift ≤ max_gift_per_day ≤ 1,000 WIRE, default 1,000 WIRE), and a reserve of pending emission, outstanding pay claims and the remaining emissions headroom, so a kick can never make the epoch readiness gate fail.
  • Bounded catch-up: one kick pays at most 72 h of accrual; older accrual is forfeited on a payment.
  • Clock holds: below min_gift (1 WIRE per pool), or while the andon cord is pulled, the clock does not advance (kick returns without writing). sysio.kicker is the fifth reader of Andon's cord row: docs/contract-upgrade-order.md, docs/emergency-stop-playbook.md and docs/sysio-synd.md list it in the set redeployed together. Zero supply advances the clock and pays nothing.
  • Accepted risk: a one-block spike can still be cashed in one block (partial unwind to spot ≈ TWAP, kick, finish the unwind; same-block trades carry no TWAP weight), pushing one kick to the daily cap. Governance sizes max_gift_per_day so that (1 + threshold) × max_gift_per_day stays well under the cheaper of holding the pool's price for a full window and a one-block spike (EMISSIONS.md, docs/platform-bootstrap-config.md).

Batch-operator crank (--batch-kick-crank, default on; --batch-kick-interval-ms, default 60000)

  • Runs in its own cron job, batch_operator_kick, so a slow push cannot hold the epoch/election tick. Pushes give up within 200 ms of shutdown.
  • A pool is pushed only when the contract could pay: min_interval_sec has passed, the TWAP window is mature, the budget and the UTC-day allowance each cover min_gift, and the LIQ token has a nonzero supply. Unpaid attempts back off per pool, doubling from the interval to a 1-hour ceiling, and reset when last_kick moves.
  • Pages through at most 10 pools per poll with a retained cursor, then looks up only that page's tokens with one exact read of sysio.liq::stat each, so the page bounds every read. A failed config, page or stat read ends the poll without pushing and keeps the cursor and retry markers. Startup requires 0 < --batch-kick-interval-ms <= 1 h.
  • sysio.kicker::kick is required of the relay permission only while the crank is enabled.

Chapters (6 commits on master 261d1ce68c; each states its final design)

  1. 6c81ff7d6d Share swap state layouts with contract readers. The sysio.swap .wasm/.abi are byte-identical to master's.
  2. 9c4f91735b Extract the LIQ test fixture into sysio_liq_tester.hpp. A pure move.
  3. f224a3fcb2 Add the kicker's simple-accrual arithmetic with host tests, including the TWAP window and daily-ceiling constants.
  4. f585f4b226 Add sysio.kicker: the contract in its final design, its committed .wasm/.abi (empty CMAKE_BUILD_TYPE, the committed flavour), the chain suite and the docs.
  5. 39771789e9 Crank sysio.kicker::kick from batch operators.
  6. 959454a660 Add a seeded differential fuzz test for sysio.kicker, which also checks the observation checkpoint and the supply snapshot after every kick.

Validation of head 959454a660 (each commit checked out in a clean worktree; clang-18 Release host build, BUILD_SYSTEM_CONTRACTS=OFF, committed wasm, as PR CI; ctest entries are test_batch_operator_plugin plus one per contract suite):

Chapter ctest entries run result
1 plugin + liq, swap, twap, emissions (6) 6/6 ✓
2 same (6) 6/6 ✓
3 + kicker math (7) 7/7 ✓
4 + kicker (8) 8/8 ✓
5 (8) 8/8 ✓
6 every contract suite + plugin (44) 44/44 ✓
  • The committed sysio.kicker.wasm/.abi come from a standalone CDT contracts build with an empty CMAKE_BUILD_TYPE; the unmodified source rebuilt byte-identically to the previous commit's artifact before the change, and the new artifact matches the build output.
  • mint_immediately_before_mature_kick paid the 1,000 WIRE cap on the previous wasm and pays 5.475701574 WIRE now; price_dip_below_minimum_does_not_forfeit forfeited on the previous wasm and holds now.
  • Plugin fixtures are real rows packed with the committed ABIs and decoded by abi_serializer.

Review of huangminghuang's findings: both rounds answered on their threads. Round one (five findings) and round two (the straddle, the unbounded stat scan, the interval range) are fixed; pump_straddling_a_checkpoint_funds_no_payout replays the straddle and paid two capped gifts on the pre-guard contract. Round three: kicker joins the Andon reader set in the upgrade docs, and the partial-unwind straddle is kept and bounded as described above (owner's decision), with the regression pinning the bound and a guard-removed contract failing it. The branch is rebased onto master after #684, with the new test fixtures on its depth-only ABI deadline. Round four (supply eligibility): gifts are sized on the checkpointed supply as described above (owner's decision: kicker-only sizing), with regressions for a mint before a mature kick and a mint between straddle kicks; its residuals are documented. heifner's review: the emissions-gate case conforms to #658's net headroom and the upgrade-order doc lists kicker's table reads. The branch is rebased on master 261d1ce68c. Adversarial review rounds on each fix; the last found only wording, which is applied.

UNVERIFIED: macOS and the sanitizer/assert lanes (CI runners); the crank against a live chain with sysio.kicker deployed (the wire-tools-ts #122 kick flow covers that); the relay-permission startup path with the crank disabled (the decision is unit-tested, a real node startup is not); cron timing of the separate job.

Companion PRs: SDK types in wire-libraries-ts and docs in wire-platform-manifest are separate PRs that reference this one. Deployment, bootstrap and a flow test are wire-tools-ts #122. This round changes the ABI: kick_config gains max_spot_deviation_bps (uint16, last field, so setconfig callers send it) and kick_pool gains spot_deviation (bool, after gift_overflow). This round also adds kick_pool.supply_snapshot (int64, after spent_today); wire-libraries-ts #106 (SDK types) needs it, and wire-tools-ts #122 if it reads kickpools rows.

🤖 Generated with Claude Code

@valthon
valthon requested a review from a team October 10, 2026 16:10
@valthon
valthon requested a review from heifner October 12, 2026 15:18
@valthon
valthon force-pushed the feature/sysio-kicker branch 2 times, most recently from c5567d0 to 6c40641 Compare October 14, 2026 22:47
valthon added a commit to Wire-Network/wire-libraries-ts that referenced this pull request Oct 15, 2026
Regenerated SysioContractTypes.ts with
contracts/tools/generate-sysio-contract-types.py -P snake against
wire-sysio 51973283f4 (Wire-Network/wire-sysio#675), then formatted with
the repo's Prettier config. The only change is the new sysio.kicker
surface: kick, setconfig, addpool, setpool, rmpool, and the kickcfg and
kickpools tables, plus its entries in SysioContractName,
SysioContractAccount, SysioContractMapping and SysioContractDefinitions.

A client test checks that the typed proxy binds sysio.kicker, prepares
kick, and reads kickpools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SUTuRushtiEZgUXLRaoEiv
@valthon
valthon force-pushed the feature/sysio-kicker branch from 6c40641 to 77823ea Compare October 15, 2026 20:54
valthon added a commit to Wire-Network/wire-libraries-ts that referenced this pull request Oct 20, 2026
Regenerated SysioContractTypes.ts with
contracts/tools/generate-sysio-contract-types.py -P snake against
wire-sysio 51973283f4 (Wire-Network/wire-sysio#675), then formatted with
the repo's Prettier config. The only change is the new sysio.kicker
surface: kick, setconfig, addpool, setpool, rmpool, and the kickcfg and
kickpools tables, plus its entries in SysioContractName,
SysioContractAccount, SysioContractMapping and SysioContractDefinitions.

A client test checks that the typed proxy binds sysio.kicker, prepares
kick, and reads kickpools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SUTuRushtiEZgUXLRaoEiv

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Five actionable findings from review of the current head; details are inline.

}
// 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) +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — partial payouts reapply already-settled price history. This advances last_kick proportionally, but price_snapshot and price_time advance only when paid == gift. The next attempt therefore prices a different residual/new accrual interval with a TWAP that still contains the history used for the amount already paid. For example, with 100,000 LIQ at 2%, a manipulated 72-hour TWAP producing a 1,001 WIRE gift pays the 1,000 WIRE cap; after 24 hours back at 1 WIRE/LIQ, the retained observation produces about 252.37 WIRE instead of the approximately 6.48 WIRE remainder plus normal new accrual. One manipulation can therefore fund more than one capped payout, defeating the documented cap-versus-manipulation-cost assumption. Please checkpoint the accumulator after every positive payment and carry the exact unpaid WIRE amount, or otherwise keep the pricing and accrual intervals aligned. A spike → capped partial → price-unwind regression would catch this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in "Bound kicker gifts with swap TWAP and daily payout limits". Your scenario reproduced on the old wasm: the day-after payout was 253.43 WIRE where about 5.51 was due. Every positive payment, full or partial, now resets price_snapshot/price_time; the unpaid remainder stays on the last_kick clock and is priced by the next fresh observation window. sysio_kicker_tests/spike_capped_partial_then_unwind is the regression you suggested (spike → capped partial → unwind), and the seeded fuzz test now checks the observation checkpoint after every kick (all three cases fail on the old wasm).

I went with checkpointing rather than carrying the exact unpaid WIRE amount. A carried amount is priced at the very TWAP the cap exists to distrust, so the cap becomes a rate limit: with 100,000 LIQ at 2%, 72 h of accrual is ~16.4 WIRE, and one window manipulated 1,000× would yield ~16,400 WIRE metered at 1,000/day over 17 days. With the reset, one manipulated window yields at most one capped payout, and a second costs a second full-window manipulation.

/// crank from pushing a no-op `kick` every poll between payments; what the clock
/// check cannot see (an Andon hold, a gift below `min_gift`) is bounded by the
/// caller's `crank_spacing`.
inline bool kick_due(const fc::variant_object& pool, fc::microseconds min_interval, fc::time_point now) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — the default gate creates recurring accepted no-op transactions. With the default one-hour minimum interval and 24-hour TWAP window, this becomes true one hour after each full payment, while the contract cannot pay for another 23 hours. The one-minute retry spacing means every ACTIVE operator submits roughly 1,380 successful no-op kick transactions per pool per day (about 28,980 with 21 operators). Exhausted budgets and daily caps create the same repeated-attempt condition. Please include TWAP maturity and known budget/day-cap eligibility in the gate, and use a substantially longer or backed-off retry for unpredictable nonpayment conditions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed. kick_due now also requires a mature TWAP window since price_time, a budget and an unspent UTC-day allowance that each cover min_gift, and a nonzero LIQ supply (a zero-supply pool only resets the clock). Unpaid attempts back off per pool from --batch-kick-interval-ms doubling to a 1-hour ceiling, resetting when last_kick moves; kick_backoff_bounds_a_day_of_unpaid_attempts keeps a day of unpaid attempts to 24–32 pushes per operator, against 1,440 before.

pools.lower_bound = kick_scan.lower_bound();
const auto page = read_table(std::move(pools));
const auto interval = kick_interval;
for (const auto& r : page.rows) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — this serial loop can block epoch/election polling for ten independent delivery timeouts. crank_kicks runs inside the one-at-a-time batch_operator_epoch_tick; each push_action gets a fresh default 15-second deadline, so ten slow attempts can occupy this job for about 150 seconds and cause the intervening 15-second polls to be discarded. A larger configured delivery timeout can span the default six-minute epoch while per-outpost jobs use stale election state. Please move kicker processing to a separate cron job, or impose one deadline on the entire page and resume from the first unprocessed row.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the kick crank runs in its own cron job, batch_operator_kick, like the underwriter's, so a slow push can no longer hold the epoch/election tick. ready_by also gives up within 200 ms of shutdown (logged as abandoned, not as a timeout). There is no automated test of the cron timing.

pools.table = kicker::table_kickpools;
pools.limit = kicker::pools_per_poll;
pools.lower_bound = kick_scan.lower_bound();
const auto page = read_table(std::move(pools));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — a failed page read is interpreted as a completed sweep. read_table maps timeout/failure to a default empty result with more == false; the unconditional finish_page below then resets the cursor and prunes spacing for unseen pools. A failure after page one can repeatedly starve tail pools and discard their retry markers. Please use read_table_checked here and return without calling finish_page on nullopt. A failed-middle-page-then-recovery test would cover the state transition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: both reads use read_table_checked, and a failed read returns without finish_page, so the cursor and the tail pools' retry markers survive. kick_page_finishes_nothing_on_a_failed_read covers a failed middle page followed by recovery.

pushed_action{chalg::account, chalg::action_chkdispute},
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},
pushed_action{batch_operator_detail::kicker::account, batch_operator_detail::kicker::action_kick},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — disabling the kick crank does not disable this authorization prerequisite. --batch-kick-crank=false gates execution, but this unconditional entry still makes signer resolution require the relay permission to authorize sysio.kicker::kick; an unsatisfied link then triggers fail-fast node shutdown even though the crank is disabled. Please construct the required action list from enabled cranks, or skip this link check when the kick crank is off, and cover disabled startup with an unsatisfied kick link.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the required action list is built from the enabled cranks (required_actions/unlinked_actions in role_config.hpp), so sysio.kicker::kick is only required with --batch-kick-crank on. a_disabled_crank_action_is_not_required_of_the_permission covers the decision; a real node startup with an unsatisfied link is not tested.

@valthon
valthon force-pushed the feature/sysio-kicker branch from 77823ea to 37cc9e8 Compare October 22, 2026 20:48

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-pass review of the rewritten head. The five issues from my prior review appear addressed; I found three remaining concerns inline.

microseconds(static_cast<int64_t>(static_cast<uint128_t>(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: one price excursion funds one payout.
pool.price_snapshot = cumulative;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium / security: This checkpoint still lets one physical pump/unwind fund two capped payouts. If an attacker pumps during the block before kick, calls kick while spot is high, holds the displacement for one more 500 ms block, then unwinds, the two 500 ms halves enter adjacent observations even though the attacker paid for one round trip. With valid 60 s settings, 100,000 LIQ at 200 bps and spot 31,557,601, each window has TWAP 262,981; the two gifts are 1,000.003661170 and 1,000.007342904 WIRE. Put the first kick just before UTC midnight and both daily caps are available. The one-day production window has the same boundary construction. This contradicts EMISSIONS.md and its assumption that one excursion funds at most one capped payout. Please add a live straddling regression and either prevent a manipulated spot from crossing the checkpoint or size and document the cap for two payouts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed with a spot-vs-TWAP deviation guard. kickcfg gains max_spot_deviation_bps (1..10000, default 5,000): when kick would pay and the pool's spot differs from the window TWAP by more than that, it pays nothing and holds last_kick, price_snapshot and price_time (diagnostic kick_pool.spot_deviation). sysio_kicker_tests/pump_straddling_a_checkpoint_funds_no_payout replays your construction just before UTC midnight; on the pre-guard contract both kicks paid the cap, with the guard neither pays. After a full unwind the straddle's second kick can price at no more than spot/(1−threshold), so each excursion funds at most one capped payout.

What the guard does not do: a one-block spike can still be cashed in one block (partial unwind to spot ≈ TWAP, kick, finish the unwind; same-block trades carry no TWAP weight), so the docs now say governance sizes max_gift_per_day against the cheaper of holding the price for a window and a one-block spike, and one_block_spike_cashed_in_one_block_pays_the_inflated_gift documents that residual. A trip holds rather than restarting the observation, because a restart would let one atomic trade delay payment a full window for almost nothing. The threshold is documented as a liveness/griefing knob: at 5,000 bps an honest ±50% move clears at once, a doubling after half the observation length.

if (!settings) return;

// Each LIQ token's supply: `kick` pays nothing on a zero supply, and only restarts the clock.
const std::optional<fc::variants> stats = read_all_rows(liq::account, liq::account, liq::table_stat);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium / performance: This makes every nominally ten-pool poll perform an unlimited full scan of sysio.liq::stat first. read_all_rows sets all_rows=true, and that table mode explicitly ignores the row limit and caller deadline, so work scales with the whole LIQ registry every 15 seconds per active operator; a slow scan can outlive the waiter and remain on the executor. Read the kicker page first and exact-query or cache only its at-most-ten symbols (or remove the supply scan, since a valid configured yield pair retains positive LIQ reserve) so the advertised bound actually bounds the poll.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the crank reads the kicker page first, then looks up only that page's ≤10 tokens with one exact find on sysio.liq::stat each, so the page bounds every read. A failed lookup ends the poll without finish_page, like a failed page read. kick_page_looks_up_only_its_own_tokens_and_stops_on_a_failed_lookup covers it. I kept the supply gate (a zero-supply pool would otherwise be kicked every hour only to reset its clock).

wlog("batch_operator_plugin: batch-underwriter-max-exposure has no effect without batch-underwriter-account");
}
_impl->kick_crank_enabled = options["batch-kick-crank"].as<bool>();
_impl->kick_interval = fc::milliseconds(options["batch-kick-interval-ms"].as<uint32_t>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium / configuration: Please validate 0 < batch-kick-interval-ms <= KICK_RETRY_CEILING here. Zero is currently accepted, and kick_backoff initializes and doubles that zero forever, so an Andon hold, treasury shortfall, or invalid pair produces a push every default 15-second poll (up to 5,760 attempts per pool/operator/day). Values above one hour likewise exceed the documented ceiling on the first wait before shrinking. Add boundary tests for zero and the ceiling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: startup fails unless 0 < --batch-kick-interval-ms <= 3600000 (the retry ceiling), with the value in the message. kick_interval_must_be_positive_and_within_the_retry_ceiling checks 0, 1, 60000, 3600000 and 3600001.

@valthon
valthon force-pushed the feature/sysio-kicker branch from 37cc9e8 to 3e9529c Compare October 23, 2026 02:28
valthon added a commit to Wire-Network/wire-libraries-ts that referenced this pull request Oct 23, 2026
Regenerated SysioContractTypes.ts with
contracts/tools/generate-sysio-contract-types.py -P snake against
wire-sysio 3e9529ce83 (Wire-Network/wire-sysio#675), then formatted with
the repo's Prettier config. The only change is the new sysio.kicker
surface: kick, setconfig, addpool, setpool, rmpool, and the kickcfg and
kickpools tables, plus its entries in SysioContractName,
SysioContractAccount, SysioContractMapping and SysioContractDefinitions.
kick_config carries max_spot_deviation_bps and kick_pool carries
spot_deviation.

A client test checks that the typed proxy binds sysio.kicker, prepares
kick, and reads kickpools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third-pass review of the current head. The prior stat-scan and interval-validation fixes look correct; I found two remaining concerns inline.

// remainder stays in the open interval (holder-favoured, < 1 subunit at realistic supply).
pool.last_kick = now - microseconds(elapsed) +
microseconds(static_cast<int64_t>(static_cast<uint128_t>(elapsed) * paid / *gift));
// Every payment settles the price history it used. A partial payment's unpaid accrual is priced by the next

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium / security: This checkpoint still lets one pump/unwind fund two daily caps when the first kick occurs after a partial unwind, rather than while fully pumped. With the default 86,400 s window and 5,000-bps guard, a one-block peak spot of 2,985,984,001 produces a first TWAP of 17,281. Partially unwind to 17,281 and call kick in the same block: the guard passes and this checkpoint is stored. Hold 17,281 for one block, fully unwind to 1, and the next TWAP is 1.1, so spot 1 also passes. With 8,675,000 LIQ at 200 bps and a 1,000 WIRE cap, the first payment hits the cap and advances the clock only 10.525 s; the next honest-price gift is 949.976 WIRE, while the manipulated gift is 1,044.974 WIRE and pays a second cap if the first kick is just before midnight. A valid 75k/75k zero-fee pool reaches the peak with about 4.10B WIRE, below the asset limit. pump_straddling_a_checkpoint_funds_no_payout only kicks while spot remains fully pumped, so it misses this partial-unwind path. Please add that regression and prevent a manipulated boundary spot from seeding the next observation, or document and size the system for two payouts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: your construction reproduces exactly (TWAP 17,281, clock +10.525 s, then 1,044.974 WIRE gift against 949.976 honest). The owner chose to keep the guard as is and state the bound it actually gives instead of the one-payout claim: one price excursion funds one capped payout plus at most threshold × cap of excess on the next payout (min(cap, honest/(1−d)) − honest, 500 WIRE at the default 5,000 bps; at 10,000 bps two capped payouts). partial_unwind_straddle_overpays_at_most_deviation_times_cap pins it on the contract: 50.02 WIRE excess in your construction, 499.98 WIRE at the worst point (honest next gift ≈ cap/2, TWAP at exactly 2× spot, the guard's inclusive edge), and a third case past the edge (TWAP 4× spot) trips the guard, pays nothing and holds the clock and observation; that case fails against a contract with the guard removed. EMISSIONS.md, docs/platform-bootstrap-config.md and the kick() comment state the bound, and the sizing rule is now (1 + threshold) × max_gift_per_day under the cheaper of a window hold or a one-block spike.

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<uint64_t>(cfg.min_interval_sec) * kicker_math::micros_per_sec ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium / deployment safety: This makes sysio.kicker a fifth reader of Andon's serialized cord row, but the canonical compatibility table at docs/contract-upgrade-order.md:149 still lists only swap, liq, bond and synd and says to deploy five contracts total. docs/emergency-stop-playbook.md:70-71,109-115,703-705 repeats the stale set/count and omits that kick returns without advancing its clock while frozen. The shared Andon header requires Andon and every reader to be redeployed together. Please add kicker as the fifth reader/sixth compatibility-set member and document its frozen behavior, otherwise a future cord-layout upgrade following the runbook can leave kicker decoding an incompatible row.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: docs/contract-upgrade-order.md lists kicker as the fifth Andon cord reader ("swap, liq, bond, synd, kicker … Deploy all six"); docs/emergency-stop-playbook.md names it in the deploy order, the upgrade window and a new depot-table row saying kick returns without paying or writing while frozen (the clock stays put, so accrual continues and the first kick after the clear covers it within the 72-hour catch-up; setconfig/addpool/setpool/rmpool still run). docs/sysio-synd.md's redeploy-together set includes kicker too.

@valthon
valthon force-pushed the feature/sysio-kicker branch from 3e9529c to fb2eb15 Compare October 23, 2026 21:06

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fourth-pass review of the current head. I found one high-severity accounting issue and one low-severity runbook inconsistency; details are inline.

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);
const auto gift = kicker_math::gift_at_price(shadow.supply.amount, pool.rate_bps, elapsed, price);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Checkpoint eligibility before pricing the elapsed interval

This applies the current supply_amount (read above) to the entire historical elapsed interval. sysio.synd::onsynd checkpoints a new position at the live yield index so it earns only from that point forward, but a syndication immediately before a mature permissionless kick bypasses that protection: the newly minted LIQ is included in the whole prior interval, and addyield distributes the resulting gift across current balances. With the defaults, 100,000 LIQ after one day at price 1 should produce 5.475701574 WIRE; minting 18,162,500 LIQ immediately before kick raises the gift to the 1,000 WIRE cap and assigns about 994.524298425 WIRE to LIQ that did not exist during that day. With valid zero synd/desynd fees, the holder can later exit. This directly overdraws the treasury and also invalidates the documented (1 + deviation) * cap manipulation bound when supply changes between straddle kicks. Please checkpoint or time-weight eligible supply across mint/burn boundaries (or use an equivalent eligibility mechanism), and add regressions for mint-immediately-before-mature-kick and supply growth between straddle kicks; the current fixed-supply regression does not cover either case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in "Add sysio.kicker: budgeted T5 WIRE gifts to LIQ holders". kick_pool gains supply_snapshot, the LIQ supply at the pool's last checkpoint, and every gift is now sized on min(supply_snapshot, current supply):

  • addpool and every full payment checkpoint the current supply; a partial payment keeps the supply it priced, so the remainder cannot be re-sized on LIQ minted in between.
  • A zero supply, or a zero snapshot under a nonzero supply, only checkpoints.
  • A tiny snapshot cannot hold a grown supply unpaid forever: with the 72 h catch-up saturated and the snapshot's gift below min_gift, the kick forfeits the interval and checkpoints the current supply, but only when the same interval sized on the current supply at the same TWAP reaches min_gift. A price dip that sinks both gifts holds, as before.

Your scenario: mint_immediately_before_mature_kick pays 5.475701574 WIRE; the previous wasm paid the 1,000 WIRE cap. partial_unwind_straddle_with_mint_stays_within_bound covers supply growth between straddle kicks. Also new: partial_payment_keeps_priced_supply, zero_snapshot_checkpoints_then_pays, saturated_tiny_snapshot_resets_to_grown_supply, saturated_tiny_snapshot_without_growth_holds, price_dip_below_minimum_does_not_forfeit. The differential fuzz model carries the snapshot and compares it after every kick.

Two residuals are documented in EMISSIONS.md and the kick doc rather than closed, since closing them would mean per-holder or in-interval supply tracking in sysio.liq:

  • addyield still divides a correctly sized gift by the current supply, so new LIQ dilutes the earlier holders' share of it, as with every other LIQ yield source.
  • 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.

The treasury bound (never above the honest gift of the supply present at the last checkpoint) and the daily cap hold in both cases.

| `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` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Reconcile the depot freeze-scope summary with this kicker row

This new row correctly says sysio.kicker::kick returns without paying while Andon is pulled, but the paragraph immediately below still says the depot freeze covers only syndication and swap custody and that the other depot payouts are not frozen. Kicker is now another depot payout that is frozen, so an incident responder could reasonably infer the opposite behavior from the summary. Please include kicker gifts in the freeze-scope sentence, or narrow “other depot payouts” to the explicitly unaffected opreg/system/dclaim paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the freeze-scope summary in docs/emergency-stop-playbook.md now says the depot freeze covers syndication and swap custody and the sysio.kicker gifts, and that the depot payouts listed below it are not frozen.

@heifner heifner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One merge blocker, plus a doc gap.

Checked on this head merged with current master (a62f42c33d): contracts rebuilt from source match the committed kicker, swap, liq, system and epoch wasm and ABI byte for byte; test_batch_operator_plugin passes; the kicker, liq, swap and twap suites pass except the case below.

Comment thread contracts/tests/sysio.kicker_tests.cpp Outdated
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 + ceiling);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case fails once master is merged (the last CI run predates #658). It puts 100 WIRE of pending emission against a 5 WIRE budget. Since #658 the gate sizes the share on net headroom, so that state accrues 0, the control's advance is not blocked, the check at 269 fails and the blocklog decode then throws.

Giving the budget room for the pending amount fixes it with no wasm change (verified on the merged tree, including the BALANCE_INSUFFICIENT control):

  • 125'000'000 * UNIT + pending + ceiling here and at 259;
  • leave 2 * pending + claims + ceiling + available at 250;
  • expect 2 * pending + claims + ceiling at 279.

The doubled pending is because it now sits inside the gross headroom, so the guard holds it twice. Please merge master so CI runs on the current base.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as you described, with no wasm change: the budget is 125'000'000 * UNIT + pending + ceiling in both testers, the transfer leaves 2 * pending + claims + ceiling + available, and the final check expects 2 * pending + claims + ceiling, with a comment saying why pending is held twice. The branch is rebased on master 261d1ce68c, so CI now runs on the current base.

| `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. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kicker also reads sysio.system's emitcfg, t5state and payclaimtot and sysio.token accounts (listed at 130 for sysio.epoch only), plus swap stat/priceaccum and liq stat. The edge at 259 applies to it too: a new reservable treasury obligation in sysio.system needs a kicker redeploy, or kicks can draw it. Please list kicker in both places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: docs/contract-upgrade-order.md now says sysio.kicker::kick reads sysio.system's emitcfg, t5state and payclaimtot and sysio.token's accounts, plus sysio.swap's stat/priceaccum and sysio.liq's stat. The staged-rollout edge now reads "sysio.epoch and sysio.kicker before sysio.system" when sysio.system gains reservable state, since an old kicker would count it as available treasury.

valthon and others added 6 commits October 26, 2026 10:08
Move pair_key, currency_stats and price_accumulator out of sysio.swap's
private section into sysio.opp.common/swap_state.hpp, so another
contract can read a pair's reserves without copying the layout.
sysio.swap aliases its tables to the shared types; field order and
serialization are unchanged.

The rebuilt sysio.swap .wasm and .abi (empty CMAKE_BUILD_TYPE, the
committed flavour) are byte-identical to 5431f9b's under cmp
(wasm sha256 7c1ea94b1ff818262566c2fe1b12ddeae62a89de5b2394a29b3ccd591b2dff6d).

Co-Authored-By: Codex CLI (gpt via ChatGPT) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Change-Id: Iccbd479e480f9cfe1b0ed28fd64af9ea4b7a3e32
Move the sysio.liq test fixture out of sysio.liq_tests.cpp into its own
header so other suites can stand up LIQ pools the same way. A pure
move: the cases in sysio.liq_tests.cpp are unchanged.

Co-Authored-By: Codex CLI (gpt via ChatGPT) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Change-Id: Ifa3fe5f709b57b7e40f4eb64b389f04b6772a88a
kicker_math.hpp holds the dependency-free arithmetic of sysio.kicker.
interest() computes floor(S x rate_bps x dt / (10000 x one Julian
year)), splitting S x rate_bps into quotient and remainder so both
products stay below 2^128 for every asset, accepted rate and uint64
interval. gift_at_price() prices that interest at a Q64.64 WIRE-per-LIQ
rate with a two-limb product, flooring twice in the treasury's favour;
a result past asset::max_amount is a non-payable gift (nullopt), never a
wrapped or clipped amount. Simple accrual pays exactly the configured
annual rate for any call pattern; a compounded per-call form would
depend on how often it runs.

spot_deviates() tells whether spot strays from the window TWAP by more
than a basis-point share of the TWAP, comparing |spot - twap| x 10000
with twap x bps exactly in 256 bits; the threshold itself does not
deviate.

The header also fixes the contract's policy constants: the 200 bps and
one-WIRE defaults, the one-hour minimum interval, the one-day TWAP
window and its 60-second floor, the 72-hour catch-up bound, the 1,000
WIRE per-pool daily ceiling and the 5,000 bps spot-deviation default.

The host tests check it against an independent wide-integer oracle
(kicker_test_reference.hpp) with the year written as a literal
(31,557,600 s), so changing the year in the header under test fails
them.

Co-Authored-By: Codex CLI (gpt via ChatGPT) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Change-Id: I39c9501e4a492be0812a236f3b2893b1aca7228c
kick(sym) is permissionless. It pays each LIQ token's holders a
simple-accrual gift on the smaller of the LIQ supply at the pool's last
checkpoint (supply_snapshot) and the current stat.supply, drawn from the
sysio (T5) treasury and delivered through sysio.liq::addyield. Each LIQ
token has its own row, clock and rate (default 200 bps), so a new LIQ
token is configuration only (setconfig, addpool, setpool, rmpool; all
require sysio).

Pricing uses the registered LIQ/WIRE swap pool's cumulative-price TWAP,
never spot. The kicker snapshots swap's price accumulator on addpool and
after every payment, full or partial, extends it to the action time over
the unchanged-reserve tail, and pays only once the observation spans
kickcfg.twap_window_sec (default one day, at least 60 s). A window
change applies to the in-flight observation without resetting it. A
same-block trade has zero weight. Every payment restarts the
observation, so the history that priced one payment never prices
another.

A kick also pays only while the pool's spot is within
kickcfg.max_spot_deviation_bps (default 5,000 bps, settable 1 to 10,000)
of the window TWAP. Past it, the kick pays nothing, records
spot_deviation with the gift as the shortfall, and holds both the clock
and the observation; restarting the observation instead would let one
atomic trade delay payment by a full window. The guard bounds the
straddle: a pump raised in the block before a kick and unwound in the
block after it would otherwise put its area into two adjacent
observations and fund two capped payouts. With the guard the first
kick sees spot far above the TWAP and the second, after a full unwind,
prices at no more than spot / (1 - threshold). One price excursion
therefore funds one capped payout plus at most threshold x cap of
excess over the next payout's honest value (500 WIRE at the default);
a straddle whose first kick follows a partial unwind reaches that
bound.

The guard does not stop a one-block spike cashed in one block: a
partial unwind to near the inflated TWAP, the kick and the rest of the
unwind carry no TWAP weight, so one kick can still reach the daily cap.
That residual is accepted; governance sizes max_gift_per_day so that
(1 + threshold) x cap stays under the cheaper of holding the pool's
price for a window and such a spike. The
threshold is therefore a liveness and griefing knob: tighter holds
payment through more honest volatility and makes a delay cheaper,
whether by a one-block spike or by holding spot off-price; wider does
the reverse and loosens the straddle bound.

A payment covers at most the last 72 hours of accrual. It is bounded by
an absolute governance budget kept outside t5_distributable, by a
mandatory per-pool UTC-day ceiling of at most 1,000 WIRE, and by a
reserve that keeps pending emission, outstanding pay claims and the
remaining emission ceiling untouched, so a kick can never make an
epoch's readiness gate fail; missing emissions state permits no draw.
When less is available than the gift, it pays what is available if that
meets min_gift and advances the clock by the floored paid fraction; the
remainder keeps accruing and is priced by the next observation. Below
min_gift or while the andon cord is pulled the clock stays open; a zero
supply, or a zero snapshot under a nonzero supply, checkpoints (restarts
the clock at the current supply, clears the diagnostics) without paying;
a below-min_gift gift on a supply grown past the snapshot with the full
72 hours unpaid forfeits the interval and checkpoints the current supply
only when the same interval sized on the current supply at the same TWAP
reaches min_gift (or overflows), so a tiny snapshot cannot hold a grown
supply unpaid forever while a price dip that sinks both gifts holds (one
that leaves only the snapshot's gift below min_gift still forfeits).
shortfall_amount, shortfall_time, gift_overflow and spot_deviation
record the latest unpaid outcome for governance.

Sizing on the smaller supply means 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 (a
pool whose payments are all partial keeps its snapshot until one lands),
so a mint just before a mature kick earns nothing for the interval it
missed. addpool and every full payment checkpoint the current supply; a
partial payment keeps the supply it priced. addyield still shares each
gift across the current supply, so new LIQ dilutes the earlier holders'
share of a correctly sized gift (the accepted residual). Sizing also
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, within the same
treasury bound and daily cap.

The suite pins the arithmetic against an independent oracle and covers
warm-up, window changes, same-block and held trades, the spot guard's
inclusive threshold and configuration bounds, a pump straddling a
checkpoint across a UTC midnight, the bound on a straddle whose first
kick follows a partial unwind, a one-block spike cashed in one block at
a 1 bps threshold, paid in full under the cap and capped above it (the
accepted residual), a price spike that funds a capped partial payment
followed by an unwind, catch-up and daily limits, budget and reserve
shortfalls, overflow, zero supply, minting before a mature kick, a mint
between straddle kicks, zero-snapshot and saturated-snapshot
checkpoints, a price dip that does not forfeit, Andon holds,
authorization, and atomic rollback of the inline transfer. The committed
.wasm and .abi were built from this source by a standalone CDT contracts
build with an empty CMAKE_BUILD_TYPE (the toolchain's -Os), the flavour
CI tests; a host build with BUILD_SYSTEM_CONTRACTS=OFF only copies them.
EMISSIONS.md and the bootstrap config doc describe deployment, the
earmark arithmetic, the guard's trade-offs and the accepted one-block
shallow-pool risk, mitigated by registering deep pools and setting
conservative daily caps.

Co-Authored-By: Codex CLI (gpt via ChatGPT) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Change-Id: Ida5c10a92471dc518791fbffb1024ccf9655fc19
kick(sym) is permissionless and nothing on chain schedules it. Every
ACTIVE batch operator now pushes it, beside the tickyield and queueyield
cranks, and not gated on election: the elected operator may be the one
that is offline.

The crank runs as its own cron job, batch_operator_kick, beside the
epoch tick: each push can wait out the full delivery timeout, and a
serial loop of them inside the epoch tick would stall the epoch and
election polling the per-outpost jobs depend on. A push waits for its
result in short slices and is abandoned, with a warning rather than a
timeout error, once the node is shutting down.

Each poll reads kickcfg, one page of at most ten kickpools rows
(resuming the next page on the next poll) and, by one exact lookup per
token, the sysio.liq::stat row of each token on that page, so the page
bounds every read in a poll. It pushes kick(sym) only for a pool the
rows say could pay: min_interval_sec has passed since last_kick, the
token's LIQ supply is not zero (on a zero supply kick only restarts the
clock; a zero supply_snapshot under a nonzero supply stays due, since
that kick's checkpoint is where accrual starts), the price observation
spans twap_window_sec, and budget_remaining and the pool's unspent
UTC-day ceiling each cover min_gift. Every payment restarts the
observation, so a paid pool stays quiet for a window. Rows are read the
way the node's in-process table reader renders them: the uint128
spent_today column arrives as a uint128 variant, which as_uint64 cannot
read.

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, a broken swap pair) leaves
last_kick in place. The next attempt waits --batch-kick-interval-ms
(default 60000), and the wait doubles per unpaid attempt up to an hour,
restarting once last_kick moves: a day of unpayable kicks costs a few
dozen pushes per pool per operator rather than one a minute. The first
wait must be 1 ms to the one-hour ceiling; startup fails otherwise,
since a zero would never back off. A failed read ends the poll without
pushing; a failed page or supply read keeps the cursor on that page, and
retired symbols leave the retry state only after a complete sweep.

--batch-kick-crank (default on, like the other cranks) turns the crank
off per node, and the relay's permission then need not be linked for
sysio.kicker::kick: the required links are built from the enabled
cranks. The crank idles until sysio.kicker is deployed and before
setconfig.

The due-test, config parsing, the paged pass over the pool rows, the
retry backoff and the required-link list are pure functions in
yield_cranks.hpp and role_config.hpp. Their Boost cases decode rows
through the committed contract ABIs, the way the node renders them; the
option tests cover the new defaults. OVERVIEW.md lists the crank, and
EMISSIONS.md names the batch operators as the kick schedule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Change-Id: I8cf351ad10b18747aab97e5c866ed668ad9f65bf
Three cases, on two real LIQ pools sharing real treasury transfers and
addyield:

- kicker_fuzz_window_boundaries: observations one second below, at and
  above the minimum, default and two-day TWAP windows, each asserting
  payment exactly when the observation spans the window.
- kicker_fuzz_boundaries: deterministic fixtures, one per boundary (one
  below the minimum when treasury- or budget-bound, available and gift
  exactly at the minimum, cap == minimum within one UTC day, treasury
  saturation, reserve >= balance, overflow, zero supply after a
  shortfall, spacing one block short of and exactly at the interval, and
  a held kick that pays after release), each asserting its outcome
  directly.
- kicker_fuzz_sequences: 150 seeded sequences of 16 random kicks over
  supply, spot price, a window price equal to spot or a literal ratio of
  it (weighted toward ones inside the default guard), the spot-guard
  threshold, per-pool
  rate, minimum and cap, funding and reserve components (including
  distributed totals), call interval, TWAP window, pool choice and Andon
  holds.

After every kick the contract's tables and balances are compared to a
reference model whose arithmetic is independent (256-bit, literal
constants); its control flow follows the rulings in kick()'s order. The
model covers the clock, spending, diagnostics, the spot guard (an exact
256-bit |spot - TWAP| x 10000 > TWAP x threshold) and the price
observation, which every payment restarts at the cumulative price.
Outcome coverage floors (random-price full and partial payments,
sub-minimum gifts, unfunded, reserve- and cap-exhausted skips, holds
and spacing skips that would have paid, guard refusals, and payments at
a window price apart from spot) are set at about half the observed
counts. The first divergence fails with its seed, batch,
sequence and step; a sequence replays from the first sequence of its
batch, which shares one chain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Change-Id: I94803bd2a36d53f2d26302ab45c422066a6e3453
@valthon
valthon force-pushed the feature/sysio-kicker branch from fb2eb15 to 959454a Compare October 26, 2026 15:48
@valthon
valthon requested a review from heifner October 26, 2026 22:27
valthon added a commit to Wire-Network/wire-libraries-ts that referenced this pull request Oct 26, 2026
Regenerated SysioContractTypes.ts with
contracts/tools/generate-sysio-contract-types.py -P snake against
wire-sysio 959454a660 (Wire-Network/wire-sysio#675), then formatted with
the repo's Prettier config. The only change is the new sysio.kicker
surface: kick, setconfig, addpool, setpool, rmpool, and the kickcfg and
kickpools tables, plus its entries in SysioContractName,
SysioContractAccount, SysioContractMapping and SysioContractDefinitions.
kick_config carries max_spot_deviation_bps and kick_pool carries
spot_deviation and supply_snapshot (int64 after spent_today).

A client test checks that the typed proxy binds sysio.kicker, prepares
kick, and reads kickpools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex CLI (gpt via ChatGPT) <[email protected]>

@heifner heifner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 959454a. Both items from my earlier review are fixed: the reserve-guard case now uses a state consistent with #658, and contract-upgrade-order.md lists kicker's reads and the treasury-reserve edge. Checked on this head merged with master c3764aa: the kicker, swap, liq and system wasm/abi rebuild byte-identical, and the kicker, kicker fuzz, kicker math, liq, swap, twap and emissions suites plus test_batch_operator_plugin pass.

@valthon
valthon merged commit 7e48d87 into master Oct 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants