From 0309fc16c688fb6c6067bc4c6d7f918bece47355 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Tue, 7 Jul 2026 16:49:47 -0500 Subject: [PATCH 1/4] Initial Council election --- contracts/CMakeLists.txt | 1 + contracts/sysio.councl/CMakeLists.txt | 44 ++ contracts/sysio.councl/DESIGN.md | 374 +++++++++++ contracts/sysio.councl/README.md | 77 +++ .../include/sysio.councl/council_math.hpp | 91 +++ .../include/sysio.councl/sysio.councl.hpp | 278 +++++++++ contracts/sysio.councl/src/sysio.councl.cpp | 496 +++++++++++++++ contracts/sysio.councl/sysio.councl.abi | 584 ++++++++++++++++++ contracts/sysio.councl/sysio.councl.wasm | Bin 0 -> 31131 bytes contracts/tests/CMakeLists.txt | 1 + contracts/tests/contracts.hpp.in | 2 + contracts/tests/council_math_tests.cpp | 110 ++++ contracts/tests/sysio.councl_tests.cpp | 439 +++++++++++++ 13 files changed, 2497 insertions(+) create mode 100644 contracts/sysio.councl/CMakeLists.txt create mode 100644 contracts/sysio.councl/DESIGN.md create mode 100644 contracts/sysio.councl/README.md create mode 100644 contracts/sysio.councl/include/sysio.councl/council_math.hpp create mode 100644 contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp create mode 100644 contracts/sysio.councl/src/sysio.councl.cpp create mode 100644 contracts/sysio.councl/sysio.councl.abi create mode 100755 contracts/sysio.councl/sysio.councl.wasm create mode 100644 contracts/tests/council_math_tests.cpp create mode 100644 contracts/tests/sysio.councl_tests.cpp diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index 9ca1153f2e..803be67bf1 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -52,6 +52,7 @@ add_subdirectory(sysio.opreg) add_subdirectory(sysio.msgch) add_subdirectory(sysio.uwrit) add_subdirectory(sysio.chalg) +add_subdirectory(sysio.councl) add_subdirectory(sysio.reserv) add_subdirectory(sysio.dclaim) diff --git a/contracts/sysio.councl/CMakeLists.txt b/contracts/sysio.councl/CMakeLists.txt new file mode 100644 index 0000000000..187c8a39ed --- /dev/null +++ b/contracts/sysio.councl/CMakeLists.txt @@ -0,0 +1,44 @@ +set(contract_name sysio.councl) +bootstrap_contract(${contract_name}) + +if(BUILD_SYSTEM_CONTRACTS) + find_cdt_magic_enum() + file(GLOB_RECURSE SOURCES src/*.cpp) + file(GLOB_RECURSE HEADERS include/*.hpp) + add_contract(${contract_name} ${contract_name} ${SOURCES}) + set(targets ${contract_name}) + + if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) + list(APPEND targets ${contract_name}_native) + add_native_contract( + TARGET ${contract_name}_native + SOURCES ${SOURCES} + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include + CONTRACT_CLASS "sysio::council" + HEADERS ${HEADERS} + ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi + ) + endif() + + foreach(target ${targets}) + if(NOT TARGET ${target}) + message(WARNING "Target ${target} not found, skipping include directory setup") + continue() + endif() + + target_include_directories(${target} + PUBLIC + $ + $ + $ + $ + $ + $ + ) + + target_link_libraries(${target} + INTERFACE + magic_enum::magic_enum + ) + endforeach() +endif() diff --git a/contracts/sysio.councl/DESIGN.md b/contracts/sysio.councl/DESIGN.md new file mode 100644 index 0000000000..c6f42f0454 --- /dev/null +++ b/contracts/sysio.councl/DESIGN.md @@ -0,0 +1,374 @@ +# sysio.councl — Design & Implementation Plan (v1) + +> Status: **confirmed design.** Supersedes the discarded `multi_index` prototype and the v0 +> draft. Every decision below is **[CONFIRMED]** with the maintainer unless explicitly tagged +> **[GROUNDED]** (dictated by existing on-chain code) or **[NOTE]** (a consequence to be aware of). + +## 1. Purpose + +Fill a **21-seat council**. There is one seat per **tier-1 node owner** (from `sysio.roa`). +Each seat is filled by electing one **candidate** from a shared candidate pool. The right to +*propose* a seat's slate of 3 candidates starts with that seat's tier-1 owner and **escalates** +through tier-2 and then tier-3 node owners if the earlier tier fails to elect anyone — so every +seat is guaranteed to fill. When all 21 seats are filled the election is complete and the +council is set. + +Per seat, the flow is: + +1. The seat's **tier-1 owner** proposes a slate of 3 candidates (`repcandidate`); the other 20 + tier-1 owners vote; strict-priority resolution (§6) either elects a candidate or fails. +2. On failure, one **tier-2** account is chosen pseudo-randomly (§5) and gets the same + opportunity, voted on by all other tier-2 accounts. +3. On failure, one **tier-3** account is chosen, voted on by all other tier-3 accounts; if that + fails, a *new* tier-3 account is chosen and it repeats. +4. If tier-3 is exhausted without a winner, a governance backstop fills the seat (§7). + +## 2. Grounding in existing contracts [GROUNDED] + +- **Tier source of truth: `sysio.roa`.** `nodeowners` is a public KV `scoped_table` scoped by + `network_gen`, indexed `bytier`, row = `{owner, tier(1/2/3), ...}`. No registration order or + timestamp is stored — only `owner.value` is a free deterministic key. +- **Read precedent: `sysio.chalg`.** `roa::roastate_t(ROA_ACCOUNT)` → `network_gen`, then + `roa::nodeowners_t(ROA_ACCOUNT, network_gen)`; point-lookup a voter and check + `tier == NODE_OWNER_TIER_T1`; iterate the `bytier` index to enumerate a tier. We reuse this. +- **Tier counts / caps: `sysio.system`.** Owns a `nodecount` KV global (`t1_count`, `t2_count`, + `t3_count`); caps are `T1_MAX = 21`, `T2_MAX = 84`, `T3_MAX = 1000`. Used at init as a + completeness cross-check for the tier snapshots (§9). +- **Block entropy is limited [GROUNDED].** An ordinary action can call + `current_block_number()` and `current_time_point()` and `get_active_producers()`, but **cannot + read a block id/hash** — `sysio.system`'s `blockinfo` table stores only `{version, + block_height, block_timestamp}`, never `previous_block_id`. This is why randomness uses an + in-contract entropy accumulator (§5), not a block hash. +- **Table engine: KV** (`kv::table`, `kv::scoped_table`, `kv::global`), not `multi_index`. Enum + handling via the protobuf `NodeOwnerTier` and `magic_enum` per repo rules; hashing via the + `sysio::sha256` intrinsic (deterministic, no FP). + +## 3. Confirmed decisions + +| # | Decision | Choice | +|---|----------|--------| +| 1 | Slot/seat ordering | `init` receives explicit `ordered_owners[21]`; asserted to be a permutation of exactly the roa tier-1 set. | +| 2 | Resolution | **Strict priority** over the 3 candidates (§6), not first-past-the-post. | +| 3 | Timing | Event-driven; single `time_slot_sec` is both the propose-deadline and the voting window, per attempt. | +| 4 | Tier-1 membership | Snapshot the 21 owners + order at init; ignore later roa churn; fail init if `t1 != 21`. | +| 5 | Seat outcome | **Every seat fills** via the T1→T2→T3→governance escalation ladder (§7). No empty seats. | +| 6 | Settlement | Lazy settle at top of every action + a permissionless `settle` action (no on-chain timers). | +| 7 | Candidate exclusivity | An elected candidate leaves the pool; losers may be re-proposed. Floor `MIN_CANDIDATES = 23`. | +| 8 | Candidate registration | Self-register (`require_auth(account)`) before init; `init` closes registration and asserts `>= 23`. | +| 9 | Randomness | In-contract **entropy accumulator**, **Variant B** (block number excluded from the stir), §5. | +| 10 | Proposer auto-yes | Tier-2/3 proposer's auto-yes counts for **all three** slate candidates. Tier-1 has no auto-yes. | +| 11 | Tier-2/3 selection set | **Full ordered tier-2 and tier-3 owner lists frozen at init**; nth-pick indexes those. | +| 12 | Admin auth / re-runs | `init*` = `require_auth(get_self())`; all tables scoped by `election_gen`. | + +## 4. Constants + +```cpp +namespace councl { + inline constexpr uint8_t SEATS = 21; // tier-1 owners == council seats + inline constexpr uint8_t T1_VOTERS = 20; // SEATS - 1 (seat owner never votes on own seat) + inline constexpr uint8_t SLATE_SIZE = 3; // candidates per repcandidate + inline constexpr uint8_t MIN_CANDIDATES = SEATS + 2; // 23: <=20 elected before the last seat, +3 available + inline constexpr size_t MAX_HANDLE_LEN = 32; // twitter handle cap + constexpr auto ROA_ACCOUNT = "sysio.roa"_n; + constexpr auto SYSTEM_ACCOUNT = "sysio"_n; // owner of sysio.system tables (nodecount) + + // Thresholds are computed per round from the electorate size N (never hard-typed): + // win(N) = floor(2N/3) + 1 == (2*N)/3 + 1 // YES needed to elect + // elim(N) = ceil(N/3) == N - (2*N)/3 // NO that makes a candidate impossible + // Duals: win(N) + (elim(N) - 1) == N. Examples: + // N=20 -> win 14, elim 7 (tier-1: "14 of the other 20", "7 no kills a candidate") + // N=84 -> win 57, elim 28 (tier-2 at cap) + constexpr uint64_t win_threshold (uint64_t n) { return (2*n)/3 + 1; } + constexpr uint64_t elim_threshold(uint64_t n) { return n - (2*n)/3; } +} +``` + +## 5. Randomness — entropy accumulator (Variant B) [CONFIRMED] + +A rolling hash of contract activity supplies the pseudo-random seed for tier-2/3 selection. It +is fully deterministic (all nodes replay the same actions in the same order) and needs no block +hash. + +```cpp +// in `state` (KV global) +checksum256 acc; // entropy accumulator +uint64_t stir_count; // monotonic; guarantees acc advances even for identical (tag,actor) + +// called at the TOP of EVERY action handler (init*, addcandidate, rmcandidate, repcandidate, +// vote, settle, stir, forceassign). Variant B: block_number is intentionally NOT folded in. +void stir(name action_tag, name actor) { + acc = sysio::sha256(pack(acc, action_tag, actor, ++stir_count)); +} + +// pure, unit-testable +uint64_t seed_u64(const checksum256& s); // fold 256->64 bits (first 8 bytes, LE) +uint64_t select_index(const checksum256& acc, // deterministic index into [0, m) + uint64_t seat, uint64_t round_id, uint64_t m) { + return seed_u64(sysio::sha256(pack(acc, seat, round_id))) % m; // m = available (untried) count +} +``` + +- **`seat` + `round_id` are folded into the selection seed**, so two re-selections that land in + the same block (e.g. successive tier-3 retries) still get distinct seeds. `round_id` is a + globally monotonic attempt counter (§8) and doubles as the "attempt number." +- **Selection over the untried set:** `m = tierN_size - tried_count`; walk the frozen tier list + skipping tried entries to the `select_index`-th survivor. [NOTE] tier-3 can be up to 1000, so + this walk is O(tier size) KV reads — acceptable because it happens only on a tier-3 escalation, + not on the hot path. + +### Security / grinding [NOTE] +Variant B removes the cheapest manipulation (free per-block resampling by choosing which block to +call `settle` in): with `block_number` out of the stir, a caller must *inject an extra stirring +transaction* to change `acc`, which is visible, costs CPU/RAM, and can be perturbed by any other +participant's action. This is **not** manipulation-proof — true unpredictability against the +party controlling the trigger needs a future-block VRF/beacon this chain doesn't expose. For a +council of node owners the documented accumulator is the accepted trade-off. The permissionless +`stir` action lets honest participants add entropy and advance liveness. + +## 6. Strict-priority resolution [CONFIRMED] + +One round has a proposer, an electorate size `N`, a slate `c[0..2]`, and per-candidate `yes[i]` +and `no[i]` tallies. `T = win_threshold(N)`, `E = elim_threshold(N)`. + +- **One ballot per voter**, carrying an independent yes/no for **each** of the 3 candidates + (needed because per-candidate `no` drives elimination). A voter never re-votes as priority + advances. +- **Tier-2/3 only:** the proposer's auto-yes seeds `yes[i] = 1` for **all three** candidates at + round start (they advocated the whole slate). **Tier-1:** no auto-yes; the seat owner is not a + voter. +- A candidate is **eliminated** when `no[i] >= E` (it can no longer reach `T`). +- The **active** candidate is the lowest index `i` with `no[i] < E`. + +Resolution, re-evaluated after every `vote` and inside `settle`: + +- **WIN** the round for `c[active]` as soon as `yes[active] >= T`. (Because priority is strict, a + higher-index candidate at/above `T` cannot win while a lower-index one is still alive; it only + becomes eligible the instant the lower one is eliminated, which this same check catches.) +- **FAIL** the round the moment **all three** are eliminated. +- Otherwise **pending** until `now >= vote_deadline` or every eligible voter has voted; then WIN + if `yes[active] >= T`, else FAIL. + +Electorate sizes / voter sets: + +| Tier | `N` | Eligible voters | Auto-yes | +|------|-----|-----------------|----------| +| 1 | `T1_VOTERS = 20` | the other 20 tier-1 owners (`roster` minus the seat owner) | none | +| 2 | `n2` (tier-2 snapshot size) | all tier-2 owners except the proposer (`n2 - 1`) | proposer, all 3 | +| 3 | `n3` (tier-3 snapshot size) | all tier-3 owners except the proposer (`n3 - 1`) | proposer, all 3 | + +[NOTE] Degenerate tiers: if a tier's snapshot size is `0`, that tier is **skipped** during +escalation (you can't run a round with no electorate). If `n == 1`, `T = win(1) = 1` and the lone +proposer's auto-yes wins instantly — acceptable and bounded. + +## 7. Escalation ladder (per seat) + +For seat `k` (owner `roster[k]`), attempts run until one elects a candidate: + +``` +T1 attempt: proposer = roster[k] (exactly 1 attempt) + └ fail ─> T2 attempt: proposer = random tier-2 acct (exactly 1 attempt) + └ fail ─> T3 loop: proposer = random tier-3 acct (repeat, excluding already-tried + this seat, until WIN or tier-3 exhausted) + └ tier-3 exhausted ─> BACKSTOP (governance forceassign) +``` + +- An **attempt fails** if the proposer does not `repcandidate` within `time_slot_sec` + (propose-deadline), or the voting round FAILS (§6). +- **T1 and T2 get exactly one attempt each.** **T3 loops**, choosing a fresh untried tier-3 + account (via §5) each time. +- **Exclusion:** a tier-3 account tried for seat `k` is not chosen again for seat `k` (tracked in + `tried3`, scoped per seat). Guarantees the T3 loop makes progress and terminates. +- **Backstop:** when every tier-3 account has been tried for seat `k` with no winner (or tier-3 is + empty and T2 failed), the seat enters `BACKSTOP`; governance calls `forceassign(member)` to seat + an un-elected candidate. This is the termination guarantee for a seat whose electorate simply + never produces a super-majority. +- On **WIN** (or `forceassign`): mark the candidate `elected`, write the `council` row, advance to + seat `k+1`. When `k+1 == SEATS`, the election is `DONE`. + +## 8. Tables (KV), all scoped by `election_gen` + +- **`config`** (`kv::global`): `initialized`, `reg_open`, `phase_init {REG, LOADING, READY}`, + `time_slot_sec`, `network_gen`, `election_gen`, `start_ts`, `n2`, `n3`, and load cursors + (`t2_loaded`, `t3_loaded`). +- **`state`** (`kv::global`): the live election cursor — + `active_seat` (0..20), `tier {1,2,3}`, `phase {AWAIT_REP, VOTING, BACKSTOP, DONE}`, + `proposer`, `round_id` (monotonic attempt counter), `round_open_ts`, `rep_ts`, `vote_deadline`, + `elect_N`, `votes_cast`, `seats_filled`, the current slate `c[3]`, `yes[3]`, `no[3]`, + and the entropy fields `acc`, `stir_count`. +- **`roster`** (`kv::table`, key = seat index → `{owner}`): frozen ordered tier-1 (21). +- **`tier2`** (`kv::table`, key = index → `{owner}`): frozen ordered tier-2 snapshot. +- **`tier3`** (`kv::table`, key = index → `{owner}`): frozen ordered tier-3 snapshot. +- **`candidates`** (`kv::table`, key = `account.value` → `{account, handle, elected}`). +- **`ballots`** (`kv::scoped_table`, scope = `round_id`, key = `voter.value` → `{voter, v[3]}`): + one row per voter per attempt; existence enforces single-vote; scoping by `round_id` keeps + retries isolated. +- **`tried3`** (`kv::scoped_table`, scope = `active_seat`, key = tier-3 index → `{}`): tier-3 + accounts already attempted for this seat. +- **`council`** (`kv::table`, key = seat index → `{seat_owner, filled_tier, proposer, member}`): + the 21 outputs (`seat_owner = roster[k]`, `proposer`/`filled_tier` record who actually + succeeded, `member` is the elected candidate). + +## 9. Actions + +### Registration (phase_init == REG) +- **`addcandidate(name account, string handle)`** — `require_auth(account)`. `reg_open`. + `handle.size() <= MAX_HANDLE_LEN`. Insert if absent (else "already a candidate"). +- **`rmcandidate(name account)`** — `require_auth(get_self())`. `reg_open`, candidate not yet + frozen. + +### Init (multi-step, to bound per-transaction work) [NOTE] +Tier-3 can hold up to 1000 rows, too many to read+write in one transaction, so init is staged: +- **`startinit(uint64 time_slot_sec, std::vector ordered_owners)`** — + `require_auth(get_self())`. + 1. `check(!config.initialized || state.phase == DONE)` (re-run bumps `election_gen`, wipes gen scope). + 2. `check(time_slot_sec > 0)` and `check(count(candidates) >= MIN_CANDIDATES)`. + 3. Read `roa::roastate` → `network_gen`. Enumerate roa tier-1 via `bytier`; `check(size == 21)` + and `check(ordered_owners` is a permutation of that set`)`. Freeze `roster[0..20]`. + 4. `phase_init = LOADING`; reset load cursors; `reg_open = false`. +- **`loadtier(uint8 tier, uint32 max_rows)`** — `require_auth(get_self())`, `LOADING` only. + Appends up to `max_rows` of roa's tier-2 or tier-3 owners (resuming from the cursor) into the + `tier2`/`tier3` snapshot in `bytier` order. Idempotent; call repeatedly until the cursor + reaches the tier's end. +- **`finalizeinit()`** — `require_auth(get_self())`, `LOADING` only. + 1. `check` the tier-2/tier-3 snapshots are complete vs `sysio.system::nodecount` + (`n2 == t2_count`, `n3 == t3_count`) — the completeness cross-check. + 2. Initialize `state`: `active_seat = 0`, `tier = 1`, `phase = AWAIT_REP`, + `proposer = roster[0]`, `round_id = 0`, `round_open_ts = now`, `acc = sha256(election_gen)`, + `seats_filled = 0`. `phase_init = READY`, `initialized = true`. + +### Election +- **`repcandidate(name proposer, name c1, name c2, name c3)`** — `require_auth(proposer)`. + `stir("repcandidate", proposer)`, then `settle_if_elapsed()`. + `check(phase == AWAIT_REP && proposer == state.proposer)`; + `check(now <= round_open_ts + time_slot_sec)` (propose-deadline); + validate slate (3 distinct, each exists, each `!elected`). + Open the round: set `c[]`, `no[] = {0,0,0}`, `yes[] = tier==1 ? {0,0,0} : {1,1,1}` (auto-yes), + `elect_N`, `votes_cast = 0`, `phase = VOTING`, `rep_ts = now`, + `vote_deadline = now + time_slot_sec`. Immediately `try_resolve()` (covers `n==1`). +- **`vote(name voter, bool v1, bool v2, bool v3)`** — `require_auth(voter)`. + `stir("vote", voter)`, then `settle_if_elapsed()`. `check(phase == VOTING)`; + `check(voter` is eligible for the current tier and `!= proposer)`; + `check(no ballot exists for voter in round_id)`. Record ballot; for each true `v`, `++yes[i]`; + for each false, `++no[i]`; `++votes_cast`. `try_resolve()`. +- **`settle()`** — no auth (permissionless). `stir("settle", get_self())` then + `settle_if_elapsed()`. Lets anyone push a timed-out attempt forward and inject entropy. +- **`forceassign(name member)`** — `require_auth(get_self())`, `phase == BACKSTOP` only. `member` + must be an un-elected candidate. Fills the seat and advances. + +### Internal helpers (callable from multiple actions per the "many states" requirement) +- **`settle_if_elapsed()`**: if `phase == AWAIT_REP && now > round_open_ts + time_slot_sec` → + `fail_attempt()`. If `phase == VOTING` → `try_resolve()` (which no-ops unless a resolve + condition is met). +- **`try_resolve()`**: apply §6. WIN → `win_attempt(c[active])`. FAIL (all eliminated, or deadline + reached / all voted without `yes[active] >= T`) → `fail_attempt()`. Else return (pending). +- **`win_attempt(candidate)`**: mark `elected`; write `council[active_seat]`; `++seats_filled`; + `advance_seat()`. +- **`fail_attempt()`**: escalate per §7 — + T1→T2 (select), T2→T3 (select), T3→next untried T3 (select) or `BACKSTOP` if exhausted; skip + empty tiers. Each new attempt: `++round_id`, `phase = AWAIT_REP`, `round_open_ts = now`, + set `proposer`, `elect_N`, clear slate/tallies. +- **`select_proposer(tier)`**: uses §5 over the untried members of `tier2`/`tier3`; records the + pick in `tried3` for tier-3. +- **`advance_seat()`**: `++active_seat`; if `== SEATS` → `phase = DONE`; else start a fresh T1 + attempt for `roster[active_seat]` (`tier = 1`, `++round_id`, `AWAIT_REP`, timers reset). + +## 10. State machine + +``` +REG ──startinit──> LOADING ──loadtier*──> finalizeinit ──> READY + │ + ┌──────────────── seat k ────────────────┘ + ▼ + ┌────────> [AWAIT_REP] ──repcandidate──> [VOTING] ──try_resolve──┐ + │ (propose-deadline miss: settle → fail_attempt) │ + │ │ + │ fail_attempt escalates tier: WIN ──win_attempt──> advance_seat + │ T1→T2→T3(loop, exclude tried)→BACKSTOP │ │ + └──────────────────── (next attempt) ────────────┘ active_seat==21? + BACKSTOP ──forceassign──> win_attempt │ yes → DONE + └─ no → seat k+1 (AWAIT_REP) +``` + +All timing is relative: an attempt's clock starts at `round_open_ts` (the moment the prior +attempt/seat resolved). Nothing is on an absolute `init + k*slot` schedule, so skips and early +resolutions compound forward naturally. + +## 11. Invariants, determinism, termination + +- **Termination:** each seat runs T1 (1) + T2 (1) + T3 (≤ `n3`, strictly shrinking untried set) + + an unconditional governance `BACKSTOP`. So a seat always resolves in bounded steps, and the + election always completes exactly `SEATS` seats. No unbounded loop. +- **Exactly one candidate per seat**, and a candidate occupies at most one seat (`elected` gate at + `repcandidate` + set on win). `MIN_CANDIDATES = 23` guarantees ≥ 3 un-elected candidates remain + for the last seat (≤ 20 elected before it). +- **One ballot per voter per attempt** (row existence in `ballots` scoped by `round_id`); the + proposer is never a voter; retries are isolated by `round_id` scope. +- **Determinism / consensus safety:** integer arithmetic only; time via + `current_time_point().sec_since_epoch()`; hashing via `sysio::sha256`; no floats, no UB, no + block-hash dependence. `acc` evolves identically on every replaying node (Variant B excludes + the only non-replay-safe temptation, and it never reads a block id). +- **Cross-contract reads** are read-only point/index lookups against roa's and sysio.system's + public KV tables. The snapshots at init make the running election immune to mid-election roa + churn. +- **Enum discipline:** tier comparisons use `opp::types::NodeOwnerTier` + `magic_enum` / + `NodeOwnerTier_Name`, never raw `int`, per repo rules. + +## 12. Test plan + +Split by binary per CLAUDE.md; the seed math is deliberately isolated for cheap maintenance. + +**Pure unit tests (fast, in `test_fc` or a contract unit target) — the maintainable seed layer:** +- `win_threshold`/`elim_threshold`: table of `N ∈ {1,2,3,20,21,84,1000}` → expected `(T,E)`; + assert the dual `T + (E-1) == N` for all `N` in a range (property test). +- `seed_u64` / `select_index`: **property tests** (output always in `[0,m)`; deterministic for + identical inputs; changes when `seat`/`round_id`/`acc` change — avalanche) **plus a small, + regeneratable golden-vector table** (≤ ~10 rows). Because the seed formula "will be tweaked," + keep exact-value assertions ONLY in this golden table (one command to regenerate); everywhere + else assert *properties*, so a formula change doesn't cascade edits. +- Strict-priority resolver as a pure function over `(N, yes[], no[], votes_cast, deadline_hit)`: + candidate-1-wins-outright; candidate-2-only-after-1-eliminated; candidate-3 path; + all-eliminated fail; deadline/all-voted fail; `n==1` auto-win; auto-yes seeding for tier-2/3. + +**On-chain integration tests (contract test harness):** +- Registration bounds (`MAX_HANDLE_LEN`, dup, auth, `< 23` fails init). +- Staged init: `startinit` permutation/`t1==21` checks; `loadtier` batching + resume; `finalizeinit` + completeness cross-check vs `nodecount`. +- Tier-1 happy path (14 yes → seat filled; strict priority: 1 wins despite 2 having more yes). +- Elimination: candidate 1 gets 7 no → candidate 2 becomes active and wins. +- Early termination: all 20 voted; deadline path via `settle`. +- Propose-deadline miss → escalate to T2. +- T2 flow with auto-yes and `n2`-based threshold; T2 fail → T3. +- T3 loop with `tried3` exclusion; distinct proposers across retries (seed uses `seat`+`round_id`). +- T3 exhaustion → `BACKSTOP` → `forceassign` fills seat. +- Empty-tier skip (`n2==0` and/or `n3==0`). +- Full 21-seat run to `DONE`; re-run bumps `election_gen` and isolates state. +- Determinism spot-check: same action sequence in a replay yields identical selections. + +## 13. Implementation plan + +1. Scaffold `contracts/sysio.councl/`: `include/sysio.councl/sysio.councl.hpp`, + `src/sysio.councl.cpp`, `CMakeLists.txt`; add the subdir to `contracts/CMakeLists.txt` + (currently absent) and register in `contracts_project`. +2. Constants + `win/elim_threshold` + the pure seed/resolver helpers (header-only, so the unit + tests link them without the WASM). +3. KV table structs (§8) and the roa/sysio.system cross-contract read helpers (mirror + `sysio.chalg`). +4. Actions in dependency order: `addcandidate`/`rmcandidate` → `startinit`/`loadtier`/ + `finalizeinit` → `repcandidate` → `vote` → `settle` → `forceassign`, with the internal + `settle_if_elapsed`/`try_resolve`/`win_attempt`/`fail_attempt`/`select_proposer`/`advance_seat`. +5. Ricardian contracts for every action under `ricardian/`. +6. Tests per §12; wire into `contracts/tests/`. +7. Rewrite `README.md` (the current one is a v0 placeholder describing the old flow) to match this + design. +8. Build via `contracts_project`, copy `.wasm`/`.abi` back to the source tree per CLAUDE.md, and + regenerate client types. + +## 14. Notes deferred / minor + +- **Candidate ↔ owner overlap:** a tier-1/2/3 node owner may also be a registered candidate; no + special rule. (An account has exactly one roa tier, so a proposer is never in the electorate of + its own round anyway.) +- **`forceassign` scope:** governance-only escape hatch; expected to be rarely if ever used. Its + existence is the formal termination guarantee, not the normal path. +- **`time_slot_sec` reuse:** one parameter serves both the propose-deadline and the voting window + for every attempt at every tier. Split into two params only if operational experience wants it. diff --git a/contracts/sysio.councl/README.md b/contracts/sysio.councl/README.md new file mode 100644 index 0000000000..1dc3f0635c --- /dev/null +++ b/contracts/sysio.councl/README.md @@ -0,0 +1,77 @@ +# sysio.councl + +Council election contract. Fills 21 council seats — one per tier-1 node owner — by electing a +candidate from a shared pool. The right to *propose* a seat's slate of 3 candidates starts with +that seat's tier-1 owner and **escalates** through tier-2 and tier-3 node owners if the earlier +tier fails to elect anyone, so every seat is guaranteed to fill. See +[DESIGN.md](DESIGN.md) for the full model and rationale. + +> Implemented against the modern KV table stack (`kv::table` / `kv::scoped_table` / `kv::global`) +> and the `sysio.roa` / `sysio.system` cross-contract read pattern used by `sysio.chalg`. The pure +> election arithmetic lives in a dependency-free header +> ([`council_math.hpp`](include/sysio.councl/council_math.hpp)) and is unit-tested host-side in +> [`contracts/tests/council_math_tests.cpp`](../tests/council_math_tests.cpp). + +## Responsibility + +- Registers council **candidates** (a sysio account + a short handle). +- Snapshots the tiered **node-owner** sets from `sysio.roa::nodeowners` (the tier-1 roster in a + governance-chosen order; the full tier-2 and tier-3 sets for escalation). +- Runs, per seat, a **strict-priority slate vote**: candidates are considered in submission order; + candidate *i+1* is only considered once candidate *i* is mathematically eliminated. +- **Escalates** a failed seat T1 → T2 → T3, choosing tier-2/3 proposers pseudo-randomly from an + in-contract entropy accumulator, and falls back to a governance assignment if tier-3 is exhausted. + +## Election rules + +| Quantity | Rule | +|----------|------| +| Win threshold | `floor(N·2/3) + 1` YES, where `N` is the tier's electorate size | +| Elimination | a candidate is out at `ceil(N/3)` NO (can no longer reach the threshold) | +| Tier-1 | `N = 20` (the other tier-1 owners); the seat owner does not vote, no auto-yes | +| Tier-2 / Tier-3 | `N =` full tier size; the proposer auto-yes counts for all 3 candidates; all other tier members vote | +| Timing | one `time_slot_sec` per attempt for both the nomination window and the voting window | +| Randomness | SHA-256 entropy accumulator over contract activity (block number excluded — "Variant B"); folds in seat + round for collision-free retries | + +## Actions + +| Action | Auth | Purpose | +|--------|------|---------| +| `addcandidate(account, handle)` | `account` | Self-register as a candidate (registration phase). | +| `rmcandidate(account)` | contract | Remove a candidate before init. | +| `startinit(time_slot_sec, ordered_owners[21])` | contract | Freeze the tier-1 roster; close registration. | +| `loadtier(tier, max_rows)` | contract | Batch-load the tier-2/3 snapshot from roa (resumable). | +| `finalizeinit()` | contract | Verify snapshots vs `sysio.system::nodecount`; open seat 0. | +| `reset()` | contract | After DONE: bump the generation and reopen registration. | +| `repcandidate(proposer, c1, c2, c3)` | `proposer` | The active proposer nominates a 3-candidate slate. | +| `vote(voter, v1, v2, v3)` | `voter` | Independent yes/no on each slate candidate. | +| `settle()` | anyone | Push a timed-out attempt forward; stir entropy. | +| `forceassign(member)` | contract | Governance backstop when tier-3 is exhausted. | +| `stir()` | anyone | Advance the entropy accumulator. | + +## Tables (KV) + +| Table | Type | Scope | Contents | +|-------|------|-------|----------| +| `config` | global | — | init progress, `time_slot_sec`, generation, tier sizes, load cursors | +| `state` | global | — | live cursor (seat/tier/phase/proposer), current slate + tallies, entropy accumulator | +| `candidates` | scoped | generation | `account`, `handle`, `elected` | +| `roster` / `tier2` / `tier3` | scoped | generation | frozen ordered node-owner snapshots (by-owner secondary index) | +| `ballots` | scoped | (generation, round) | one row per voter per attempt | +| `tried3` | scoped | (generation, seat) | tier-3 proposers already attempted for a seat | +| `council` | scoped | generation | the 21 filled seats (owner, tier, proposer, member) | + +## Lifecycle + +`addcandidate*` → `startinit` → `loadtier*` → `finalizeinit` → per seat +`repcandidate` + `vote*` (with `settle` cranks) escalating T1→T2→T3→`forceassign` as needed → +all 21 seats filled → `DONE` → optional `reset` for a new generation. + +## Build / status + +Compiled with the Wire CDT toolchain; `sysio.councl.wasm` / `sysio.councl.abi` are committed +alongside the source (like every other system contract), so `BUILD_SYSTEM_CONTRACTS=OFF` builds +consume the prebuilt artifacts. Rebuild the artifacts with `BUILD_SYSTEM_CONTRACTS=ON` (targeting +the Wire CDT), then copy `.wasm`/`.abi` back to this directory and regenerate client types per the +root `CLAUDE.md`. Whenever the contract changes, regenerate the affected reference data as noted in +`CLAUDE.md` (system-contract WASM changes shift action merkle roots). diff --git a/contracts/sysio.councl/include/sysio.councl/council_math.hpp b/contracts/sysio.councl/include/sysio.councl/council_math.hpp new file mode 100644 index 0000000000..972057d401 --- /dev/null +++ b/contracts/sysio.councl/include/sysio.councl/council_math.hpp @@ -0,0 +1,91 @@ +#pragma once + +/** + * @file council_math.hpp + * @brief Pure, dependency-free election arithmetic for sysio.councl. + * + * Everything here is a `constexpr`/`inline` free function over plain integers and + * `std::array`, with **no CDT / KV / chain dependencies**, so it can be unit-tested + * host-side without a WASM build. The intentionally-tweakable randomness lives at the + * seed-derivation boundary (`seed_u64` / `bounded_index`); keep exact-value assertions + * for those in a small regeneratable golden table and assert *properties* everywhere else. + * + * See DESIGN.md §5 (randomness) and §6 (strict-priority resolution). + */ + +#include +#include +#include + +namespace sysio::councl_math { + +/// YES votes needed to elect, for an electorate of size `n`: floor(2n/3) + 1. +inline constexpr uint64_t win_threshold(uint64_t n) { return (2 * n) / 3 + 1; } + +/// NO votes that make a candidate impossible to elect (ceil(n/3)). +/// Dual of win_threshold: `win_threshold(n) + (elim_threshold(n) - 1) == n`. +inline constexpr uint64_t elim_threshold(uint64_t n) { return n - (2 * n) / 3; } + +/// Outcome of evaluating one voting round. +enum class round_result : uint8_t { + PENDING = 0, ///< keep collecting votes + WIN = 1, ///< `winner_index` (0..2) has reached the win threshold + FAIL = 2 ///< no candidate can win this round -> escalate +}; + +struct resolution { + round_result result; + uint8_t winner_index; ///< only meaningful when result == WIN +}; + +/** + * @brief Strict-priority resolution over a 3-candidate slate. + * + * Candidate `i` is *eliminated* once `no[i] >= elim_threshold(N)`. The *active* candidate is + * the lowest index that is not eliminated. A round is WON the instant the active candidate + * reaches `win_threshold(N)` — a higher-index candidate can never win while a lower-index one + * is still alive. A round FAILS when all three are eliminated, or when voting has closed + * (`all_voted` or `deadline_hit`) with the active candidate still short of the threshold. + * + * @param yes per-candidate YES tallies (includes tier-2/3 proposer auto-yes seeding) + * @param no per-candidate NO tallies + * @param N electorate size for this tier + * @param all_voted every eligible voter has cast a ballot + * @param deadline_hit the voting window has elapsed + */ +inline resolution resolve(const std::array& yes, + const std::array& no, + uint64_t N, + bool all_voted, + bool deadline_hit) { + const uint64_t T = win_threshold(N); + const uint64_t E = elim_threshold(N); + + int active = -1; + for (int i = 0; i < 3; ++i) { + if (no[i] < E) { active = i; break; } + } + if (active < 0) + return {round_result::FAIL, 0}; // all three eliminated + + if (yes[static_cast(active)] >= T) + return {round_result::WIN, static_cast(active)}; + + if (all_voted || deadline_hit) + return {round_result::FAIL, 0}; + + return {round_result::PENDING, 0}; +} + +/// Fold a 32-byte hash into a uint64 (first 8 bytes, big-endian). Deterministic. +inline uint64_t seed_u64(const std::array& h) { + uint64_t s = 0; + for (int i = 0; i < 8; ++i) + s = (s << 8) | static_cast(h[static_cast(i)]); + return s; +} + +/// Map a seed to an index in [0, m). Returns 0 when m == 0 (caller must guard empty sets). +inline uint64_t bounded_index(uint64_t seed, uint64_t m) { return m ? seed % m : 0; } + +} // namespace sysio::councl_math diff --git a/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp new file mode 100644 index 0000000000..8071b0dd0f --- /dev/null +++ b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp @@ -0,0 +1,278 @@ +#pragma once + +/** + * @file sysio.councl.hpp + * @brief Council election contract — fills 21 council seats via a tier-1 → tier-2 → tier-3 + * escalation ladder with strict-priority slate voting. See DESIGN.md for the full model. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace sysio { + +/// Contract-wide constants and identifiers for sysio.councl. +namespace councl { + inline constexpr uint8_t SEATS = 21; ///< tier-1 owners == council seats + inline constexpr uint8_t T1_VOTERS = 20; ///< SEATS - 1 (seat owner never votes) + inline constexpr uint8_t SLATE_SIZE = 3; ///< candidates per repcandidate + inline constexpr uint8_t MIN_CANDIDATES = SEATS + 2;///< 23: <=20 elected before the last seat, +3 + inline constexpr size_t MAX_HANDLE_LEN = 32; ///< twitter-handle byte cap + + constexpr name ROA_ACCOUNT = "sysio.roa"_n; ///< owner of the nodeowners / roastate tables + constexpr name SYSTEM_ACCOUNT = "sysio"_n; ///< owner of the nodecount table; RAM pool payer + + // Election phase (state.phase). uint8 for a stable ABI; named to avoid magic literals. + inline constexpr uint8_t PH_AWAIT_REP = 0; ///< waiting for the active proposer's repcandidate + inline constexpr uint8_t PH_VOTING = 1; ///< a slate is open for voting + inline constexpr uint8_t PH_BACKSTOP = 2; ///< tier-3 exhausted; awaiting governance forceassign + inline constexpr uint8_t PH_DONE = 3; ///< all 21 seats filled + + // Init phase (config.init_phase). + inline constexpr uint8_t IP_REG = 0; ///< candidate registration open + inline constexpr uint8_t IP_LOADING = 1; ///< tier-2/3 snapshots being loaded + inline constexpr uint8_t IP_READY = 2; ///< election running / finished + + // filled_tier sentinel for a governance-assigned seat. + inline constexpr uint8_t TIER_GOV = 0; +} + +/** + * @brief The council election contract. + * + * Actions fall into three groups: registration (`addcandidate`/`rmcandidate`), staged init + * (`startinit`/`loadtier`/`finalizeinit`, plus `reset`), and the election + * (`repcandidate`/`vote`/`settle`/`forceassign`). `stir` is a permissionless entropy crank. + */ +class [[sysio::contract("sysio.councl")]] council : public contract { +public: + using contract::contract; + + // ---- Registration ------------------------------------------------------- + + /// Self-register as a council candidate. `handle` is a short (<= MAX_HANDLE_LEN) label + /// (assumed a twitter handle for now). Allowed only while registration is open. + [[sysio::action]] + void addcandidate(name account, std::string handle); + + /// Remove a candidate before the election starts. Governance only. + [[sysio::action]] + void rmcandidate(name account); + + // ---- Staged initialization --------------------------------------------- + + /// Begin an election: freeze the ordered tier-1 roster and close registration. `ordered_owners` + /// must be a permutation of exactly the 21 roa tier-1 node owners. Governance only. + [[sysio::action]] + void startinit(uint64_t time_slot_sec, std::vector ordered_owners); + + /// Append up to `max_rows` of roa's tier-`tier` (2 or 3) owners into the frozen snapshot, + /// resuming from the load cursor. Call repeatedly until complete. Governance only. + [[sysio::action]] + void loadtier(uint8_t tier, uint32_t max_rows); + + /// Finalize init: verify the tier-2/3 snapshots are complete and open seat 0. Governance only. + [[sysio::action]] + void finalizeinit(); + + /// Start a fresh election generation (after DONE): bump the generation and reopen registration. + /// Governance only. Prior-generation rows remain under their old scope and are ignored. + [[sysio::action]] + void reset(); + + // ---- Election ----------------------------------------------------------- + + /// The active proposer nominates a slate of 3 distinct, un-elected candidates. + [[sysio::action]] + void repcandidate(name proposer, name c1, name c2, name c3); + + /// Cast an independent yes/no on each of the 3 current-slate candidates. One ballot per voter + /// per attempt; the proposer never votes on their own slate. + [[sysio::action]] + void vote(name voter, bool v1, bool v2, bool v3); + + /// Permissionless crank: push a timed-out attempt forward and stir entropy. + [[sysio::action]] + void settle(); + + /// Governance backstop: seat an un-elected candidate when tier-3 is exhausted (phase BACKSTOP). + [[sysio::action]] + void forceassign(name member); + + /// Permissionless entropy crank: advance the accumulator (Variant B — no block number folded). + [[sysio::action]] + void stir(); + + // ----------------------------------------------------------------------- + // Tables + // ----------------------------------------------------------------------- + + /// Contract configuration + init progress singleton. + struct [[sysio::table("config")]] config_state { + bool initialized = false; + uint8_t init_phase = councl::IP_REG; + bool reg_open = true; + uint64_t time_slot_sec = 0; + uint8_t network_gen = 0; ///< roa network generation captured at startinit + uint64_t election_gen = 0; ///< scope for all per-election tables + uint32_t n2 = 0; ///< tier-2 snapshot size (set at finalize) + uint32_t n3 = 0; ///< tier-3 snapshot size + uint32_t t2_loaded = 0; ///< tier-2 load cursor + uint32_t t3_loaded = 0; ///< tier-3 load cursor + uint32_t cand_count = 0; ///< registered candidates (current generation) + + SYSLIB_SERIALIZE(config_state, + (initialized)(init_phase)(reg_open)(time_slot_sec)(network_gen) + (election_gen)(n2)(n3)(t2_loaded)(t3_loaded)(cand_count)) + }; + using config_t = sysio::kv::global<"config"_n, config_state>; + + /// Live election cursor + current round tallies + entropy accumulator. + struct [[sysio::table("state")]] election_state { + uint8_t phase = councl::PH_AWAIT_REP; + uint8_t active_seat = 0; ///< 0..20 + uint8_t tier = 1; ///< 1, 2, or 3 + name proposer{}; + uint64_t round_id = 0; ///< monotonic attempt counter (also selection nonce) + time_point round_open_ts{}; ///< when the current attempt opened (propose-deadline base) + time_point vote_deadline{}; + uint64_t elect_N = 0; ///< electorate size of the current round + uint32_t eligible_voters = 0; ///< voters expected this round (excludes the proposer) + uint32_t votes_cast = 0; + uint32_t tried_count = 0; ///< tier-3 accounts tried for the current seat + uint8_t seats_filled = 0; + // current slate + independent per-candidate tallies + name c1{}, c2{}, c3{}; + uint64_t yes1 = 0, yes2 = 0, yes3 = 0; + uint64_t no1 = 0, no2 = 0, no3 = 0; + // entropy accumulator (Variant B) + checksum256 acc{}; + uint64_t stir_count = 0; + + SYSLIB_SERIALIZE(election_state, + (phase)(active_seat)(tier)(proposer)(round_id)(round_open_ts)(vote_deadline) + (elect_N)(eligible_voters)(votes_cast)(tried_count)(seats_filled) + (c1)(c2)(c3)(yes1)(yes2)(yes3)(no1)(no2)(no3)(acc)(stir_count)) + }; + using state_t = sysio::kv::global<"state"_n, election_state>; + + /// Ordered-index key shared by the roster / tier snapshots and the council output. + struct index_key { + uint64_t idx; + uint64_t primary_key() const { return idx; } + SYSLIB_SERIALIZE(index_key, (idx)) + }; + + // One frozen node-owner slot, ordered by `idx`, with a by-owner secondary index for membership + // checks. The three tiers use separate row structs (identical shape) so each carries a + // [[sysio::table]] attribute matching its KV table name — the ABI convention this repo follows + // (one value struct per table name). The shape is shared via the SYSLIB_SERIALIZE field list. + struct [[sysio::table("roster")]] roster_row { + uint64_t idx; + name owner; + uint64_t by_owner() const { return owner.value; } + SYSLIB_SERIALIZE(roster_row, (idx)(owner)) + }; + struct [[sysio::table("tier2")]] tier2_row { + uint64_t idx; + name owner; + uint64_t by_owner() const { return owner.value; } + SYSLIB_SERIALIZE(tier2_row, (idx)(owner)) + }; + struct [[sysio::table("tier3")]] tier3_row { + uint64_t idx; + name owner; + uint64_t by_owner() const { return owner.value; } + SYSLIB_SERIALIZE(tier3_row, (idx)(owner)) + }; + using roster_t = sysio::kv::scoped_table<"roster"_n, index_key, roster_row, + sysio::kv::index<"byowner"_n, sysio::const_mem_fun>>; + using tier2_t = sysio::kv::scoped_table<"tier2"_n, index_key, tier2_row, + sysio::kv::index<"byowner"_n, sysio::const_mem_fun>>; + using tier3_t = sysio::kv::scoped_table<"tier3"_n, index_key, tier3_row, + sysio::kv::index<"byowner"_n, sysio::const_mem_fun>>; + + /// A registered candidate. + struct cand_key { + uint64_t account; + uint64_t primary_key() const { return account; } + SYSLIB_SERIALIZE(cand_key, (account)) + }; + struct [[sysio::table("candidates")]] candidate_row { + name account; + std::string handle; + bool elected = false; + SYSLIB_SERIALIZE(candidate_row, (account)(handle)(elected)) + }; + using candidates_t = sysio::kv::scoped_table<"candidates"_n, cand_key, candidate_row>; + + /// One voter's ballot in an attempt. Scoped by (generation, round_id); existence = has-voted. + struct voter_key { + uint64_t voter; + uint64_t primary_key() const { return voter; } + SYSLIB_SERIALIZE(voter_key, (voter)) + }; + struct [[sysio::table("ballots")]] ballot_row { + name voter; + bool v1, v2, v3; + SYSLIB_SERIALIZE(ballot_row, (voter)(v1)(v2)(v3)) + }; + using ballots_t = sysio::kv::scoped_table<"ballots"_n, voter_key, ballot_row>; + + /// A tier-3 index already attempted for the current seat. Scoped by (generation, seat). + struct [[sysio::table("tried3")]] tried_row { + uint64_t idx; + SYSLIB_SERIALIZE(tried_row, (idx)) + }; + using tried3_t = sysio::kv::scoped_table<"tried3"_n, index_key, tried_row>; + + /// A filled council seat (the 21 outputs). Scoped by generation. + struct [[sysio::table("council")]] council_row { + uint64_t seat; + name seat_owner; ///< roster[seat] — the tier-1 owner of this seat + uint8_t filled_tier; ///< 1/2/3, or TIER_GOV for a governance assignment + name proposer; ///< the account whose slate won + name member; ///< the elected candidate + SYSLIB_SERIALIZE(council_row, (seat)(seat_owner)(filled_tier)(proposer)(member)) + }; + using council_t = sysio::kv::scoped_table<"council"_n, index_key, council_row>; + +private: + using NodeOwnerTier = opp::types::NodeOwnerTier; + + // Per-election table scope is the generation; per-round/seat tables fold the generation in. + static uint64_t gr_scope(uint64_t gen, uint64_t x) { return (gen << 40) | (x & ((uint64_t(1) << 40) - 1)); } + + // Entropy + selection + void do_stir(election_state& st, name action_tag, name actor); + name select_from_tier(election_state& st, uint8_t tier, uint32_t avail, + const config_state& cfg); + + // State machine + void settle_if_elapsed(election_state& st, const config_state& cfg); + void try_resolve(election_state& st, const config_state& cfg); + void win_attempt(election_state& st, const config_state& cfg, name winner); + void fail_attempt(election_state& st, const config_state& cfg); + void open_tier_attempt(election_state& st, const config_state& cfg, uint8_t tier, name proposer); + void advance_seat(election_state& st, const config_state& cfg); + + // roa / nodecount helpers + uint8_t roa_network_gen() const; + uint32_t tier_count(uint8_t tier) const; // from sysio.system::nodecount + + // convenience + name roster_owner(const config_state& cfg, uint8_t seat) const; + bool is_member(const config_state& cfg, uint8_t tier, name who) const; +}; + +} // namespace sysio diff --git a/contracts/sysio.councl/src/sysio.councl.cpp b/contracts/sysio.councl/src/sysio.councl.cpp new file mode 100644 index 0000000000..c14f9dde10 --- /dev/null +++ b/contracts/sysio.councl/src/sysio.councl.cpp @@ -0,0 +1,496 @@ +#include + +#include // roa::roastate_t / roa::nodeowners_t — tier membership +#include // sysiosystem::emissions::nodecountstate_t — tier counts +#include + +#include +#include +#include + +namespace sysio { + +using councl_math::resolve; +using councl_math::round_result; + +namespace { + // System-owned rows bill to the sysio RAM pool (privileged-contract model, as sysio.chalg + // does): the council account stays at code+abi size while row growth draws from the pool. + constexpr name ram_payer = councl::SYSTEM_ACCOUNT; + + // Domain-separation tag for the entropy accumulator seed at election start. + constexpr name ACC_SEED_TAG = "councilseed"_n; + + /// sha256 over the packed tuple, returned as a raw 32-byte array (for the pure seed helpers). + template + std::array hash_tuple(const Tuple& t) { + auto packed = pack(t); + checksum256 h = sha256(packed.data(), packed.size()); + return h.extract_as_byte_array(); + } +} + +// =========================================================================== +// Entropy accumulator (Variant B — block number intentionally excluded) +// =========================================================================== +void council::do_stir(election_state& st, name action_tag, name actor) { + ++st.stir_count; + auto packed = pack(std::make_tuple(st.acc, action_tag, actor, st.stir_count)); + st.acc = sha256(packed.data(), packed.size()); +} + +// =========================================================================== +// Cross-contract reads (sysio.roa / sysio.system) +// =========================================================================== +uint8_t council::roa_network_gen() const { + roa::roastate_t rs(councl::ROA_ACCOUNT); + check(rs.exists(), "roa state not initialized"); + return rs.get().network_gen; +} + +uint32_t council::tier_count(uint8_t tier) const { + sysiosystem::emissions::nodecountstate_t nc(councl::SYSTEM_ACCOUNT); + check(nc.exists(), "sysio.system nodecount not initialized"); + auto v = nc.get(); + return tier == 1 ? v.t1_count : (tier == 2 ? v.t2_count : v.t3_count); +} + +// =========================================================================== +// Convenience lookups +// =========================================================================== +name council::roster_owner(const config_state& cfg, uint8_t seat) const { + roster_t roster(get_self(), cfg.election_gen); + return roster.get(index_key{seat}, "roster seat missing").owner; +} + +bool council::is_member(const config_state& cfg, uint8_t tier, name who) const { + if (tier == 1) { + roster_t r(get_self(), cfg.election_gen); + auto idx = r.get_index<"byowner"_n>(); + return idx.find(who.value) != idx.end(); + } + if (tier == 2) { + tier2_t t(get_self(), cfg.election_gen); + auto idx = t.get_index<"byowner"_n>(); + return idx.find(who.value) != idx.end(); + } + tier3_t t(get_self(), cfg.election_gen); + auto idx = t.get_index<"byowner"_n>(); + return idx.find(who.value) != idx.end(); +} + +// =========================================================================== +// Pseudo-random tier proposer selection (§5) +// =========================================================================== +name council::select_from_tier(election_state& st, uint8_t tier, uint32_t avail, + const config_state& cfg) { + // Seed folds in the seat and round_id so successive selections (even in one block) differ. + const uint64_t seed = councl_math::seed_u64( + hash_tuple(std::make_tuple(st.acc, static_cast(st.active_seat), st.round_id))); + const uint64_t j = councl_math::bounded_index(seed, avail); + + if (tier == 2) { + tier2_t t2(get_self(), cfg.election_gen); + return t2.get(index_key{j}, "tier-2 index out of range").owner; + } + + // tier 3: walk indices skipping already-tried entries to the j-th survivor. + tier3_t t3(get_self(), cfg.election_gen); + tried3_t tried(get_self(), gr_scope(cfg.election_gen, st.active_seat)); + uint64_t survivor = 0; + for (uint64_t i = 0; i < cfg.n3; ++i) { + if (tried.contains(index_key{i})) continue; + if (survivor == j) { + tried.emplace(ram_payer, index_key{i}, tried_row{i}); + ++st.tried_count; + return t3.get(index_key{i}, "tier-3 index missing").owner; + } + ++survivor; + } + check(false, "tier-3 selection out of range"); + return name{}; +} + +// =========================================================================== +// State machine internals +// =========================================================================== +void council::open_tier_attempt(election_state& st, const config_state& cfg, uint8_t tier, + name proposer) { + st.tier = tier; + st.proposer = proposer; + ++st.round_id; + st.phase = councl::PH_AWAIT_REP; + st.round_open_ts = current_time_point(); + st.vote_deadline = time_point{}; + st.elect_N = 0; + st.eligible_voters = 0; + st.votes_cast = 0; + st.c1 = st.c2 = st.c3 = name{}; + st.yes1 = st.yes2 = st.yes3 = 0; + st.no1 = st.no2 = st.no3 = 0; +} + +void council::advance_seat(election_state& st, const config_state& cfg) { + ++st.active_seat; + st.tried_count = 0; // tier-3 tried set is scoped per seat; just reset the live counter + if (st.active_seat >= councl::SEATS) { + st.phase = councl::PH_DONE; + return; + } + open_tier_attempt(st, cfg, 1, roster_owner(cfg, st.active_seat)); +} + +void council::fail_attempt(election_state& st, const config_state& cfg) { + uint8_t next = (st.tier == 1) ? 2 : 3; // T1->T2, T2->T3, T3->T3 (retry) + for (;;) { + if (next == 2) { + if (cfg.n2 == 0) { next = 3; continue; } + name p = select_from_tier(st, 2, cfg.n2, cfg); + open_tier_attempt(st, cfg, 2, p); + return; + } + // tier 3 + const uint32_t avail = (cfg.n3 > st.tried_count) ? (cfg.n3 - st.tried_count) : 0; + if (cfg.n3 == 0 || avail == 0) { + st.phase = councl::PH_BACKSTOP; // tier-3 exhausted -> governance fills the seat + return; + } + name p = select_from_tier(st, 3, avail, cfg); + open_tier_attempt(st, cfg, 3, p); + return; + } +} + +void council::win_attempt(election_state& st, const config_state& cfg, name winner) { + candidates_t cands(get_self(), cfg.election_gen); + cands.modify(ram_payer, cand_key{winner.value}, + [&](auto& c) { c.elected = true; }, "winner is not a candidate"); + + council_t council(get_self(), cfg.election_gen); + council.emplace(ram_payer, index_key{st.active_seat}, council_row{ + .seat = st.active_seat, + .seat_owner = roster_owner(cfg, st.active_seat), + .filled_tier = st.tier, + .proposer = st.proposer, + .member = winner, + }); + ++st.seats_filled; + advance_seat(st, cfg); +} + +void council::try_resolve(election_state& st, const config_state& cfg) { + if (st.phase != councl::PH_VOTING) return; + + const bool all_voted = st.votes_cast >= st.eligible_voters; + const bool deadline = current_time_point() >= st.vote_deadline; + + auto r = resolve(std::array{st.yes1, st.yes2, st.yes3}, + std::array{st.no1, st.no2, st.no3}, + st.elect_N, all_voted, deadline); + + if (r.result == round_result::PENDING) return; + if (r.result == round_result::WIN) { + name w = (r.winner_index == 0) ? st.c1 : (r.winner_index == 1 ? st.c2 : st.c3); + win_attempt(st, cfg, w); + } else { + fail_attempt(st, cfg); + } +} + +void council::settle_if_elapsed(election_state& st, const config_state& cfg) { + if (st.phase == councl::PH_AWAIT_REP) { + if (current_time_point() > st.round_open_ts + sysio::seconds(cfg.time_slot_sec)) + fail_attempt(st, cfg); // active proposer missed the nomination window + } else if (st.phase == councl::PH_VOTING) { + try_resolve(st, cfg); + } +} + +// =========================================================================== +// Registration +// =========================================================================== +void council::addcandidate(name account, std::string handle) { + require_auth(account); + config_t cg(get_self()); + config_state cfg = cg.get_or_create(ram_payer, config_state{}); + check(cfg.init_phase == councl::IP_REG && cfg.reg_open, "candidate registration is closed"); + check(!handle.empty() && handle.size() <= councl::MAX_HANDLE_LEN, "handle length invalid"); + + candidates_t cands(get_self(), cfg.election_gen); + check(!cands.contains(cand_key{account.value}), "already a candidate"); + cands.emplace(ram_payer, cand_key{account.value}, candidate_row{account, handle, false}); + + ++cfg.cand_count; + cg.set(cfg, ram_payer); +} + +void council::rmcandidate(name account) { + require_auth(get_self()); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::IP_REG, "candidate registration is closed"); + + candidates_t cands(get_self(), cfg.election_gen); + check(cands.contains(cand_key{account.value}), "not a candidate"); + cands.erase(cand_key{account.value}); + + --cfg.cand_count; + cg.set(cfg, ram_payer); +} + +// =========================================================================== +// Staged initialization +// =========================================================================== +void council::startinit(uint64_t time_slot_sec, std::vector ordered_owners) { + require_auth(get_self()); + config_t cg(get_self()); + config_state cfg = cg.get("no candidates registered"); + check(cfg.init_phase == councl::IP_REG, "init already started"); + check(time_slot_sec > 0, "time_slot_sec must be positive"); + check(cfg.cand_count >= councl::MIN_CANDIDATES, "need at least 23 registered candidates"); + check(ordered_owners.size() == councl::SEATS, "ordered_owners must list all 21 tier-1 owners"); + + const uint8_t ng = roa_network_gen(); + const uint8_t T1 = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1); + + // Enumerate roa's tier-1 owners (the authoritative set the ordering must permute). + roa::nodeowners_t no(councl::ROA_ACCOUNT, ng); + auto t1_idx = no.get_index<"bytier"_n>(); + std::vector t1; + for (auto it = t1_idx.lower_bound(static_cast(T1)); it != t1_idx.end(); ++it) { + if (it->tier != T1) break; + t1.push_back(it->owner); + } + check(t1.size() == councl::SEATS, "roa does not have exactly 21 tier-1 node owners"); + + // Freeze the ordered roster, verifying each entry is a distinct member of the roa tier-1 set. + roster_t roster(get_self(), cfg.election_gen); + auto by_owner = roster.get_index<"byowner"_n>(); + for (uint64_t i = 0; i < ordered_owners.size(); ++i) { + name o = ordered_owners[i]; + bool in_t1 = false; + for (const auto& x : t1) { if (x == o) { in_t1 = true; break; } } + check(in_t1, "ordered_owners contains a non tier-1 owner"); + check(by_owner.find(o.value) == by_owner.end(), "duplicate owner in ordered_owners"); + roster.emplace(ram_payer, index_key{i}, roster_row{i, o}); + } + + cfg.network_gen = ng; + cfg.time_slot_sec = time_slot_sec; + cfg.reg_open = false; + cfg.init_phase = councl::IP_LOADING; + cfg.t2_loaded = 0; + cfg.t3_loaded = 0; + cg.set(cfg, ram_payer); +} + +void council::loadtier(uint8_t tier, uint32_t max_rows) { + require_auth(get_self()); + check(tier == 2 || tier == 3, "tier must be 2 or 3"); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::IP_LOADING, "not in the loading phase"); + + roa::nodeowners_t no(councl::ROA_ACCOUNT, cfg.network_gen); + auto idx = no.get_index<"bytier"_n>(); + const uint32_t already = (tier == 2) ? cfg.t2_loaded : cfg.t3_loaded; + uint32_t seen = 0, written = 0; + + if (tier == 2) { + tier2_t t2(get_self(), cfg.election_gen); + for (auto it = idx.lower_bound(static_cast(2)); it != idx.end() && written < max_rows; ++it) { + if (it->tier != 2) break; + if (seen < already) { ++seen; continue; } + t2.emplace(ram_payer, index_key{already + written}, tier2_row{already + written, it->owner}); + ++written; + } + cfg.t2_loaded += written; + } else { + tier3_t t3(get_self(), cfg.election_gen); + for (auto it = idx.lower_bound(static_cast(3)); it != idx.end() && written < max_rows; ++it) { + if (it->tier != 3) break; + if (seen < already) { ++seen; continue; } + t3.emplace(ram_payer, index_key{already + written}, tier3_row{already + written, it->owner}); + ++written; + } + cfg.t3_loaded += written; + } + + cg.set(cfg, ram_payer); +} + +void council::finalizeinit() { + require_auth(get_self()); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::IP_LOADING, "not in the loading phase"); + + // Completeness cross-check against sysio.system's live tier counts. + const uint32_t c2 = tier_count(2); + const uint32_t c3 = tier_count(3); + check(cfg.t2_loaded == c2, "tier-2 snapshot incomplete"); + check(cfg.t3_loaded == c3, "tier-3 snapshot incomplete"); + + cfg.n2 = c2; + cfg.n3 = c3; + cfg.init_phase = councl::IP_READY; + cfg.initialized = true; + cg.set(cfg, ram_payer); + + // Open seat 0 with a fresh accumulator seeded from the generation. + election_state st{}; + auto seed_bytes = pack(std::make_tuple(ACC_SEED_TAG, cfg.election_gen)); + st.acc = sha256(seed_bytes.data(), seed_bytes.size()); + st.stir_count = 0; + st.active_seat = 0; + st.seats_filled = 0; + st.tried_count = 0; + open_tier_attempt(st, cfg, 1, roster_owner(cfg, 0)); + state_t(get_self()).set(st, ram_payer); +} + +void council::reset() { + require_auth(get_self()); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::IP_READY, "an election is still in progress"); + state_t sg(get_self()); + check(!sg.exists() || sg.get().phase == councl::PH_DONE, "current election is not complete"); + + // Bump the generation and reopen registration; prior-gen rows stay under their old scope. + ++cfg.election_gen; + cfg.init_phase = councl::IP_REG; + cfg.reg_open = true; + cfg.initialized = false; + cfg.cand_count = 0; + cfg.n2 = cfg.n3 = cfg.t2_loaded = cfg.t3_loaded = 0; + cg.set(cfg, ram_payer); +} + +// =========================================================================== +// Election +// =========================================================================== +void council::repcandidate(name proposer, name c1, name c2, name c3) { + require_auth(proposer); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::IP_READY, "election is not running"); + + state_t sg(get_self()); + election_state st = sg.get("election state missing"); + do_stir(st, "repcandi"_n, proposer); + settle_if_elapsed(st, cfg); + + check(st.phase == councl::PH_AWAIT_REP, "not accepting nominations right now"); + check(proposer == st.proposer, "not your turn to nominate"); + check(current_time_point() <= st.round_open_ts + sysio::seconds(cfg.time_slot_sec), + "nomination window has passed"); + check(c1 != c2 && c1 != c3 && c2 != c3, "slate candidates must be distinct"); + + candidates_t cands(get_self(), cfg.election_gen); + for (name c : {c1, c2, c3}) { + auto cr = cands.get(cand_key{c.value}, "candidate not registered"); + check(!cr.elected, "candidate already elected to a seat"); + } + + // Open the voting round; tier-2/3 seed the proposer's auto-yes on all three candidates. + st.c1 = c1; st.c2 = c2; st.c3 = c3; + if (st.tier == 1) { + st.elect_N = councl::T1_VOTERS; + st.eligible_voters = councl::T1_VOTERS; + st.yes1 = st.yes2 = st.yes3 = 0; + } else { + const uint32_t N = (st.tier == 2) ? cfg.n2 : cfg.n3; + st.elect_N = N; + st.eligible_voters = (N > 0) ? (N - 1) : 0; + st.yes1 = st.yes2 = st.yes3 = 1; // proposer auto-yes + } + st.no1 = st.no2 = st.no3 = 0; + st.votes_cast = 0; + st.phase = councl::PH_VOTING; + st.vote_deadline = current_time_point() + sysio::seconds(cfg.time_slot_sec); + + try_resolve(st, cfg); // resolves immediately when the electorate is trivially small + sg.set(st, ram_payer); +} + +void council::vote(name voter, bool v1, bool v2, bool v3) { + require_auth(voter); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::IP_READY, "election is not running"); + + state_t sg(get_self()); + election_state st = sg.get("election state missing"); + do_stir(st, "vote"_n, voter); + settle_if_elapsed(st, cfg); + + check(st.phase == councl::PH_VOTING, "voting is not open"); + check(voter != st.proposer, "the proposer cannot vote on their own slate"); + check(is_member(cfg, st.tier, voter), "not eligible to vote in this tier"); + + ballots_t ballots(get_self(), gr_scope(cfg.election_gen, st.round_id)); + check(!ballots.contains(voter_key{voter.value}), "already voted in this round"); + ballots.emplace(ram_payer, voter_key{voter.value}, ballot_row{voter, v1, v2, v3}); + + if (v1) ++st.yes1; else ++st.no1; + if (v2) ++st.yes2; else ++st.no2; + if (v3) ++st.yes3; else ++st.no3; + ++st.votes_cast; + + try_resolve(st, cfg); + sg.set(st, ram_payer); +} + +void council::settle() { + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::IP_READY, "election is not running"); + + state_t sg(get_self()); + election_state st = sg.get("election state missing"); + do_stir(st, "settle"_n, get_self()); + settle_if_elapsed(st, cfg); + sg.set(st, ram_payer); +} + +void council::forceassign(name member) { + require_auth(get_self()); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + + state_t sg(get_self()); + election_state st = sg.get("election state missing"); + do_stir(st, "forceasgn"_n, get_self()); + check(st.phase == councl::PH_BACKSTOP, "not awaiting a governance assignment"); + + candidates_t cands(get_self(), cfg.election_gen); + auto cr = cands.get(cand_key{member.value}, "member is not a candidate"); + check(!cr.elected, "candidate already elected to a seat"); + cands.modify(ram_payer, cand_key{member.value}, + [&](auto& c) { c.elected = true; }, "member is not a candidate"); + + council_t council(get_self(), cfg.election_gen); + council.emplace(ram_payer, index_key{st.active_seat}, council_row{ + .seat = st.active_seat, + .seat_owner = roster_owner(cfg, st.active_seat), + .filled_tier = councl::TIER_GOV, + .proposer = get_self(), + .member = member, + }); + ++st.seats_filled; + advance_seat(st, cfg); + sg.set(st, ram_payer); +} + +void council::stir() { + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + state_t sg(get_self()); + check(sg.exists(), "election has not started"); + election_state st = sg.get(); + do_stir(st, "stir"_n, get_self()); + sg.set(st, ram_payer); +} + +} // namespace sysio diff --git a/contracts/sysio.councl/sysio.councl.abi b/contracts/sysio.councl/sysio.councl.abi new file mode 100644 index 0000000000..2075b506fc --- /dev/null +++ b/contracts/sysio.councl/sysio.councl.abi @@ -0,0 +1,584 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "addcandidate", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "handle", + "type": "string" + } + ] + }, + { + "name": "ballot_row", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "v1", + "type": "bool" + }, + { + "name": "v2", + "type": "bool" + }, + { + "name": "v3", + "type": "bool" + } + ] + }, + { + "name": "cand_key", + "base": "", + "fields": [ + { + "name": "account", + "type": "uint64" + } + ] + }, + { + "name": "candidate_row", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "handle", + "type": "string" + }, + { + "name": "elected", + "type": "bool" + } + ] + }, + { + "name": "config_state", + "base": "", + "fields": [ + { + "name": "initialized", + "type": "bool" + }, + { + "name": "init_phase", + "type": "uint8" + }, + { + "name": "reg_open", + "type": "bool" + }, + { + "name": "time_slot_sec", + "type": "uint64" + }, + { + "name": "network_gen", + "type": "uint8" + }, + { + "name": "election_gen", + "type": "uint64" + }, + { + "name": "n2", + "type": "uint32" + }, + { + "name": "n3", + "type": "uint32" + }, + { + "name": "t2_loaded", + "type": "uint32" + }, + { + "name": "t3_loaded", + "type": "uint32" + }, + { + "name": "cand_count", + "type": "uint32" + } + ] + }, + { + "name": "council_row", + "base": "", + "fields": [ + { + "name": "seat", + "type": "uint64" + }, + { + "name": "seat_owner", + "type": "name" + }, + { + "name": "filled_tier", + "type": "uint8" + }, + { + "name": "proposer", + "type": "name" + }, + { + "name": "member", + "type": "name" + } + ] + }, + { + "name": "election_state", + "base": "", + "fields": [ + { + "name": "phase", + "type": "uint8" + }, + { + "name": "active_seat", + "type": "uint8" + }, + { + "name": "tier", + "type": "uint8" + }, + { + "name": "proposer", + "type": "name" + }, + { + "name": "round_id", + "type": "uint64" + }, + { + "name": "round_open_ts", + "type": "time_point" + }, + { + "name": "vote_deadline", + "type": "time_point" + }, + { + "name": "elect_N", + "type": "uint64" + }, + { + "name": "eligible_voters", + "type": "uint32" + }, + { + "name": "votes_cast", + "type": "uint32" + }, + { + "name": "tried_count", + "type": "uint32" + }, + { + "name": "seats_filled", + "type": "uint8" + }, + { + "name": "c1", + "type": "name" + }, + { + "name": "c2", + "type": "name" + }, + { + "name": "c3", + "type": "name" + }, + { + "name": "yes1", + "type": "uint64" + }, + { + "name": "yes2", + "type": "uint64" + }, + { + "name": "yes3", + "type": "uint64" + }, + { + "name": "no1", + "type": "uint64" + }, + { + "name": "no2", + "type": "uint64" + }, + { + "name": "no3", + "type": "uint64" + }, + { + "name": "acc", + "type": "checksum256" + }, + { + "name": "stir_count", + "type": "uint64" + } + ] + }, + { + "name": "finalizeinit", + "base": "", + "fields": [] + }, + { + "name": "forceassign", + "base": "", + "fields": [ + { + "name": "member", + "type": "name" + } + ] + }, + { + "name": "index_key", + "base": "", + "fields": [ + { + "name": "idx", + "type": "uint64" + } + ] + }, + { + "name": "loadtier", + "base": "", + "fields": [ + { + "name": "tier", + "type": "uint8" + }, + { + "name": "max_rows", + "type": "uint32" + } + ] + }, + { + "name": "repcandidate", + "base": "", + "fields": [ + { + "name": "proposer", + "type": "name" + }, + { + "name": "c1", + "type": "name" + }, + { + "name": "c2", + "type": "name" + }, + { + "name": "c3", + "type": "name" + } + ] + }, + { + "name": "reset", + "base": "", + "fields": [] + }, + { + "name": "rmcandidate", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + } + ] + }, + { + "name": "roster_row", + "base": "", + "fields": [ + { + "name": "idx", + "type": "uint64" + }, + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "settle", + "base": "", + "fields": [] + }, + { + "name": "startinit", + "base": "", + "fields": [ + { + "name": "time_slot_sec", + "type": "uint64" + }, + { + "name": "ordered_owners", + "type": "name[]" + } + ] + }, + { + "name": "stir", + "base": "", + "fields": [] + }, + { + "name": "tier2_row", + "base": "", + "fields": [ + { + "name": "idx", + "type": "uint64" + }, + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "tier3_row", + "base": "", + "fields": [ + { + "name": "idx", + "type": "uint64" + }, + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "tried_row", + "base": "", + "fields": [ + { + "name": "idx", + "type": "uint64" + } + ] + }, + { + "name": "vote", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "v1", + "type": "bool" + }, + { + "name": "v2", + "type": "bool" + }, + { + "name": "v3", + "type": "bool" + } + ] + }, + { + "name": "voter_key", + "base": "", + "fields": [ + { + "name": "voter", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "addcandidate", + "type": "addcandidate", + "ricardian_contract": "" + }, + { + "name": "finalizeinit", + "type": "finalizeinit", + "ricardian_contract": "" + }, + { + "name": "forceassign", + "type": "forceassign", + "ricardian_contract": "" + }, + { + "name": "loadtier", + "type": "loadtier", + "ricardian_contract": "" + }, + { + "name": "repcandidate", + "type": "repcandidate", + "ricardian_contract": "" + }, + { + "name": "reset", + "type": "reset", + "ricardian_contract": "" + }, + { + "name": "rmcandidate", + "type": "rmcandidate", + "ricardian_contract": "" + }, + { + "name": "settle", + "type": "settle", + "ricardian_contract": "" + }, + { + "name": "startinit", + "type": "startinit", + "ricardian_contract": "" + }, + { + "name": "stir", + "type": "stir", + "ricardian_contract": "" + }, + { + "name": "vote", + "type": "vote", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "ballots", + "type": "ballot_row", + "index_type": "i64", + "key_names": ["scope","voter"], + "key_types": ["name","uint64"], + "table_id": 23362 + }, + { + "name": "candidates", + "type": "candidate_row", + "index_type": "i64", + "key_names": ["scope","account"], + "key_types": ["name","uint64"], + "table_id": 37238 + }, + { + "name": "config", + "type": "config_state", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 21143 + }, + { + "name": "council", + "type": "council_row", + "index_type": "i64", + "key_names": ["scope","idx"], + "key_types": ["name","uint64"], + "table_id": 39085 + }, + { + "name": "roster", + "type": "roster_row", + "index_type": "i64", + "key_names": ["scope","idx"], + "key_types": ["name","uint64"], + "table_id": 35108, + "secondary_indexes": [ + { + "name": "byowner", + "key_type": "uint64", + "table_id": 60639 + } + ] + }, + { + "name": "state", + "type": "election_state", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 56269 + }, + { + "name": "tier2", + "type": "tier2_row", + "index_type": "i64", + "key_names": ["scope","idx"], + "key_types": ["name","uint64"], + "table_id": 25558, + "secondary_indexes": [ + { + "name": "byowner", + "key_type": "uint64", + "table_id": 22929 + } + ] + }, + { + "name": "tier3", + "type": "tier3_row", + "index_type": "i64", + "key_names": ["scope","idx"], + "key_types": ["name","uint64"], + "table_id": 42070, + "secondary_indexes": [ + { + "name": "byowner", + "key_type": "uint64", + "table_id": 6673 + } + ] + }, + { + "name": "tried3", + "type": "tried_row", + "index_type": "i64", + "key_names": ["scope","idx"], + "key_types": ["name","uint64"], + "table_id": 26046 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/sysio.councl/sysio.councl.wasm b/contracts/sysio.councl/sysio.councl.wasm new file mode 100755 index 0000000000000000000000000000000000000000..e457ffbd554c12754afaefe27c1fe015415d708b GIT binary patch literal 31131 zcmeI5e{@|}b?47L_q``s?>V^v2ZSBSeGj*lnV4}Bkc|@tT{Ywf3BLk`bOvh6){8A! zmSjmz?3P*zm{dU88WJWU1H>f!Ff^u4fgz>PQE7lc!_|bY}eeQJwle;75T=ZD9cB^w+u6rEPpwujSO%POQSYwp0-iq*0{s7AvF z)aEp)dOUD|lH93(CA!U9IKYMKx{zU&DPXnoCP?Tdf1*Bp-oOF=7?pki^uX3w_x^+N zv*PCH4VB5A0~fux*G0OiP41r@8rw23IoX_;a^9ZrZtmVbzTd^g&17>b+>{5ljZwbCtX=NXOS~BxNpl~b8>28Y`?20 z^V~f1`k~RmEt5@Z3=T}}cO80GD_Wi!+T9GTRwyngI_>#HbI;zPiRPApy;D2g3S~Vb zZ*FAl#^%J9ZDV^!2SZx|N1cy0_f5Gj6-@@VPYsQYZW$by8rU*9^xI8Wwd{%Jz~Gj) zXRfAfb-aU)SGM;uJ~6Z#Xt(TY?ssSDK_{h}69bb?ceZZ3@=jaNYk9`x)YwGRB`R}H zJKn+AVAGuw-yI!}Tvx?)byX@|@iQv5N>p1>dZzcauCA!Is#fFo+~`?R6rEG4RG#h6 zt#zLpbw|&MqSfd6?(QgBb6&0cxwY=vITTqFJ+G^~a(=Bl>gKQFpIfU{sGmmNHEz$T z)N8d`T|eIQTMvJ9RqLwNqDI7n@E6r+x|=+*{PUk*Do5kAUXCLdRh|`3N7>Art4_OY z_Uo1$BI$TxsR9v=P3-rsYfC_Nb969{s#~_~Yz~ZX*#@p+Umh4AAKC9d6LtOM+l;*A zlIK(>qIlZ(rEaCm+}YKP-yZ+NYf_gbH#DN*hVSJr$BTn6LcJ^>KzNg`O{iD}V z(Pzn2>aJFGuA^aKMiP+{=a%nJ@S1qT_n$LxRp`n>IVGd*EIagS4OEn$GMuT zR#R84&`z>CB;6r7uli8L$Wy0AH2y5!^YWEWSL+?fNcp$xY$`L7Mid^V5s%cg%luNA z@lu(kkhzkPN7-A3>z?FyK#l;PGnr;11JbW2$Smrs;R3t9cqJ zrR81qShF)6MF;v_y<}(s828GRj(cCT`J~G}5V{oeW8jvS@0v6C45GOt&x$$`ij@SAIBiR^aTafDc1>ejw*`S0i2v2*N``8~pyt zYDS+9uXWWg#8LS`8eQnl7IrRlXEBc?E9(M<3)d+3Z@M`x_a`^&HtO%Z`JgWR!8|7} za!N%`xyY#;Y*e_Wg&F;weBufBwu3aq_2W;RA1;6LLd0}MF)yT5$VLeF}4(^m%W zH^x0Aut`c1Wp_}h%L;YseqDSh3XMGAhSO5Aj?7YzzpMcZ_xLN*>aJvM+L2cBW>=)0 z`Bhh1S{HZPMCl4h4n@8%)QylBYP^>QqU1c9Vo0Ld`=hut?Xi&rs$joa?=kxfm2Iu{4j z?vxIbxG3#PBV+^Mm2!r*e2IH{5a#JSYDq`xz@WP{napH1eGTH&XXgzAS*L^uvKNUd zwm=3TLmFjff&Z^wlRd#-2SZXCU$v$Y3)?ZGs8j`ZI;$y3W18um{MYsvFn^-GJVx0q1(^iC)M zOHc>N-MK3}{}pB;y!C6C2$osztfowJmLhf$sFLtZY>?Yg@}Hx+J%mQn&gv_%3Lwx8 zyBd+05UDr!p+BDc+=(k-C3Teb3@7WY{r3eEg!ahGKE>S&rDIabt!#r@^VPk^*M)gP zi;|+p|K!&1-u#8>y3ac5F%u&d#Ehk0RP9B`NDWO7KO$thb+ACg_q%IXx}=Qm!~XG_ z7HVdlNkuXeDyPdhb5~SvT%bW9C+V0tI|@Rc$3%w9UY|f=@j9QF7^bCu6+*n`m_`Nz;3%wlOpXX zvkDamCooc>fmTtBunC4n_L1q~?1^Yk_D_)+gW0!LO)6E00IY^oKDW^E2Iov=6cz~f zhpS@b<|d(ZC{PAfBWXuJQNP$ne=mDW>+%WrdV}!RzY^KdPp51QKw?6uO*O;I9k4Ou zctE8zaKpN*-sx?ojdl)ulWy%(9W&J>rrKwBcqA=2)?A~6XIqkK;atbXTw~$(*=z%e zDrchAWH_7gNrD;Tt~5Kd#J#L1h=PZ75Cnm4f`kiK$T#f6G)iudR%;SU$(nkZkr{cl zb$LpMXy+tB8cI#)Etf;HB<*dc<>a|cZFbm;7LmA<+4(nAr&{DFneT-cxXM^cwB(_= zR|88)5Lvcy)r&+((02qcm0v%E2FJB`Yjzt2sB(c+INzOa`!Oy(& z81bcRrxu zP!udMv!9S@z@mR3ZF=}D?;^wka%WxKjRA*QX56ze-p5rbd)&bwQ+D}7*;Lsq3x$^m zi-;4UGke5|Rtq%>yI^XSSQWy_kX6#b3hGHVzxgzqkF0sI{gZfU_}GJALA$qbr5J-lW(~9h>3J#@ySMS0@qE2WHCnr$R8BWiG)-pboO4b<`&PXMk z%+z29gwF9OzqMWqa@c2H3ksGrX2}U9S_a8XET=X3&vGnS2id{Da7n*hQP*H!(~#V#Hs_%FqSx8Za=cpr>fF1%${IG1Z; zp7aW*SuAj5(At_-Sdc+0LoQd6cCCenS+XLnM*9l;&;QCc0#pIF8YXO|j((;LdZ`rZw<7hce&AtMft%D1M3^}Y?63d|k5Kr@6YAP49DcxNO$R5R#sd$O5H> z?r?WyNOp##FC<$-GDE9s=DLvV3(0tOHflL$ZCgy3-Xsv#J6uCJxi_;g?-PaEy7OFC z0w_Gd4c-B*HCeCsV73M+pK?_?+W$oKHVsVv0)A5cu#k8RKU6OTv^3*?swP)y*&(lP zbq|2h4;}lKPbv+Lc)+1kjCcFR6_fNOSJ|#2l(LrH0tx6wDeQB4V8eMH>HwC&g)spO z_k(8m>NTN(>dDy0LGwSRXJ~$$ZZ@87uEKZ{)z}z6A}7RgJV)9X;^&dlaP~c9`73#h zqC3tc+^-_{lWz_j%5sr3*eGFVV6%HjPr>amrT9fmJwnEEu<}kM3@B^cA&xtl1v_VQTjHBOad7jP>}#z9G4Uet#}PD=o_h9BI3nnh%!bFhTLF z=z4boF!LuTizhig8XX(sIp8*wG}xcgP|eV3tk5i%Qj(N}a`%ggC~{0G*=>lkQ5)t0 z9T;NiTLV1-0=1>4+28E6#|973%%(8#HuY>?G~vgz^Q$$_>)oZ0>SlMTCY!l^1(hBv ziKcchbimou8^d@{^}d;f((=9~4VHvL%c{+8RbIi+hQq0arzA%p-I<5(kT%J&BZ-&@ z+AC~*nQK_Ya90GWYUyFNgrft z9meEnr;ViLFg|SRSq}6wr0b5}=TB4DVW8!@?!!?)y`1(fCCe~nGifsptzORRz5Y;< zmDR~*MP3wOMO$3nrn0w*XOa&`t22SqrX{3(rGpRUWY6U+jsVS;E#ggisAn-|OL>?p zy?U#UdKpn%5mlxb(?}dFRta9a+A8w5tDQj> z@^n0SOu5=;l2A2ff-hiQ+|%exJNu)X`{S9$ir>ZN54Ja~sDE)Ejdf{Bk#@eGJp#zl19N5m9*n^D{*!PUbxzva)z%cdr;fpw8IuqqbrLp zUn7r7*BbSdT+~#6CoRB^xww8-rA`DI^>8Y_mg=C=+Z-XeutlT@rz1TR6q?`nHJz zAs~6Zff}!OuVw5c*J^a^fnjX_M8>wno7l|4aui^41MC{@S;KNDOM_qQt}lj7Cs79z zsQtSj{83;jQ>NV%=`Vih@He0=8vjgDi2`(N8w8k< znu*;Q|51>~G=`9vk@|)2g*1lEFKfs7mXbG;V;$vIea3 zY_mhT=?={%9Cwj`W2CsKtPD_?6kI0Pi2;DctE+lhlU!zpAK!lovXOK-6ZSg#Onp4< z_PY8xJ$wB(8D_B8ztFa)Bsm5_hY9%p(ObWSOAt?}A}V6rd@LiLv@9p!W)_)I)^eL4 zt(Pg$)9aC9P_s$C;A1!87cQB5$?+l|%}CiAc}7{ue_Wm2{t-AVbj98r^l`{%JweZN zbBk}3CGc#YFqYes=~-s)bY>=iKffpTl=e;qkS6Yo?VWz)XY8FW>=0ph8hfX-x%A%Y zwo?}&GVOwSB~ezaK%`3Y^_`hi=$327o@0gf`daUuc(&@YIK`Q z%PRJg8oyz}@gIY54A1AEW1^`?2s39aNTEeVEZ)Y7!Y=E?)dVPCfOTsAyqxHryCo*^|CXy)6U zeg&~y*pSDAyea0Y&`6loarU^W1I+1c<}br+0rCG)01~9AQb+_@O<6y-Udjjq=fr@ni5hqOTRQ^N4sq&K{A5`X1Ms zp_3{@z}X3<&;=_2Q-^W(J%jgt_HDNMKK&DhdnB)7X%gu`1*MqD6=x@jqpHMtxgP&; z#HtO*PezRn1x;~dbVy}oKde}tUJ}_k7-l{6&sr{v_Y$vWVPG@-`B$$Lynul6@_QRA zD9ZnN;FlEM<3z1=T(yRHrb;q^u|-3344}QMIT3hcMXx z1C^^Xn=m;dQGX+^WNS!3SepmP(2lmE!f3)+=4t5e2j-2cL&OOQo4LS&GDL5TTeN59 zw9SpS(hPQaNNX_-1ApyfSv(~RwXqVLB~XvB7N`R15LHl?MWjntzLw2?SuZ^8km{8g z_>CMA#>eNdR3%w@8Hrn>o#VM)XHsj8)rqg@>La;if*MNiahj>D8qu+nZ}+F z?hq$~8N0^w!mrb2NP-uH8LFE)vj9C>beuC=!tj)06wqRUKy#-NgLBe_ARKlnc*t*Z zy)BmV&2xmfs{Qito{NQJQG>%6Ot*H^8EUBkWRi$ZAuUM%SSocVDi!?TUy>fFk!YCA zo^g8!jLmW}9%1;zQz0-HK_+1-o!})Taqmo+6oERZBMbr#oRXd)uZlMCPj5xCqqQQrrBnn^93zG~S{pEA z<5<9PQicqS<39>`_8I1lJYrhzN7j3?n8Gs(1w9q5hRzTaYVpNZ6byYV0(jdc#?SpX z;!e$hC*~xkN+75OAspG_Zjl>W(}cL?CEH{>2}X*tV`cHp+jHN2h@XdptSh5u-Dutw zmJH!?nFlYf8cv=GQF|iMGm9M#vC_5xCsw*Wk_VyFij}qo_^}Gd+$>kYJr1$?OR9HP(_ za%d4R6c+3#M>gqOTSK}vk%{q5PjVbunNsozZgSiD$7PG6rscT>wza|Z6YNiqA%Q7y z*Ozj`C9ICjjGZxki}~1EA0eu5^0?5f%BPAd@r6#d&f|#kc5_+gGvqX!^<)=-fFiCI zGM4a`5&dXn=;E3Dj%HKbK|?30m>W%IR$IJv4C1Nxs9Du4S++9Sj!^)T8P(?cTwqzN zQ3`?jx{LclPr+HJ6=>aTXZpdZd{l>GZ}38lR{D7Ii*3dg5!TM7|cv!sgtJfhQW%cF)(8tKU=iyC=TpH9>umQE8j@l!y} zy#Y1%V%Ue(jgi^>Jeikp&P=~Ev+b?T;?q-FyIPEQhI)SRbk*fZtLNn;iAM2cCCMMn zOOj=q1;i)Y?7j2N-d$!p(gM6Cpiu6`;y=no0DC2o_>f=R1uap8=jdDT@d8nVAW1H= zIK`M$dV#uRl2gB!p}+j-Dje(EDdT{IxIM^Lt;v^GlS)aRNZ=@MM^0LdSY3&pIj@4X7!FZDeGK zLbB!o+U(8=Ak0xehp)Y%d~VdfU}D<}Fl0&CB8}KEW-9D=M)HcJJSbq6=gNROf?V&O z-|Ek?vj_v)>hB5Nnj;|V3REnXBQxTTM(6q?`Co{(*BXtH0FyQMef>7{ui zb1Yg_odvh0$MckBx!s@4Q$n!qo|S>6kLM}TbICa272ZM?Ws7K5@u?~4WOAM6JY`MN zsmbls-dsLPP>zDK;zr2th$?5s8Gy zH_CvBU!Yqk{&NF+YN@Xmk^rTaoM9+I6Q*&34T zLh_oBTp5x+$N=rb%0cMZ6f&3FZcqfPTiD_0?ZwgN(tC0LyA;A^PUZ`~h`p&0`nxec zp@p8*X*oO5#tsBBeaIK=@IavIq6!+g2>ca39_Hy3?OC4a_OWmL^Wney+%&SS2fi(e z(noSF(?-j$fr*&M|7Fc8i%^d=BD{di+rJWaR|tq8`-l$_5nS#hB0>f3%L`BsVXl}2 z9MTq=ke03uVF~};iU_22Y?5q@uWXH$zNF(7Wz2GhNNF9g?O}Pseh4>Zol&Vs%xSG- zQR?LkG0LTa>uC0sVFYW#2;T8v09J$Q&NsA>Sg91clp*ol-6s01*TY&j)9oEf=~u5F z2+dtfL2b@ttLl~hRrR?0HZr(u+W&1yg{8g5!CH770)#uO)>5LSCD| z8YAbV88dV7yu>=j-c4&i?_Sh%cCUn8v3aK(xyYA4RjEzAq8iHEn!xoxbE*@mJBS;F zPl`q;ps>Z(UIEeqT##YmZ^$-XzFtg{|L7*LhOwI?q4W6Zdy)sp!>=?^-$KJZ zy}2bftQ_qZVq-(G1UuSdJDewJ6gFY!icAryLABmpL|dCoYCLLBA^o7yQ?3vWec=%O z`ES$B9iD>^2ekmetbqT09}VH{5br1Z1H=EpSIsc60^O7&c=s73MC+bI!cIbxSGKD2 zJi#sQ9FrnC^6=Zn_~u43IA((hFl&Ay%Q+l!4(Dg@>}?tL;81vQXujx?kaHw2YI{>w z^p5c0j`^bYRu;XJMX~}xHHhKa>+!w=S_|7^Yi7r?=0}i-tLKcX&qP$x7yqjqZ%==vp$`vd+eI@0O1b6&en4;5xRDE1iV!SWijZ1h_V9@EKV73`-9 z4^LDSiyq2KS~P5L0M+oQo}xC6D}3l*{Mc%Z;_a3OE@34ll`b6hF;ZOF5cXTn}^)PR%C?zrkPD<_pQ z=*Ovxf3KJ`3{ykwya-Y;VJMGF=We|lL4s{|BPp;1s+mDwyn-7^u$*d8D}hAAfQ!f@ z?bzTxk*(&lLmlG$cRmT8f@K25wyzFN*Zgy!!zgDdy$r^x%$(^MfIG-rBze9^c z@alP~2XET>HPW$3RCc~? zj3tpoDJKcoGaTih6<*1eoM(pcDfswVt^BfJW=0HVTSM4vN}jR;(VGV>976z@#;nbM1FW&N8vi0~&8)0u*$fn@W5^iq}2NPPyp_ZXHy$UE$ z7c?X*lOu`k^7@O=yM~mb^8Zp%L6LinB}J5n1cu znKsx$Y^L1dlvzZ)3iO$RYQ+h-&>_+|2m^Y-2!K7p;gRp^r6_qJpu52FRzCGA`39Pp zQeMxx2P)Q~o6=MoPeA`19ni}HJ=;{cHSp0%mMP+iPWu=aeS|jEuxoe?pb_(uS6Zgv zuH;iuc&Ta{>&TA=sjw(9UA4L?=oH>T%%5J}lG=*|Rh2T#^6Cb=71osCQ;R09tN-u| z=F-5P7Y`@zGQu@xC5ZhNyE|=WUGFZ|Zpj77PCqX{7IW-N7in7PX`vj$M+GXfHd0^` z4Mj{!@k*RrDr!oW)6=DSL5RW$4cr+8;j3+yGb8}9Oj_a(H7};j1);eZhHc`f!FE?}XX^E>X9{r-={-L4xD(>AF_i?Viq|I2^y*eK_e^(4p=@a7 zvbEL9m6{1<3q7N+7S&CP% z|H==>;T6d~P_vYA9hCGhnXccm%)A-T{VTMHes`Ji{EI;Lfn|U*WWQu7mAXKmzF?6$ z4Z8Fl65D76vw+Q1$Q5#f_<5KDK7)kqkcX@Y4k%zo%V6L&Fe-_t$IT(ZZ2BE-YY)k! z6z4WDtM%BFN@v(|=Vidku>4xyH4Ri7YQw=UikQ$9@+QAR9*wWgO4V1Or4R*0bOySN zlVoB`)Y^J?HXNh%6zyn1@IFSx>J~{xN~`aR>e7DsnXwN?rHFIU5$}~YcYp;CdL)bU zeb{Ev$FlCJtb1?v-z-M-IajZw6+8Lp`#g{PXMPgDt>4b%mS`56NUJNCRrLQW*2p-* zO4?O-v{lLrU&|St{Pbkln3q{ZZ7nkuT}V;RDWpVZ`VFWC0G`Av_TCD2@!om-b;VROL z>H>^?#rvl1opYG7VDROfWD)uJm3(Ug!-Vqz3kox|%ktn##RN0x@S=b|c~ck(lxVpq zwd$w^mWAq_7`_Zybcj8P0d!Ykz_Ob}KSH(Hw~z_(5dG*9W<6q_=)%@@oMzJdq39*N z910rNeA28f=1bLI&xbXG5A4*p2LgoIW`<$BVL7NY4n$VL-gQttzWAL;sQ(a_@-Oza z*U=LE>ESJWV{x?zj|j3v-i(IVX>@+}tYMQ>2ncR0ax@2V{}b*F$b`7RW0uFPl=BG{ zdQBi%BY0BsBkjSdio7vyS<%1MZRVo0lh) zhG@(W(}>k6KYMu9VatbbkNiwa6>607H>ykM8MUOPHTEuc#+)-~v@4BYWds>sFfIZ$ zf@C-*R*gcqHo;r~spZmQ%iJv&0=1NBe{)pCg%@H(Jwx3uW;)D;l$Jgg)^M5-=)7ZU z0-P~agPzsp;{N0k(X;_CpyI$-*t1TmfJx10OS=3D158`6$PUtmHMM@64*E8=xNe~EPKc!gXiyg`NYld{|wy#%eo z4p3z81x6x;$1G!b;n^x~!j9xqY*Vl3UAE0@pBkLJEqB=_mm<<96)gE3AIuF~YOQq= z05Gf(gYg=8VtdE#+c?WO3KqUo90jph3P)MKaT56iNg9W-yw8Sk^>-?qdAsjDpX2FaQJ>&|^!3yGm=Z62V zF8)}fA*hwC?huhAfpXX~lKG4~l#z$BFx$2Td?+$LJZ2!($;A|sn>eceBSxp0oEDLa zI7Su(EaS>EOC+fiCe%6}Ue3(DrO)ahZu#tSDH6##&{Ah8xo zvLL_iGdG$AR?GJ_ug68SGnzh#^d@M6(Jo#l@ST!i2Ds|hMYS!URfa(4X15Y3eYR3T zy@f@wV!>N3irlVc?TkaYGd+YL`#$|M-P$mtNZ*0M|Ctt_f(f{?*VV}MWC3D|)Ye)0 z=#}OkQ;ILa$ZN_wo3!U+zEB-SvY-wG8N{VG0fG~5}#wFJaxs}>TQo#W}=Rn>InFc zyyN0mX)8+g7Keb7;y3jK2Ho|ekKX>Bk6zCEHYlYG{{HZfPJC{f9PSzK{qMc~V>kc) zw2s94exh)H5CR{sUD-jaQSyhlkVCj|?^qmiWo_lPU`9O&j=%K&7&*CNsleDoM z`m=9-^UrU;^PB%*>&F)CAHS#oeHzFG%I|9kx_D1<@y_Dn4~mPo6&DAKi(Bm?K%|{_ zi|p)=l5?w7J_GZt6#hWKeVU-%n^qzZX69V=rq1gJhDMr$>C{*{FfuZ>ePF7Yjt}f; zPP&P)fpl_8_oHJ|>CouV)X=~PUr-!$J4VK~4UD9NW6eo>*4#HVIpypVhcD!Js=1r& zK|b@icXVn|<%uy0Or(>|fvI%&(B$OM=ngkE)SOs<5q%6c_oZWdr_!nxi|WcGBq$;9$@dAT4N$?_f^wNy$Yc7W&!?YD~(z z-acG8IyMi^WDZ4hf}Vx(*4UYdi77Jmk<2ZVBV$wgjApuf@8ndvjR_l@WOi<7y3r<6 z31UZ@13bCt;B8DJ+pTe4#MAU*7w9pa+R40( z4Gcm->G)24k29yE$s0h?alEifj&C6duY_ij_DuTu~P(e zqm6O@*xreBYVQO{8!L)2$F1@ybUiqBW6Id!S-$By=q5+Ruq~?2=N1wfWO|22w@ z=V(UA^NGJF2-ZnN8zV4=MPm2wDQH>=EU{D?)LJn-Em|tgJ@1n mz@E(2^+Wp*F5C7`p&#IW8r-{`Z{a$9TGwT+S~~Qr&i#MZ9HGhp literal 0 HcmV?d00001 diff --git a/contracts/tests/CMakeLists.txt b/contracts/tests/CMakeLists.txt index de4b006562..80e8c69338 100644 --- a/contracts/tests/CMakeLists.txt +++ b/contracts/tests/CMakeLists.txt @@ -8,6 +8,7 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/contracts.hpp.in ${CMAKE_CURRENT_BINA 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) # 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 d5a8cd5bf5..013c2d76e9 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -28,6 +28,8 @@ struct contracts { static std::vector uwrit_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.uwrit/sysio.uwrit.abi"); } static std::vector chalg_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.chalg/sysio.chalg.wasm"); } static std::vector chalg_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.chalg/sysio.chalg.abi"); } + static std::vector councl_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.councl/sysio.councl.wasm"); } + static std::vector councl_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.councl/sysio.councl.abi"); } static std::vector reserve_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.reserv/sysio.reserv.wasm"); } static std::vector reserve_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.reserv/sysio.reserv.abi"); } static std::vector dclaim_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.dclaim/sysio.dclaim.wasm"); } diff --git a/contracts/tests/council_math_tests.cpp b/contracts/tests/council_math_tests.cpp new file mode 100644 index 0000000000..8c0fb8698f --- /dev/null +++ b/contracts/tests/council_math_tests.cpp @@ -0,0 +1,110 @@ +/** + * @file council_math_tests.cpp + * @brief Host-side unit tests for the dependency-free council election math + * (`sysio::councl_math`, contracts/sysio.councl/include/sysio.councl/council_math.hpp). + * + * The kernel is pure integer / std::array C++ (no contract intrinsics), so it is exercised + * directly on the host. Coverage: + * - win / elim thresholds at representative electorate sizes and the win+elim-1 == N dual. + * - Strict-priority resolution: outright win, priority hold, elimination-then-promotion, + * all-eliminated fail, deadline / full-turnout fail, and the auto-yes N==1 instant win. + * - Seed helpers: determinism, input sensitivity, in-range mapping, and the empty-set guard. + * + * The exact seed-mixing formula is intentionally tweakable, so tests here assert *properties* + * of the seed→index mapping rather than golden hash values (see DESIGN.md §5, §12). + */ + +#include +#include + +#include +#include + +using namespace sysio::councl_math; + +namespace { + resolution R(std::array yes, std::array no, + uint64_t N, bool all_voted, bool deadline_hit) { + return resolve(yes, no, N, all_voted, deadline_hit); + } +} + +BOOST_AUTO_TEST_SUITE(council_math_tests) + +BOOST_AUTO_TEST_CASE(thresholds) { + BOOST_CHECK_EQUAL(win_threshold(20), 14u); BOOST_CHECK_EQUAL(elim_threshold(20), 7u); + BOOST_CHECK_EQUAL(win_threshold(84), 57u); BOOST_CHECK_EQUAL(elim_threshold(84), 28u); + BOOST_CHECK_EQUAL(win_threshold(1000), 667u);BOOST_CHECK_EQUAL(elim_threshold(1000), 334u); + BOOST_CHECK_EQUAL(win_threshold(1), 1u); BOOST_CHECK_EQUAL(elim_threshold(1), 1u); + BOOST_CHECK_EQUAL(win_threshold(2), 2u); BOOST_CHECK_EQUAL(elim_threshold(2), 1u); + BOOST_CHECK_EQUAL(win_threshold(3), 3u); BOOST_CHECK_EQUAL(elim_threshold(3), 1u); + + // Dual: floor(2n/3)+1 YES to win, ceil(n/3) NO to eliminate, summing to n+1. + for (uint64_t n = 1; n <= 2000; ++n) + BOOST_CHECK_EQUAL(win_threshold(n) + (elim_threshold(n) - 1), n); +} + +BOOST_AUTO_TEST_CASE(candidate_one_wins_outright) { + auto r = R({14, 0, 0}, {0, 0, 0}, 20, false, false); + BOOST_CHECK(r.result == round_result::WIN); + BOOST_CHECK_EQUAL(r.winner_index, 0u); + + // one short, round still open + BOOST_CHECK(R({13, 0, 0}, {0, 0, 0}, 20, false, false).result == round_result::PENDING); +} + +BOOST_AUTO_TEST_CASE(strict_priority_holds_higher_candidate) { + // c2/c3 already at threshold, but c1 alive and below -> must not resolve to c2. + auto r = R({5, 14, 14}, {2, 0, 0}, 20, false, false); + BOOST_CHECK(r.result == round_result::PENDING); +} + +BOOST_AUTO_TEST_CASE(elimination_then_promotion) { + // c1 eliminated (7 no), c2 at threshold -> c2 wins. + auto r2 = R({5, 14, 0}, {7, 0, 0}, 20, false, false); + BOOST_CHECK(r2.result == round_result::WIN); + BOOST_CHECK_EQUAL(r2.winner_index, 1u); + + // c1 & c2 eliminated, c3 at threshold -> c3 wins. + auto r3 = R({7, 7, 14}, {7, 7, 0}, 20, false, false); + BOOST_CHECK(r3.result == round_result::WIN); + BOOST_CHECK_EQUAL(r3.winner_index, 2u); +} + +BOOST_AUTO_TEST_CASE(round_failures) { + // all three eliminated -> FAIL immediately + BOOST_CHECK(R({0, 0, 0}, {7, 7, 7}, 20, false, false).result == round_result::FAIL); + // deadline, active below threshold and not eliminated -> FAIL (escalate) + BOOST_CHECK(R({10, 0, 0}, {3, 0, 0}, 20, false, true).result == round_result::FAIL); + // all voted, active below threshold -> FAIL + BOOST_CHECK(R({10, 0, 0}, {10, 0, 0}, 20, true, false).result == round_result::FAIL); +} + +BOOST_AUTO_TEST_CASE(degenerate_and_full_turnout) { + // tier-2/3 N==1 with proposer auto-yes seeded -> instant win c1 + auto r = R({1, 1, 1}, {0, 0, 0}, 1, true, false); + BOOST_CHECK(r.result == round_result::WIN); + BOOST_CHECK_EQUAL(r.winner_index, 0u); + + // tier-1 full turnout: c1 has 14 yes / 6 no -> win + auto f = R({14, 3, 3}, {6, 17, 17}, 20, true, false); + BOOST_CHECK(f.result == round_result::WIN); + BOOST_CHECK_EQUAL(f.winner_index, 0u); +} + +BOOST_AUTO_TEST_CASE(seed_helpers) { + std::array h1{}; + for (int i = 0; i < 32; ++i) h1[static_cast(i)] = static_cast(i * 7 + 1); + std::array h2 = h1; + h2[0] ^= 0xFF; + + BOOST_CHECK_EQUAL(seed_u64(h1), seed_u64(h1)); // deterministic + BOOST_CHECK(seed_u64(h1) != seed_u64(h2)); // input-sensitive + + for (uint64_t m = 1; m <= 1000; ++m) + BOOST_CHECK_LT(bounded_index(seed_u64(h1), m), m); // always in range + + BOOST_CHECK_EQUAL(bounded_index(12345, 0), 0u); // empty-set guard +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.councl_tests.cpp b/contracts/tests/sysio.councl_tests.cpp new file mode 100644 index 0000000000..af2e2b3581 --- /dev/null +++ b/contracts/tests/sysio.councl_tests.cpp @@ -0,0 +1,439 @@ +/// Integration tests for sysio.councl — the council election contract. +/// +/// SKELETON: these compile and run only once sysio.councl.wasm / sysio.councl.abi exist (build the +/// contract under BUILD_SYSTEM_CONTRACTS=ON with the Wire CDT toolchain, then commit the artifacts +/// and drop the add_subdirectory guard in contracts/CMakeLists.txt). The fixture mirrors +/// sysio.dispute_tests.cpp: it bootstraps sysio.roa (node owners / tiers) and sysio.system +/// (nodecount), then deploys sysio.councl. +/// +/// The escalation tests are deliberately *seed-agnostic*: instead of predicting which tier-2/3 +/// account the entropy accumulator selects, they read `state.proposer` back and drive that account. +/// This keeps the suite stable when the seed formula is tweaked (see DESIGN.md §5, §12). +/// +/// Coverage: +/// * registration -- self-register, duplicate, handle bound, auth, rmcandidate +/// * startinit guards -- <23 candidates, roster size/permutation vs roa tier-1, network_gen +/// * staged load -- loadtier batching + finalizeinit completeness cross-check +/// * tier-1 happy path -- 14/20 yes on c1 fills seat 0 and advances +/// * strict priority -- c1 eliminated (7 no) then c2 wins +/// * repcandidate/vote guards +/// * escalation -- nomination timeout / voting timeout -> tier-2 (read-back proposer) +/// * backstop -- empty tier-2/3 -> BACKSTOP -> forceassign +/// * full election -- drive all 21 seats to DONE + +#include +#include +#include + +#include + +#include +#include + +#include "contracts.hpp" + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; + +using mvo = fc::mutable_variant_object; + +namespace { + +// Mirror of the contract's phase constants (sysio.councl.hpp, namespace councl). +constexpr uint8_t PH_AWAIT_REP = 0, PH_VOTING = 1, PH_BACKSTOP = 2, PH_DONE = 3; +constexpr uint8_t IP_REG = 0, IP_LOADING = 1, IP_READY = 2; + +/// Build a valid account name of the form `` (lowercase a-z only), e.g. +/// name_idx("own", 0) == "owna". Good for up to 26 distinct names per prefix. +name name_idx(const std::string& prefix, size_t i) { + BOOST_REQUIRE_LT(i, 26u); + return name(prefix + static_cast('a' + i)); +} + +} // anonymous namespace + +class sysio_councl_tester : public tester { +public: + static constexpr auto COUNCL_ACCOUNT = "sysio.councl"_n; + static constexpr auto ROA_ACCOUNT = "sysio.roa"_n; + static constexpr uint64_t GEN0 = 0; + static constexpr uint64_t TIME_SLOT = 60; // seconds per attempt window + + // 21 tier-1 owners, plus pools of tier-2 / tier-3 owners and candidates. + std::vector t1_owners; // exactly 21 + std::vector t2_owners; // optional + std::vector t3_owners; // optional + std::vector candidates_; // >= 23 + + abi_serializer councl_abi, roa_abi, system_abi; + + sysio_councl_tester() { + produce_blocks(2); + + create_accounts({COUNCL_ACCOUNT}); + + // Node-owner + candidate accounts. Created without a roa policy so regnodeowner's reslimit + // creation does not collide (matches sysio.dispute_tests). + // The tester genesis (init_roa) already forcereg'd NODE_DADDY at tier 1, and startinit + // requires roa's tier-1 set to be exactly 21 owners — so nodedaddy takes the first roster + // slot and only 20 fresh accounts are created/registered here. + t1_owners.push_back(NODE_DADDY); + for (size_t i = 0; i < 20; ++i) t1_owners.push_back(name_idx("own", i)); + for (const auto& o : t1_owners) + if (o != NODE_DADDY) + create_account(o, config::system_account_name, false, true, /*include_roa_policy=*/false); + for (size_t i = 0; i < 26; ++i) candidates_.push_back(name_idx("cnd", i)); + for (const auto& c : candidates_) + create_account(c, config::system_account_name, false, true, /*include_roa_policy=*/false); + produce_blocks(2); + + // sysio.roa is a genesis system account already running this build's code; just load its abi. + load_abi(ROA_ACCOUNT, roa_abi); + + // Deploy + init sysio.system (setemitcfg + addnodeowner -> nodecount.t{1,2,3}_count). + set_code(config::system_account_name, contracts::system_wasm()); + set_abi(config::system_account_name, contracts::system_abi().data()); + produce_blocks(); + load_abi(config::system_account_name, system_abi); + base_tester::push_action(config::system_account_name, "init"_n, config::system_account_name, + mvo()("version", 0)("core", std::string("4,SYS"))); + setup_emission_config(); + + // Deploy sysio.councl (privileged: rows bill to the sysio RAM pool). + set_code(COUNCL_ACCOUNT, contracts::councl_wasm()); + set_abi(COUNCL_ACCOUNT, contracts::councl_abi().data()); + set_privileged(COUNCL_ACCOUNT); + produce_blocks(); + load_abi(COUNCL_ACCOUNT, councl_abi); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + void load_abi(name account, abi_serializer& out_ser) { + const auto* accnt = control->find_account_metadata(account); + BOOST_REQUIRE(accnt != nullptr); + abi_def parsed; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt->abi, parsed), true); + out_ser.set_abi(std::move(parsed), abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + void setup_emission_config() { + auto cfg = mvo() + ("t1_allocation", int64_t(7500000000000000))("t2_allocation", int64_t(1000000000000000)) + ("t3_allocation", int64_t(100000000000000)) + ("t1_duration", uint32_t(12u*30u*24u*3600u))("t2_duration", uint32_t(24u*30u*24u*3600u)) + ("t3_duration", uint32_t(36u*30u*24u*3600u)) + ("min_claimable", int64_t(10000000000)) + ("t5_distributable", int64_t(375000000000000000LL))("t5_floor", int64_t(125000000000000000LL)) + ("target_annual_decay_bps", uint16_t(6940)) + ("annual_initial_emission", int64_t(563150000000000LL*365)) + ("annual_max_emission", int64_t(3000000000000000LL*365)) + ("annual_min_emission", int64_t(100000000000000LL*365)) + ("compute_bps", uint16_t(4000))("capex_bps", uint16_t(2000))("governance_bps", uint16_t(1000)) + ("producer_bps", uint16_t(7000))("batch_op_bps", uint16_t(3000)) + ("standby_end_rank", uint32_t(28)) + ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); + push(config::system_account_name, system_abi, config::system_account_name, + "setemitcfg"_n, mvo()("cfg", cfg)); + produce_blocks(); + } + + /// Register `owner` at `tier` in sysio.roa::nodeowners (forcereg -> regnodeowner also bumps + /// sysio.system::nodecount.t{tier}_count via addnodeowner). + void register_node_owner(name owner, uint8_t tier) { + BOOST_REQUIRE_EQUAL(success(), push(ROA_ACCOUNT, roa_abi, ROA_ACCOUNT, "forcereg"_n, mvo() + ("owner", owner.to_string())("tier", tier))); + } + + /// Register all 21 tier-1 owners, plus any tier-2 / tier-3 owners requested. + /// NODE_DADDY is skipped: genesis already forcereg'd it, and regnodeowner rejects re-registration. + void register_tiers(size_t n_t2 = 0, size_t n_t3 = 0) { + for (const auto& o : t1_owners) + if (o != NODE_DADDY) register_node_owner(o, 1); + for (size_t i = 0; i < n_t2; ++i) { auto o = name_idx("two", i); mk(o); register_node_owner(o, 2); t2_owners.push_back(o); } + for (size_t i = 0; i < n_t3; ++i) { auto o = name_idx("thr", i); mk(o); register_node_owner(o, 3); t3_owners.push_back(o); } + } + + void mk(name a) { create_account(a, config::system_account_name, false, true, false); produce_block(); } + + // ── generic action push (lands each action in its own block for distinct TaPoS) ───────────── + action_result push(name contract, abi_serializer& ser, name signer, + name action_name, const fc::variant_object& data) { + try { + std::string action_type = ser.get_action_type(action_name); + action act; + act.account = contract; + act.name = action_name; + act.data = ser.variant_to_binary(action_type, data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + act.authorization = std::vector{{signer, config::active_name}}; + signed_transaction trx; + trx.actions.emplace_back(std::move(act)); + set_transaction_headers(trx); + trx.sign(get_private_key(signer, "active"), control->get_chain_id()); + push_transaction(trx); + produce_block(); + return success(); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + } + + // ── councl action wrappers ──────────────────────────────────────────────── + action_result addcandidate(name acct, const std::string& handle) { + return push(COUNCL_ACCOUNT, councl_abi, acct, "addcandidate"_n, mvo()("account", acct.to_string())("handle", handle)); + } + action_result rmcandidate(name acct) { + return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "rmcandidate"_n, mvo()("account", acct.to_string())); + } + action_result startinit(uint64_t slot, const std::vector& owners) { + fc::variants v; for (const auto& o : owners) v.emplace_back(o.to_string()); + return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "startinit"_n, + mvo()("time_slot_sec", slot)("ordered_owners", v)); + } + action_result loadtier(uint8_t tier, uint32_t max_rows) { + return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "loadtier"_n, mvo()("tier", tier)("max_rows", max_rows)); + } + action_result finalizeinit() { + return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "finalizeinit"_n, mvo()); + } + action_result reset() { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "reset"_n, mvo()); } + action_result repcandidate(name proposer, name c1, name c2, name c3) { + return push(COUNCL_ACCOUNT, councl_abi, proposer, "repcandidate"_n, + mvo()("proposer", proposer.to_string())("c1", c1.to_string())("c2", c2.to_string())("c3", c3.to_string())); + } + action_result vote(name voter, bool v1, bool v2, bool v3) { + return push(COUNCL_ACCOUNT, councl_abi, voter, "vote"_n, + mvo()("voter", voter.to_string())("v1", v1)("v2", v2)("v3", v3)); + } + action_result settle() { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "settle"_n, mvo()); } + action_result forceassign(name member) { + return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "forceassign"_n, mvo()("member", member.to_string())); + } + + // ── convenience: bring the contract to READY with `slot`, `n_t2`/`n_t3` extra tiers ────────── + void register_candidates(size_t n) { + for (size_t i = 0; i < n; ++i) BOOST_REQUIRE_EQUAL(success(), addcandidate(candidates_[i], "handle")); + } + void init_ready(size_t n_candidates = 23, size_t n_t2 = 0, size_t n_t3 = 0) { + register_candidates(n_candidates); + register_tiers(n_t2, n_t3); + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + } + + // ── state / config readers ──────────────────────────────────────────────── + fc::variant get_state() { + auto data = get_row_by_account(COUNCL_ACCOUNT, COUNCL_ACCOUNT, "state"_n, "state"_n); + return data.empty() ? fc::variant() : councl_abi.binary_to_variant("election_state", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + fc::variant get_config() { + auto data = get_row_by_account(COUNCL_ACCOUNT, COUNCL_ACCOUNT, "config"_n, "config"_n); + return data.empty() ? fc::variant() : councl_abi.binary_to_variant("config_state", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + uint8_t phase() { return get_state()["phase"].as(); } + uint8_t tier() { return get_state()["tier"].as(); } + uint8_t active_seat() { return get_state()["active_seat"].as(); } + uint8_t seats_filled() { return get_state()["seats_filled"].as(); } + name proposer() { return name(get_state()["proposer"].as_string()); } + + /// Elected member for a filled seat, or the empty name if the seat row is absent. + /// Per-election tables are scoped by the generation, so the scope name's value is election_gen. + name council_member(uint64_t seat) { + auto data = get_row_by_id(COUNCL_ACCOUNT, name(GEN0), "council"_n, seat); + if (data.empty()) return name{}; + auto v = councl_abi.binary_to_variant("council_row", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + return name(v["member"].as_string()); + } + + /// Elapse one full attempt window so a nomination/voting deadline passes, then settle. + void elapse_and_settle() { + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), settle()); + } + + /// The 20 tier-1 owners other than the active proposer (tier-1 electorate). + std::vector tier1_voters_excluding(name p) { + std::vector v; + for (const auto& o : t1_owners) if (o != p) v.push_back(o); + return v; + } +}; + +// =========================================================================== +BOOST_AUTO_TEST_SUITE(sysio_councl_tests) + +// ── registration ────────────────────────────────────────────────────────── +BOOST_FIXTURE_TEST_CASE(registration, sysio_councl_tester) { try { + BOOST_REQUIRE_EQUAL(success(), addcandidate(candidates_[0], "alice")); + BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 1u); + + // duplicate + BOOST_REQUIRE_EQUAL(error("assertion failure with message: already a candidate"), + addcandidate(candidates_[0], "alice")); + // handle too long (> 32 bytes) + BOOST_REQUIRE_EQUAL(error("assertion failure with message: handle length invalid"), + addcandidate(candidates_[1], std::string(33, 'x'))); + // wrong auth: data.account = candidates_[3] but the tx is signed by candidates_[2]. + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[2], "addcandidate"_n, + mvo()("account", candidates_[3].to_string())("handle", "x")) != success()); + + // rmcandidate by governance + BOOST_REQUIRE_EQUAL(success(), rmcandidate(candidates_[0])); + BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 0u); +} FC_LOG_AND_RETHROW() } + +// ── startinit guards ────────────────────────────────────────────────────── +BOOST_FIXTURE_TEST_CASE(startinit_requires_23_candidates, sysio_councl_tester) { try { + register_candidates(22); + register_tiers(); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: need at least 23 registered candidates"), + startinit(TIME_SLOT, t1_owners)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(startinit_roster_must_permute_tier1, sysio_councl_tester) { try { + register_candidates(23); + register_tiers(); + // wrong size + std::vector short_roster(t1_owners.begin(), t1_owners.end() - 1); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: ordered_owners must list all 21 tier-1 owners"), + startinit(TIME_SLOT, short_roster)); + // right size but contains a non-tier-1 account (a candidate) + std::vector bad = t1_owners; bad.back() = candidates_[0]; + BOOST_REQUIRE_EQUAL(error("assertion failure with message: ordered_owners contains a non tier-1 owner"), + startinit(TIME_SLOT, bad)); + // happy + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(get_config()["init_phase"].as(), IP_LOADING); +} FC_LOG_AND_RETHROW() } + +// ── staged load + finalize ──────────────────────────────────────────────── +BOOST_FIXTURE_TEST_CASE(staged_load_and_finalize, sysio_councl_tester) { try { + register_candidates(23); + register_tiers(/*n_t2=*/5, /*n_t3=*/9); + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + // batch tier-2 in two calls, tier-3 in one + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 3)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 3)); // remaining 2 + // finalize before tier-3 loaded -> incomplete + BOOST_REQUIRE_EQUAL(error("assertion failure with message: tier-3 snapshot incomplete"), finalizeinit()); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(get_config()["n2"].as(), 5u); + BOOST_REQUIRE_EQUAL(get_config()["n3"].as(), 9u); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + BOOST_REQUIRE_EQUAL(active_seat(), 0); + BOOST_REQUIRE_EQUAL(proposer().to_string(), t1_owners[0].to_string()); +} FC_LOG_AND_RETHROW() } + +// ── tier-1 happy path: 14 yes on c1 fills seat 0 ────────────────────────── +BOOST_FIXTURE_TEST_CASE(tier1_seat0_win, sysio_councl_tester) { try { + init_ready(); + name p = proposer(); // == t1_owners[0] + name a = candidates_[0], b = candidates_[1], c = candidates_[2]; + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + BOOST_REQUIRE_EQUAL(phase(), PH_VOTING); + + // 14 of the other 20 vote yes on c1 -> win the instant the 14th lands. + auto voters = tier1_voters_excluding(p); + for (int i = 0; i < 14; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], true, false, false)); + + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), a.to_string()); + BOOST_REQUIRE_EQUAL(seats_filled(), 1); + BOOST_REQUIRE_EQUAL(active_seat(), 1); // advanced to next seat + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); +} FC_LOG_AND_RETHROW() } + +// ── strict priority: c1 eliminated (7 no) then c2 wins ──────────────────── +BOOST_FIXTURE_TEST_CASE(strict_priority_promotes_c2, sysio_councl_tester) { try { + init_ready(); + name p = proposer(); + name a = candidates_[0], b = candidates_[1], c = candidates_[2]; + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + auto voters = tier1_voters_excluding(p); + // 7 voters vote NO on c1 (eliminates it) and YES on c2; then 7 more YES on c2 -> c2 at 14. + for (int i = 0; i < 7; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], false, true, false)); + for (int i = 7; i < 14; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], false, true, false)); + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), b.to_string()); +} FC_LOG_AND_RETHROW() } + +// ── repcandidate / vote guards ──────────────────────────────────────────── +BOOST_FIXTURE_TEST_CASE(repcandidate_and_vote_guards, sysio_councl_tester) { try { + init_ready(); + name p = proposer(); + name a = candidates_[0], b = candidates_[1], c = candidates_[2]; + + // not your turn + name not_p = (t1_owners[1] == p) ? t1_owners[2] : t1_owners[1]; + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not your turn to nominate"), + repcandidate(not_p, a, b, c)); + // distinctness + BOOST_REQUIRE_EQUAL(error("assertion failure with message: slate candidates must be distinct"), + repcandidate(p, a, a, b)); + // unregistered candidate + BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate not registered"), + repcandidate(p, a, b, candidates_[25])); // [25] not registered by init_ready (23) + + // open a valid slate, then vote guards + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: the proposer cannot vote on their own slate"), + vote(p, true, false, false)); + auto voters = tier1_voters_excluding(p); + BOOST_REQUIRE_EQUAL(success(), vote(voters[0], true, false, false)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: already voted in this round"), + vote(voters[0], false, false, false)); +} FC_LOG_AND_RETHROW() } + +// ── escalation on nomination timeout: seat 0 -> tier 2 ──────────────────── +BOOST_FIXTURE_TEST_CASE(escalation_to_tier2_on_timeout, sysio_councl_tester) { try { + init_ready(/*n_candidates=*/23, /*n_t2=*/5, /*n_t3=*/0); + BOOST_REQUIRE_EQUAL(tier(), 1); + // tier-1 proposer never nominates; window elapses; settle escalates to tier 2. + elapse_and_settle(); + BOOST_REQUIRE_EQUAL(tier(), 2); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + // The selected tier-2 proposer is read back (seed-agnostic) and must be one of the tier-2 owners. + name p2 = proposer(); + bool is_t2 = false; for (const auto& o : t2_owners) if (o == p2) is_t2 = true; + BOOST_REQUIRE(is_t2); +} FC_LOG_AND_RETHROW() } + +// ── governance backstop when tiers 2 & 3 are empty ──────────────────────── +BOOST_FIXTURE_TEST_CASE(backstop_forceassign, sysio_councl_tester) { try { + init_ready(/*n_candidates=*/23, /*n_t2=*/0, /*n_t3=*/0); // no escalation targets + elapse_and_settle(); // tier-1 nomination times out -> no tier2/3 -> BACKSTOP + BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); + // governance seats an un-elected candidate + BOOST_REQUIRE_EQUAL(success(), forceassign(candidates_[0])); + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), candidates_[0].to_string()); + BOOST_REQUIRE_EQUAL(active_seat(), 1); +} FC_LOG_AND_RETHROW() } + +// ── full election: drive all 21 seats to DONE via tier-1 wins ───────────── +BOOST_FIXTURE_TEST_CASE(full_election_to_done, sysio_councl_tester) { try { + init_ready(/*n_candidates=*/26); // >= 21 winners + slate headroom + size_t next_cand = 0; + auto take = [&]() { return candidates_[next_cand++]; }; + + for (int seat = 0; seat < 21; ++seat) { + BOOST_REQUIRE_EQUAL(active_seat(), seat); + name p = proposer(); + name a = take(), b = take(), c = take(); // 3 fresh un-elected candidates + // return the 2 losers to the pool for reuse on later seats (only the winner is consumed) + next_cand -= 2; + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + auto voters = tier1_voters_excluding(p); + for (int i = 0; i < 14; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], true, false, false)); + BOOST_REQUIRE_EQUAL(council_member(seat).to_string(), a.to_string()); + } + BOOST_REQUIRE_EQUAL(phase(), PH_DONE); + BOOST_REQUIRE_EQUAL(seats_filled(), 21); +} FC_LOG_AND_RETHROW() } + +BOOST_AUTO_TEST_SUITE_END() From dbb784ccaf41465b395f246cccb19f38b0e02c09 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Tue, 14 Jul 2026 09:15:09 -0500 Subject: [PATCH 2/4] Test addition and contract plan --- .../ricardian/sysio.councl.contract.md | 139 ++++++++++++++++++ contracts/tests/sysio.councl_tests.cpp | 49 ++++++ 2 files changed, 188 insertions(+) create mode 100644 contracts/sysio.councl/ricardian/sysio.councl.contract.md diff --git a/contracts/sysio.councl/ricardian/sysio.councl.contract.md b/contracts/sysio.councl/ricardian/sysio.councl.contract.md new file mode 100644 index 0000000000..bf0e647c9a --- /dev/null +++ b/contracts/sysio.councl/ricardian/sysio.councl.contract.md @@ -0,0 +1,139 @@ +

addcandidate

+ +--- +spec_version: "0.2.0" +title: Add Council Candidate +summary: '{{nowrap account}} registers as a council candidate.' +--- + +{{account}} registers as a candidate for the council election with the short handle {{handle}}. + +## Preconditions +- The caller must be authorized as {{account}}. +- Candidate registration must be open (before the election has started). +- {{account}} must not already be a candidate. + +

rmcandidate

+ +--- +spec_version: "0.2.0" +title: Remove Council Candidate +summary: 'Remove candidate {{nowrap account}} before the election starts.' +--- + +The contract owner removes {{account}} from the candidate pool. Allowed only while registration is open. + +

startinit

+ +--- +spec_version: "0.2.0" +title: Start Election Initialization +summary: 'Freeze the tier-1 roster and begin an election.' +--- + +The contract owner begins an election: registration closes, the ordered list of 21 tier-1 node +owners is frozen as the seat roster, and the roa network generation is captured. + +## Preconditions +- The caller must have contract-owner authorization. +- At least 23 candidates must be registered. +- `ordered_owners` must be a permutation of exactly the 21 tier-1 node owners in sysio.roa. + +

loadtier

+ +--- +spec_version: "0.2.0" +title: Load Tier Snapshot +summary: 'Append tier-{{nowrap tier}} node owners into the frozen snapshot.' +--- + +The contract owner appends up to {{max_rows}} tier-{{tier}} node owners from sysio.roa into the +frozen escalation snapshot, resuming from the load cursor. Called repeatedly until complete. + +

finalizeinit

+ +--- +spec_version: "0.2.0" +title: Finalize Election Initialization +summary: 'Verify the tier snapshots and open the first seat.' +--- + +The contract owner finalizes initialization: the tier-2 and tier-3 snapshots are verified complete +against the live node counts, and voting opens for the first council seat. + +

reset

+ +--- +spec_version: "0.2.0" +title: Reset For New Election +summary: 'Start a new election generation and reopen registration.' +--- + +The contract owner starts a fresh election generation and reopens candidate registration. Only +valid once the previous election is complete. + +

repcandidate

+ +--- +spec_version: "0.2.0" +title: Nominate a Candidate Slate +summary: '{{nowrap proposer}} nominates a slate of three candidates.' +--- + +{{proposer}} nominates a slate of three distinct, un-elected candidates ({{first_c}} is c1, +{{second_c}} is c2, {{third_c}} is c3) for the current seat, opening the voting round. + +## Preconditions +- The caller must be authorized as {{proposer}}. +- {{proposer}} must be the active proposer for the current seat. +- The nomination window must not have elapsed. +- The three candidates must be distinct, registered, and not already elected. + +

vote

+ +--- +spec_version: "0.2.0" +title: Vote on the Current Slate +summary: '{{nowrap voter}} votes on the three current-slate candidates.' +--- + +{{voter}} casts an independent yes/no vote on each of the three candidates in the current slate. + +## Preconditions +- The caller must be authorized as {{voter}}. +- Voting must be open for the current slate. +- {{voter}} must be an eligible voter for the active tier and must not be the proposer. +- {{voter}} must not have already voted in this round. + +

settle

+ +--- +spec_version: "0.2.0" +title: Settle Election State +summary: 'Advance a timed-out attempt and stir entropy.' +--- + +A permissionless crank that resolves an elapsed attempt (a missed nomination window or a closed +voting window) and advances the election, while mixing entropy into the accumulator. + +

forceassign

+ +--- +spec_version: "0.2.0" +title: Governance Seat Assignment +summary: 'Assign {{nowrap member}} to the current seat.' +--- + +The contract owner seats {{member}} for the current seat. Valid only when tier-3 has been exhausted +without electing a candidate (the governance backstop). + +

stir

+ +--- +spec_version: "0.2.0" +title: Stir Entropy +summary: 'Advance the entropy accumulator.' +--- + +A permissionless crank that advances the entropy accumulator used for pseudo-random tier-2/tier-3 +proposer selection. diff --git a/contracts/tests/sysio.councl_tests.cpp b/contracts/tests/sysio.councl_tests.cpp index af2e2b3581..5ba86e2707 100644 --- a/contracts/tests/sysio.councl_tests.cpp +++ b/contracts/tests/sysio.councl_tests.cpp @@ -27,6 +27,7 @@ #include +#include #include #include @@ -252,6 +253,15 @@ class sysio_councl_tester : public tester { return name(v["member"].as_string()); } + /// Owner of tier-2 snapshot row `idx`, or the empty name if the row is absent. + name tier2_owner(uint64_t idx) { + auto data = get_row_by_id(COUNCL_ACCOUNT, name(GEN0), "tier2"_n, idx); + if (data.empty()) return name{}; + auto v = councl_abi.binary_to_variant("tier2_row", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + return name(v["owner"].as_string()); + } + /// Elapse one full attempt window so a nomination/voting deadline passes, then settle. void elapse_and_settle() { produce_block(fc::seconds(TIME_SLOT + 1)); @@ -332,6 +342,45 @@ BOOST_FIXTURE_TEST_CASE(staged_load_and_finalize, sysio_councl_tester) { try { BOOST_REQUIRE_EQUAL(proposer().to_string(), t1_owners[0].to_string()); } FC_LOG_AND_RETHROW() } +// ── staged load: roa tier churn between loadtier batches ────────────────── +/// REGRESSION (known bug, currently FAILS): loadtier resumes by *position* (skip-count over the +/// live bytier enumeration), not by identity. A tier-2 owner forcereg'd between batches that +/// sorts before an already-loaded owner shifts the enumeration, so the resumed batch re-writes +/// an already-snapshotted owner (duplicate) and never writes the newcomer — while finalizeinit's +/// count cross-check still passes. The frozen snapshot must be a faithful, duplicate-free copy +/// of roa's tier-2 set (DESIGN.md §11: the election is immune to roa churn only if the snapshot +/// itself is sound). +BOOST_FIXTURE_TEST_CASE(loadtier_roa_churn_mid_load, sysio_councl_tester) { try { + register_candidates(23); + register_tiers(); // the 21 tier-1 owners only + + // Two tier-2 owners present at startinit; bytier enumerates them in name order. + name twob{"twob"}, twoc{"twoc"}; + mk(twob); register_node_owner(twob, 2); + mk(twoc); register_node_owner(twoc, 2); + + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1)); // snapshots "twob" into idx 0 + + // Churn mid-load: a new tier-2 owner that sorts BEFORE the already-loaded "twob". + name twoa{"twoa"}; + mk(twoa); register_node_owner(twoa, 2); + + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); // resumes by skip-count past "twoa" + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); // t2_loaded == nodecount either way + + // Faithful snapshot: rows 0..2 hold exactly {twoa, twob, twoc}, no duplicates. + std::set snapshot; + for (uint64_t i = 0; i < 3; ++i) { + name o = tier2_owner(i); + BOOST_REQUIRE_MESSAGE(snapshot.insert(o).second, + "duplicate owner in tier-2 snapshot: " + o.to_string()); + } + BOOST_CHECK_MESSAGE(snapshot == std::set({twoa, twob, twoc}), + "tier-2 snapshot is not the roa tier-2 set"); +} FC_LOG_AND_RETHROW() } + // ── tier-1 happy path: 14 yes on c1 fills seat 0 ────────────────────────── BOOST_FIXTURE_TEST_CASE(tier1_seat0_win, sysio_councl_tester) { try { init_ready(); From f86761407a890fc2094af7c21fac1dc0ff543e30 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Tue, 14 Jul 2026 10:20:25 -0500 Subject: [PATCH 3/4] Councl: resume loadtier by identity to survive roa churn mid-load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadtier resumed its staged snapshot by position (skip-count over the live bytier enumeration). A tier-2/3 owner forcereg'd between batches that sorted before an already-loaded owner shifted the enumeration, so the resumed batch re-wrote a snapshotted owner (duplicate) and dropped the newcomer, while finalizeinit's count cross-check still passed — silently corrupting the frozen electorate. Skip owners already present in the snapshot's byowner index instead. No positional cursor remains to mis-align, and mid-load newcomers are absorbed by whichever batch encounters them, so t{2,3}_loaded converges on the live nodecount and finalizeinit stays reachable. This also makes loadtier genuinely idempotent, matching DESIGN.md §9. The loadtier_roa_churn_mid_load regression test now passes; wasm rebuilt, ABI unchanged. Co-Authored-By: Claude Fable 5 --- .../include/sysio.councl/sysio.councl.hpp | 4 +++- contracts/sysio.councl/src/sysio.councl.cpp | 14 +++++++++++--- contracts/sysio.councl/sysio.councl.wasm | Bin 31131 -> 31365 bytes contracts/tests/sysio.councl_tests.cpp | 14 +++++++------- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp index 8071b0dd0f..94ad4fa5dc 100644 --- a/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp +++ b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp @@ -77,7 +77,9 @@ class [[sysio::contract("sysio.councl")]] council : public contract { void startinit(uint64_t time_slot_sec, std::vector ordered_owners); /// Append up to `max_rows` of roa's tier-`tier` (2 or 3) owners into the frozen snapshot, - /// resuming from the load cursor. Call repeatedly until complete. Governance only. + /// skipping owners already snapshotted (identity-based resume, so owners forcereg'd in roa + /// mid-load are absorbed by a later batch). Call repeatedly until `finalizeinit`'s count + /// cross-check passes. Idempotent. Governance only. [[sysio::action]] void loadtier(uint8_t tier, uint32_t max_rows); diff --git a/contracts/sysio.councl/src/sysio.councl.cpp b/contracts/sysio.councl/src/sysio.councl.cpp index c14f9dde10..79e4bdd239 100644 --- a/contracts/sysio.councl/src/sysio.councl.cpp +++ b/contracts/sysio.councl/src/sysio.councl.cpp @@ -293,23 +293,31 @@ void council::loadtier(uint8_t tier, uint32_t max_rows) { roa::nodeowners_t no(councl::ROA_ACCOUNT, cfg.network_gen); auto idx = no.get_index<"bytier"_n>(); + // Resume by identity, not position: an owner is skipped iff it is already in the snapshot + // (byowner lookup). A positional (skip-count) cursor mis-resumes when roa's tier set grows + // between batches — a newcomer sorting before the cursor shifts the enumeration, duplicating + // one owner and dropping another while finalizeinit's count cross-check still passes. + // Identity dedup also absorbs such newcomers: whichever batch encounters them appends them, + // so t{2,3}_loaded converges on the live nodecount and finalizeinit stays reachable. const uint32_t already = (tier == 2) ? cfg.t2_loaded : cfg.t3_loaded; - uint32_t seen = 0, written = 0; + uint32_t written = 0; if (tier == 2) { tier2_t t2(get_self(), cfg.election_gen); + auto by_owner = t2.get_index<"byowner"_n>(); for (auto it = idx.lower_bound(static_cast(2)); it != idx.end() && written < max_rows; ++it) { if (it->tier != 2) break; - if (seen < already) { ++seen; continue; } + if (by_owner.find(it->owner.value) != by_owner.end()) continue; // already snapshotted t2.emplace(ram_payer, index_key{already + written}, tier2_row{already + written, it->owner}); ++written; } cfg.t2_loaded += written; } else { tier3_t t3(get_self(), cfg.election_gen); + auto by_owner = t3.get_index<"byowner"_n>(); for (auto it = idx.lower_bound(static_cast(3)); it != idx.end() && written < max_rows; ++it) { if (it->tier != 3) break; - if (seen < already) { ++seen; continue; } + if (by_owner.find(it->owner.value) != by_owner.end()) continue; // already snapshotted t3.emplace(ram_payer, index_key{already + written}, tier3_row{already + written, it->owner}); ++written; } diff --git a/contracts/sysio.councl/sysio.councl.wasm b/contracts/sysio.councl/sysio.councl.wasm index e457ffbd554c12754afaefe27c1fe015415d708b..0cda94f41acb1f90761f152ca45ba2eb1883b3e5 100755 GIT binary patch delta 9704 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 diff --git a/contracts/tests/sysio.councl_tests.cpp b/contracts/tests/sysio.councl_tests.cpp index 5ba86e2707..d95471f65d 100644 --- a/contracts/tests/sysio.councl_tests.cpp +++ b/contracts/tests/sysio.councl_tests.cpp @@ -343,13 +343,13 @@ BOOST_FIXTURE_TEST_CASE(staged_load_and_finalize, sysio_councl_tester) { try { } FC_LOG_AND_RETHROW() } // ── staged load: roa tier churn between loadtier batches ────────────────── -/// REGRESSION (known bug, currently FAILS): loadtier resumes by *position* (skip-count over the -/// live bytier enumeration), not by identity. A tier-2 owner forcereg'd between batches that -/// sorts before an already-loaded owner shifts the enumeration, so the resumed batch re-writes -/// an already-snapshotted owner (duplicate) and never writes the newcomer — while finalizeinit's -/// count cross-check still passes. The frozen snapshot must be a faithful, duplicate-free copy -/// of roa's tier-2 set (DESIGN.md §11: the election is immune to roa churn only if the snapshot -/// itself is sound). +/// REGRESSION: loadtier must resume by *identity* (skip owners already snapshotted), not by +/// position. A skip-count cursor mis-resumed when a tier-2 owner forcereg'd between batches +/// sorted before an already-loaded owner: the shifted enumeration re-wrote a snapshotted owner +/// (duplicate) and never wrote the newcomer, while finalizeinit's count cross-check still +/// passed. Identity-based dedup instead absorbs the newcomer in the next batch, keeping the +/// frozen snapshot a faithful, duplicate-free copy of roa's tier-2 set (DESIGN.md §11: the +/// election is immune to roa churn only if the snapshot itself is sound). BOOST_FIXTURE_TEST_CASE(loadtier_roa_churn_mid_load, sysio_councl_tester) { try { register_candidates(23); register_tiers(); // the 21 tier-1 owners only From 84e094766919fca8db716f140697d37b9cb6ed8d Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 14 Jul 2026 19:40:00 +0000 Subject: [PATCH 4/4] Councl: harden election lifecycle and bounded storage Bound candidate and election storage, bill candidate rows to registrants, replace per-round ballots with a vote bitmap, and use a lazy Fisher-Yates remap for tier-3 selection. Add authenticated settlement and entropy cranks, inclusive deadline handling, stale-action settlement, governance recovery, typed lifecycle state, staged generation cleanup, and complete Ricardian coverage. Expand contract and property tests across authorization, tier escalation, deterministic replay, maximum owner bounds, cleanup, and multi-generation history retention. --- contracts/sysio.councl/CMakeLists.txt | 6 +- contracts/sysio.councl/DESIGN.md | 267 +++-- contracts/sysio.councl/README.md | 29 +- .../include/sysio.councl/council_math.hpp | 32 +- .../include/sysio.councl/sysio.councl.hpp | 266 ++-- ....contract.md => sysio.councl.contracts.md} | 64 +- contracts/sysio.councl/src/sysio.councl.cpp | 628 ++++++---- contracts/sysio.councl/sysio.councl.abi | 275 +++-- contracts/sysio.councl/sysio.councl.wasm | Bin 31365 -> 43929 bytes contracts/tests/council_math_tests.cpp | 89 +- contracts/tests/sysio.councl_tests.cpp | 1066 +++++++++++++---- 11 files changed, 1863 insertions(+), 859 deletions(-) rename contracts/sysio.councl/ricardian/{sysio.councl.contract.md => sysio.councl.contracts.md} (57%) diff --git a/contracts/sysio.councl/CMakeLists.txt b/contracts/sysio.councl/CMakeLists.txt index 187c8a39ed..e28642ac64 100644 --- a/contracts/sysio.councl/CMakeLists.txt +++ b/contracts/sysio.councl/CMakeLists.txt @@ -6,6 +6,7 @@ if(BUILD_SYSTEM_CONTRACTS) file(GLOB_RECURSE SOURCES src/*.cpp) file(GLOB_RECURSE HEADERS include/*.hpp) add_contract(${contract_name} ${contract_name} ${SOURCES}) + target_ricardian_directory(${contract_name} ${CMAKE_CURRENT_SOURCE_DIR}/ricardian) set(targets ${contract_name}) if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) @@ -37,8 +38,11 @@ if(BUILD_SYSTEM_CONTRACTS) ) target_link_libraries(${target} - INTERFACE + PRIVATE magic_enum::magic_enum ) + # cdt-cpp intentionally drops host system include paths. Treat the imported header-only + # target as a normal include so its contract-compatible headers reach the WASM compiler. + set_target_properties(${target} PROPERTIES NO_SYSTEM_FROM_IMPORTED TRUE) endforeach() endif() diff --git a/contracts/sysio.councl/DESIGN.md b/contracts/sysio.councl/DESIGN.md index c6f42f0454..b1fce4e2b7 100644 --- a/contracts/sysio.councl/DESIGN.md +++ b/contracts/sysio.councl/DESIGN.md @@ -1,17 +1,17 @@ -# sysio.councl — Design & Implementation Plan (v1) +# sysio.councl — Design and implementation -> Status: **confirmed design.** Supersedes the discarded `multi_index` prototype and the v0 -> draft. Every decision below is **[CONFIRMED]** with the maintainer unless explicitly tagged -> **[GROUNDED]** (dictated by existing on-chain code) or **[NOTE]** (a consequence to be aware of). +> Status: **implemented design.** Supersedes the discarded `multi_index` prototype and the v0 +> draft. **[GROUNDED]** marks behavior dictated by existing on-chain code and **[NOTE]** calls out +> an operational consequence. ## 1. Purpose Fill a **21-seat council**. There is one seat per **tier-1 node owner** (from `sysio.roa`). Each seat is filled by electing one **candidate** from a shared candidate pool. The right to *propose* a seat's slate of 3 candidates starts with that seat's tier-1 owner and **escalates** -through tier-2 and then tier-3 node owners if the earlier tier fails to elect anyone — so every -seat is guaranteed to fill. When all 21 seats are filled the election is complete and the -council is set. +through tier-2 and then tier-3 node owners if the earlier tier fails to elect anyone. Every seat +has a bounded path to a governance backstop; governance must call `forceassign` there to guarantee +eventual completion. When all 21 seats are filled the election is complete and the council is set. Per seat, the flow is: @@ -49,16 +49,17 @@ Per seat, the flow is: |---|----------|--------| | 1 | Slot/seat ordering | `init` receives explicit `ordered_owners[21]`; asserted to be a permutation of exactly the roa tier-1 set. | | 2 | Resolution | **Strict priority** over the 3 candidates (§6), not first-past-the-post. | -| 3 | Timing | Event-driven; single `time_slot_sec` is both the propose-deadline and the voting window, per attempt. | +| 3 | Timing | Event-driven; one inclusive `time_slot_sec` window (maximum 30 days) is used for both nomination and voting. Settlement starts only after the deadline. | | 4 | Tier-1 membership | Snapshot the 21 owners + order at init; ignore later roa churn; fail init if `t1 != 21`. | | 5 | Seat outcome | **Every seat fills** via the T1→T2→T3→governance escalation ladder (§7). No empty seats. | -| 6 | Settlement | Lazy settle at top of every action + a permissionless `settle` action (no on-chain timers). | +| 6 | Settlement | Lazy settlement in election interactions plus authenticated `settle(caller)` and `stir(caller)` cranks (no on-chain timers). A stale nomination or vote commits settlement and returns without applying itself to the new attempt. | | 7 | Candidate exclusivity | An elected candidate leaves the pool; losers may be re-proposed. Floor `MIN_CANDIDATES = 23`. | -| 8 | Candidate registration | Self-register (`require_auth(account)`) before init; `init` closes registration and asserts `>= 23`. | -| 9 | Randomness | In-contract **entropy accumulator**, **Variant B** (block number excluded from the stir), §5. | +| 8 | Candidate registration | Self-register and pay the row RAM before init; registration is capped at 1,000 and init asserts `>= 23`. | +| 9 | Randomness | In-contract **entropy accumulator**, **Variant B** (block number and timestamp excluded), §5. | | 10 | Proposer auto-yes | Tier-2/3 proposer's auto-yes counts for **all three** slate candidates. Tier-1 has no auto-yes. | | 11 | Tier-2/3 selection set | **Full ordered tier-2 and tier-3 owner lists frozen at init**; nth-pick indexes those. | -| 12 | Admin auth / re-runs | `init*` = `require_auth(get_self())`; all tables scoped by `election_gen`. | +| 12 | Admin auth / re-runs | Initialization and cleanup require contract auth. `reset` enters bounded cleanup; `purge` removes ephemeral rows and then advances `election_gen`. Historical council rows remain. | +| 13 | Recovery | Once an active attempt is past its deadline, governance may call `forceback` to enter `BACKSTOP` immediately instead of waiting through every remaining tier-3 attempt. | ## 4. Constants @@ -68,7 +69,9 @@ namespace councl { inline constexpr uint8_t T1_VOTERS = 20; // SEATS - 1 (seat owner never votes on own seat) inline constexpr uint8_t SLATE_SIZE = 3; // candidates per repcandidate inline constexpr uint8_t MIN_CANDIDATES = SEATS + 2; // 23: <=20 elected before the last seat, +3 available - inline constexpr size_t MAX_HANDLE_LEN = 32; // twitter handle cap + inline constexpr size_t MAX_HANDLE_LEN = 32; // candidate-handle byte cap + inline constexpr uint32_t MAX_CANDIDATES = 1000; // registration/RAM bound + inline constexpr uint32_t MAX_TIME_SLOT_SEC = 30 * 24 * 60 * 60; constexpr auto ROA_ACCOUNT = "sysio.roa"_n; constexpr auto SYSTEM_ACCOUNT = "sysio"_n; // owner of sysio.system tables (nodecount) @@ -81,58 +84,53 @@ namespace councl { constexpr uint64_t win_threshold (uint64_t n) { return (2*n)/3 + 1; } constexpr uint64_t elim_threshold(uint64_t n) { return n - (2*n)/3; } } + +static_assert(councl::T1_VOTERS == councl::SEATS - 1); ``` -## 5. Randomness — entropy accumulator (Variant B) [CONFIRMED] +## 5. Randomness — entropy accumulator (Variant B) -A rolling hash of contract activity supplies the pseudo-random seed for tier-2/3 selection. It -is fully deterministic (all nodes replay the same actions in the same order) and needs no block -hash. +A rolling hash of authenticated election activity supplies the pseudo-random seed for tier-2/3 +selection. Candidate registration and initialization do not stir because election state does not +exist yet. Nomination, voting, settlement, recovery, assignment, and explicit stirring do. ```cpp -// in `state` (KV global) -checksum256 acc; // entropy accumulator -uint64_t stir_count; // monotonic; guarantees acc advances even for identical (tag,actor) +// In `state` (KV global). +checksum256 acc; +uint64_t stir_count; -// called at the TOP of EVERY action handler (init*, addcandidate, rmcandidate, repcandidate, -// vote, settle, stir, forceassign). Variant B: block_number is intentionally NOT folded in. void stir(name action_tag, name actor) { - acc = sysio::sha256(pack(acc, action_tag, actor, ++stir_count)); + acc = sha256(pack(acc, action_tag, actor, ++stir_count)); } -// pure, unit-testable -uint64_t seed_u64(const checksum256& s); // fold 256->64 bits (first 8 bytes, LE) -uint64_t select_index(const checksum256& acc, // deterministic index into [0, m) - uint64_t seat, uint64_t round_id, uint64_t m) { - return seed_u64(sysio::sha256(pack(acc, seat, round_id))) % m; // m = available (untried) count +uint64_t select_index(const checksum256& acc, + uint64_t seat, uint64_t round_id, uint64_t available) { + const auto seed = sha256(pack(acc, seat, round_id)); + return seed_u64(seed) % available; } ``` -- **`seat` + `round_id` are folded into the selection seed**, so two re-selections that land in - the same block (e.g. successive tier-3 retries) still get distinct seeds. `round_id` is a - globally monotonic attempt counter (§8) and doubles as the "attempt number." -- **Selection over the untried set:** `m = tierN_size - tried_count`; walk the frozen tier list - skipping tried entries to the `select_index`-th survivor. [NOTE] tier-3 can be up to 1000, so - this walk is O(tier size) KV reads — acceptable because it happens only on a tier-3 escalation, - not on the hot path. - -### Security / grinding [NOTE] -Variant B removes the cheapest manipulation (free per-block resampling by choosing which block to -call `settle` in): with `block_number` out of the stir, a caller must *inject an extra stirring -transaction* to change `acc`, which is visible, costs CPU/RAM, and can be perturbed by any other -participant's action. This is **not** manipulation-proof — true unpredictability against the -party controlling the trigger needs a future-block VRF/beacon this chain doesn't expose. For a -council of node owners the documented accumulator is the accepted trade-off. The permissionless -`stir` action lets honest participants add entropy and advance liveness. +`seed_u64` reads the first eight checksum bytes in **big-endian** order. Golden vectors make this +wire-level choice explicit. Seat and monotonic `round_id` distinguish retries. Tier-3 uses a +persistent lazy Fisher-Yates swap remap: each selection performs constant KV work, removes one +virtual slot, and cannot repeat an owner within the seat. A new seat starts with the full tier-3 +set, so an owner may be selected again for a later seat. + +`settle(caller)` and `stir(caller)` both require `caller` authorization. Neither block number nor +`current_time_point()` is hashed. Excluding the timestamp is deliberate: the transaction's timing +should determine whether settlement is allowed, not provide a caller-chosen extra resampling +input. This remains pseudo-random, deterministic, and grindable—not a cryptographic beacon. A +future-block VRF would be required to remove trigger-party manipulation. Authenticated cranks make +each resampling attributable and non-free while allowing any account to advance liveness. ## 6. Strict-priority resolution [CONFIRMED] One round has a proposer, an electorate size `N`, a slate `c[0..2]`, and per-candidate `yes[i]` and `no[i]` tallies. `T = win_threshold(N)`, `E = elim_threshold(N)`. -- **One ballot per voter**, carrying an independent yes/no for **each** of the 3 candidates - (needed because per-candidate `no` drives elimination). A voter never re-votes as priority - advances. +- **One vote per voter**, carrying an independent yes/no for **each** of the 3 candidates. + A bounded bitmap indexed by the frozen tier snapshot prevents duplicates; individual ballots + are not retained because the aggregate tallies are sufficient for resolution. - **Tier-2/3 only:** the proposer's auto-yes seeds `yes[i] = 1` for **all three** candidates at round start (they advocated the whole slate). **Tier-1:** no auto-yes; the seat owner is not a voter. @@ -145,7 +143,7 @@ Resolution, re-evaluated after every `vote` and inside `settle`: higher-index candidate at/above `T` cannot win while a lower-index one is still alive; it only becomes eligible the instant the lower one is eliminated, which this same check catches.) - **FAIL** the round the moment **all three** are eliminated. -- Otherwise **pending** until `now >= vote_deadline` or every eligible voter has voted; then WIN +- Otherwise **pending** until `now > vote_deadline` or every eligible voter has voted; then WIN if `yes[active] >= T`, else FAIL. Electorate sizes / voter sets: @@ -176,87 +174,104 @@ T1 attempt: proposer = roster[k] (exactly 1 attempt) (propose-deadline), or the voting round FAILS (§6). - **T1 and T2 get exactly one attempt each.** **T3 loops**, choosing a fresh untried tier-3 account (via §5) each time. -- **Exclusion:** a tier-3 account tried for seat `k` is not chosen again for seat `k` (tracked in - `tried3`, scoped per seat). Guarantees the T3 loop makes progress and terminates. +- **Exclusion:** the per-seat lazy Fisher-Yates remap removes each selected tier-3 owner from that + seat's available set. This guarantees progress and termination without an O(n3) survivor scan. - **Backstop:** when every tier-3 account has been tried for seat `k` with no winner (or tier-3 is empty and T2 failed), the seat enters `BACKSTOP`; governance calls `forceassign(member)` to seat an un-elected candidate. This is the termination guarantee for a seat whose electorate simply never produces a super-majority. +- **Recovery:** after the current nomination or voting deadline has elapsed, governance may call + `forceback()` to enter `BACKSTOP` directly. Without intervention the normal retry bound remains + T1 + T2 + up to 1,000 T3 attempts; with intervention, a stalled election needs only the current + slot plus governance response time. + +At the 30-day maximum slot, the protocol-controlled path to `BACKSTOP` is bounded by at most +`21 seats × 1,002 attempts × 2 windows × 30 days = 1,262,520 days`; two windows accounts for a +nomination arriving at its deadline followed by a voting timeout. This deliberately conservative +bound is operationally impractical, which is why `forceback` exists. Final completion still +depends on a governance `forceassign`, so the contract cannot promise a wall-clock completion time +when governance itself is unavailable. - On **WIN** (or `forceassign`): mark the candidate `elected`, write the `council` row, advance to seat `k+1`. When `k+1 == SEATS`, the election is `DONE`. -## 8. Tables (KV), all scoped by `election_gen` - -- **`config`** (`kv::global`): `initialized`, `reg_open`, `phase_init {REG, LOADING, READY}`, - `time_slot_sec`, `network_gen`, `election_gen`, `start_ts`, `n2`, `n3`, and load cursors - (`t2_loaded`, `t3_loaded`). -- **`state`** (`kv::global`): the live election cursor — - `active_seat` (0..20), `tier {1,2,3}`, `phase {AWAIT_REP, VOTING, BACKSTOP, DONE}`, - `proposer`, `round_id` (monotonic attempt counter), `round_open_ts`, `rep_ts`, `vote_deadline`, - `elect_N`, `votes_cast`, `seats_filled`, the current slate `c[3]`, `yes[3]`, `no[3]`, - and the entropy fields `acc`, `stir_count`. -- **`roster`** (`kv::table`, key = seat index → `{owner}`): frozen ordered tier-1 (21). -- **`tier2`** (`kv::table`, key = index → `{owner}`): frozen ordered tier-2 snapshot. -- **`tier3`** (`kv::table`, key = index → `{owner}`): frozen ordered tier-3 snapshot. -- **`candidates`** (`kv::table`, key = `account.value` → `{account, handle, elected}`). -- **`ballots`** (`kv::scoped_table`, scope = `round_id`, key = `voter.value` → `{voter, v[3]}`): - one row per voter per attempt; existence enforces single-vote; scoping by `round_id` keeps - retries isolated. -- **`tried3`** (`kv::scoped_table`, scope = `active_seat`, key = tier-3 index → `{}`): tier-3 - accounts already attempted for this seat. -- **`council`** (`kv::table`, key = seat index → `{seat_owner, filled_tier, proposer, member}`): - the 21 outputs (`seat_owner = roster[k]`, `proposer`/`filled_tier` record who actually - succeeded, `member` is the elected candidate). +## 8. Tables (KV) + +- **`config`** (`kv::global`): `init_phase {REG, LOADING, READY, CLEANING}`, `time_slot_sec`, + `network_gen`, `election_gen`, tier sizes/loaded-row counts, candidate count, and cleanup position. +- **`state`** (`kv::global`): live seat/tier/phase/proposer, round timing, 32-bit electorate and + tally counts, current slate, bounded duplicate-vote bitmap, remaining tier-3 count, and entropy. +- **`roster`**, **`tier2`**, **`tier3`** (`kv::scoped_table`, generation scope): frozen ordered + owner snapshots with a by-owner index. +- **`candidates`** (`kv::scoped_table`, generation scope): account, restricted handle, elected + flag. The candidate pays its row RAM. +- **`tier3remap`** (`kv::scoped_table`, generation/seat scope): only non-identity Fisher-Yates + virtual-to-actual mappings required by constant-time selection. +- **`council`** (`kv::scoped_table`, generation scope): the 21 historical results. These rows are + intentionally retained after reset. + +Rows mirror their primary key in the serialized value (`idx`, `account`, or `seat`) consistently. +This makes ABI-decoded rows self-identifying and secondary-index/debug tooling clearer despite the +small duplication. ## 9. Actions -### Registration (phase_init == REG) -- **`addcandidate(name account, string handle)`** — `require_auth(account)`. `reg_open`. - `handle.size() <= MAX_HANDLE_LEN`. Insert if absent (else "already a candidate"). -- **`rmcandidate(name account)`** — `require_auth(get_self())`. `reg_open`, candidate not yet - frozen. +### Registration (init_phase == REG) +- **`addcandidate(name account, string handle)`** — `require_auth(account)`. Require 1–32 bytes + drawn from ASCII alphanumeric, `@`, `_`, `-`, and `.`; insert with `account` as RAM payer while + the count is below `MAX_CANDIDATES`. +- **`rmcandidate(name account)`** — `require_auth(get_self())`; remove during `REG`. ### Init (multi-step, to bound per-transaction work) [NOTE] Tier-3 can hold up to 1000 rows, too many to read+write in one transaction, so init is staged: - **`startinit(uint64 time_slot_sec, std::vector ordered_owners)`** — `require_auth(get_self())`. - 1. `check(!config.initialized || state.phase == DONE)` (re-run bumps `election_gen`, wipes gen scope). - 2. `check(time_slot_sec > 0)` and `check(count(candidates) >= MIN_CANDIDATES)`. + 1. Require `init_phase == REG`; a completed prior generation must first use `reset`/`purge`. + 2. Require `0 < time_slot_sec <= MAX_TIME_SLOT_SEC` and at least `MIN_CANDIDATES`. 3. Read `roa::roastate` → `network_gen`. Enumerate roa tier-1 via `bytier`; `check(size == 21)` and `check(ordered_owners` is a permutation of that set`)`. Freeze `roster[0..20]`. - 4. `phase_init = LOADING`; reset load cursors; `reg_open = false`. + 4. Set `init_phase = LOADING` and reset the loaded-row counts/next snapshot indices. - **`loadtier(uint8 tier, uint32 max_rows)`** — `require_auth(get_self())`, `LOADING` only. - Appends up to `max_rows` of roa's tier-2 or tier-3 owners (resuming from the cursor) into the - `tier2`/`tier3` snapshot in `bytier` order. Idempotent; call repeatedly until the cursor - reaches the tier's end. + Appends up to `max_rows` of roa's tier-2 or tier-3 owners into the `tier2`/`tier3` snapshot in + `bytier` order. Idempotent; the stored progress value is a loaded-row count/next snapshot index, + while each call rescans the live tier and skips identities already frozen. - **`finalizeinit()`** — `require_auth(get_self())`, `LOADING` only. 1. `check` the tier-2/tier-3 snapshots are complete vs `sysio.system::nodecount` (`n2 == t2_count`, `n3 == t3_count`) — the completeness cross-check. 2. Initialize `state`: `active_seat = 0`, `tier = 1`, `phase = AWAIT_REP`, - `proposer = roster[0]`, `round_id = 0`, `round_open_ts = now`, `acc = sha256(election_gen)`, - `seats_filled = 0`. `phase_init = READY`, `initialized = true`. + `proposer = roster[0]`, `round_id = 1`, `round_open_ts = now`, + `acc = sha256(pack((ACC_SEED_TAG, election_gen)))`, where `ACC_SEED_TAG` is the + `name` value `"councilseed"_n`, + `seats_filled = 0`, `tier3_available = n3`. Set `init_phase = READY`. + +### Generation cleanup +- **`reset()`** — contract auth, `DONE` only. Enter `CLEANING`; do not advance the generation yet. +- **`purge(max_rows)`** — contract auth. Delete at most `max_rows` ephemeral candidate, snapshot, + and remap rows across resumable calls. Council history is not deleted. Once cleanup completes, + increment `election_gen` and reopen `REG`. ### Election - **`repcandidate(name proposer, name c1, name c2, name c3)`** — `require_auth(proposer)`. - `stir("repcandidate", proposer)`, then `settle_if_elapsed()`. + `stir("repcandidate", proposer)`, then `resolve_or_settle()`. `check(phase == AWAIT_REP && proposer == state.proposer)`; `check(now <= round_open_ts + time_slot_sec)` (propose-deadline); validate slate (3 distinct, each exists, each `!elected`). Open the round: set `c[]`, `no[] = {0,0,0}`, `yes[] = tier==1 ? {0,0,0} : {1,1,1}` (auto-yes), - `elect_N`, `votes_cast = 0`, `phase = VOTING`, `rep_ts = now`, + `elect_N`, `votes_cast = 0`, `phase = VOTING`, `vote_deadline = now + time_slot_sec`. Immediately `try_resolve()` (covers `n==1`). - **`vote(name voter, bool v1, bool v2, bool v3)`** — `require_auth(voter)`. - `stir("vote", voter)`, then `settle_if_elapsed()`. `check(phase == VOTING)`; + `stir("vote", voter)`, then `resolve_or_settle()`. `check(phase == VOTING)`; `check(voter` is eligible for the current tier and `!= proposer)`; - `check(no ballot exists for voter in round_id)`. Record ballot; for each true `v`, `++yes[i]`; + `check` and set the voter's frozen-snapshot bitmap bit; for each true `v`, `++yes[i]`; for each false, `++no[i]`; `++votes_cast`. `try_resolve()`. -- **`settle()`** — no auth (permissionless). `stir("settle", get_self())` then - `settle_if_elapsed()`. Lets anyone push a timed-out attempt forward and inject entropy. +- **`settle(name caller)`** — `require_auth(caller)`. Stir, then resolve or advance elapsed state. +- **`stir(name caller)`** — `require_auth(caller)`. Stir and perform the same lazy settlement. +- **`forceback()`** — contract auth. After the active nomination/vote deadline has elapsed, move + directly to `BACKSTOP` as the governance recovery policy. - **`forceassign(name member)`** — `require_auth(get_self())`, `phase == BACKSTOP` only. `member` must be an un-elected candidate. Fills the seat and advances. ### Internal helpers (callable from multiple actions per the "many states" requirement) -- **`settle_if_elapsed()`**: if `phase == AWAIT_REP && now > round_open_ts + time_slot_sec` → +- **`resolve_or_settle()`**: if `phase == AWAIT_REP && now > round_open_ts + time_slot_sec` → `fail_attempt()`. If `phase == VOTING` → `try_resolve()` (which no-ops unless a resolve condition is met). - **`try_resolve()`**: apply §6. WIN → `win_attempt(c[active])`. FAIL (all eliminated, or deadline @@ -267,8 +282,8 @@ Tier-3 can hold up to 1000 rows, too many to read+write in one transaction, so i T1→T2 (select), T2→T3 (select), T3→next untried T3 (select) or `BACKSTOP` if exhausted; skip empty tiers. Each new attempt: `++round_id`, `phase = AWAIT_REP`, `round_open_ts = now`, set `proposer`, `elect_N`, clear slate/tallies. -- **`select_proposer(tier)`**: uses §5 over the untried members of `tier2`/`tier3`; records the - pick in `tried3` for tier-3. +- **`select_tier3_proposer()`**: uses §5's mutating Fisher-Yates remap and decrements the available + count, making the tier-3-only side effect explicit. - **`advance_seat()`**: `++active_seat`; if `== SEATS` → `phase = DONE`; else start a fresh T1 attempt for `roster[active_seat]` (`tier = 1`, `++round_id`, `AWAIT_REP`, timers reset). @@ -283,10 +298,13 @@ REG ──startinit──> LOADING ──loadtier*──> finalizeinit ──> R │ (propose-deadline miss: settle → fail_attempt) │ │ │ │ fail_attempt escalates tier: WIN ──win_attempt──> advance_seat - │ T1→T2→T3(loop, exclude tried)→BACKSTOP │ │ + │ T1→T2→T3(loop, no repeats)→BACKSTOP │ │ └──────────────────── (next attempt) ────────────┘ active_seat==21? - BACKSTOP ──forceassign──> win_attempt │ yes → DONE - └─ no → seat k+1 (AWAIT_REP) + elapsed attempt ──forceback──> BACKSTOP ──forceassign──> win_attempt + │ yes → DONE + └─ no → seat k+1 (AWAIT_REP) + +DONE ──reset──> CLEANING ──purge*──> REG (next generation) ``` All timing is relative: an attempt's clock starts at `round_open_ts` (the moment the prior @@ -296,13 +314,14 @@ resolutions compound forward naturally. ## 11. Invariants, determinism, termination - **Termination:** each seat runs T1 (1) + T2 (1) + T3 (≤ `n3`, strictly shrinking untried set) + - an unconditional governance `BACKSTOP`. So a seat always resolves in bounded steps, and the - election always completes exactly `SEATS` seats. No unbounded loop. + an unconditional transition to governance `BACKSTOP`. The automated state machine reaches that + backstop in bounded steps with no unbounded loop; the election completes exactly `SEATS` seats + provided governance performs any required `forceassign`. - **Exactly one candidate per seat**, and a candidate occupies at most one seat (`elected` gate at `repcandidate` + set on win). `MIN_CANDIDATES = 23` guarantees ≥ 3 un-elected candidates remain for the last seat (≤ 20 elected before it). -- **One ballot per voter per attempt** (row existence in `ballots` scoped by `round_id`); the - proposer is never a voter; retries are isolated by `round_id` scope. +- **One vote per voter per attempt** (bounded bitmap reset for every attempt); the proposer is + never a voter. - **Determinism / consensus safety:** integer arithmetic only; time via `current_time_point().sec_since_epoch()`; hashing via `sysio::sha256`; no floats, no UB, no block-hash dependence. `acc` evolves identically on every replaying node (Variant B excludes @@ -310,8 +329,13 @@ resolutions compound forward naturally. - **Cross-contract reads** are read-only point/index lookups against roa's and sysio.system's public KV tables. The snapshots at init make the running election immune to mid-election roa churn. -- **Enum discipline:** tier comparisons use `opp::types::NodeOwnerTier` + `magic_enum` / - `NodeOwnerTier_Name`, never raw `int`, per repo rules. +- **Enum discipline:** election state uses typed contract enums. The action-boundary tier byte is + checked with `magic_enum`; protobuf ROA tiers use their generated enum names/helpers. +- **Storage bound:** at most 1,000 candidate-paid rows; 21 roster + 84 tier-2 + 1,000 tier-3 + system-paid snapshot rows; at most 125 bytes in the vote bitmap; and at most 1,000 remap rows + per seat (21,000 across a worst-case generation). Remap rows are sparse in typical operation. + All of these ephemeral rows are purged before the next generation. Council history grows by + exactly 21 deliberately retained rows per completed generation. ## 12. Test plan @@ -330,38 +354,31 @@ Split by binary per CLAUDE.md; the seed math is deliberately isolated for cheap all-eliminated fail; deadline/all-voted fail; `n==1` auto-win; auto-yes seeding for tier-2/3. **On-chain integration tests (contract test harness):** -- Registration bounds (`MAX_HANDLE_LEN`, dup, auth, `< 23` fails init). +- Registration bounds (handle length/characters, duplicate/auth, candidate cap, `< 23` fails init). - Staged init: `startinit` permutation/`t1==21` checks; `loadtier` batching + resume; `finalizeinit` completeness cross-check vs `nodecount`. - Tier-1 happy path (14 yes → seat filled; strict priority: 1 wins despite 2 having more yes). - Elimination: candidate 1 gets 7 no → candidate 2 becomes active and wins. -- Early termination: all 20 voted; deadline path via `settle`. +- Early termination: all 20 voted; partial-turnout voting deadline via `settle`; exact inclusive + boundary and immediately-after behavior. - Propose-deadline miss → escalate to T2. - T2 flow with auto-yes and `n2`-based threshold; T2 fail → T3. -- T3 loop with `tried3` exclusion; distinct proposers across retries (seed uses `seat`+`round_id`). +- T3 Fisher-Yates exclusion; distinct proposers across retries and reuse on a later seat. - T3 exhaustion → `BACKSTOP` → `forceassign` fills seat. - Empty-tier skip (`n2==0` and/or `n3==0`). -- Full 21-seat run to `DONE`; re-run bumps `election_gen` and isolates state. +- Candidate 3 win after candidates 1 and 2 are eliminated; elected candidate cannot be reused. +- One-member tier auto-yes; empty tier combinations; maximum tier-3 retry/storage behavior. +- Full 21-seat run to `DONE`; bounded reset/purge; second generation isolation and retained council. +- Late nomination/vote settlement-only behavior; authenticated stir/settle; forceback authorization. - Determinism spot-check: same action sequence in a replay yields identical selections. -## 13. Implementation plan - -1. Scaffold `contracts/sysio.councl/`: `include/sysio.councl/sysio.councl.hpp`, - `src/sysio.councl.cpp`, `CMakeLists.txt`; add the subdir to `contracts/CMakeLists.txt` - (currently absent) and register in `contracts_project`. -2. Constants + `win/elim_threshold` + the pure seed/resolver helpers (header-only, so the unit - tests link them without the WASM). -3. KV table structs (§8) and the roa/sysio.system cross-contract read helpers (mirror - `sysio.chalg`). -4. Actions in dependency order: `addcandidate`/`rmcandidate` → `startinit`/`loadtier`/ - `finalizeinit` → `repcandidate` → `vote` → `settle` → `forceassign`, with the internal - `settle_if_elapsed`/`try_resolve`/`win_attempt`/`fail_attempt`/`select_proposer`/`advance_seat`. -5. Ricardian contracts for every action under `ricardian/`. -6. Tests per §12; wire into `contracts/tests/`. -7. Rewrite `README.md` (the current one is a v0 placeholder describing the old flow) to match this - design. -8. Build via `contracts_project`, copy `.wasm`/`.abi` back to the source tree per CLAUDE.md, and - regenerate client types. +## 13. Validation and artifacts + +The contract is built only through the repository's `contracts_project` target with Wire CDT. +Both `council_math_tests` and `sysio_councl_tests` belong to `contracts_unit_test`. The generated +ABI and WASM are copied back beside the source, and their hashes are compared with a clean rebuild +before release. Ricardian contracts are sourced from `ricardian/sysio.councl.contracts.md`; every +action parameter placeholder must match the ABI. ## 14. Notes deferred / minor diff --git a/contracts/sysio.councl/README.md b/contracts/sysio.councl/README.md index 1dc3f0635c..f4acb6ed77 100644 --- a/contracts/sysio.councl/README.md +++ b/contracts/sysio.councl/README.md @@ -3,7 +3,8 @@ Council election contract. Fills 21 council seats — one per tier-1 node owner — by electing a candidate from a shared pool. The right to *propose* a seat's slate of 3 candidates starts with that seat's tier-1 owner and **escalates** through tier-2 and tier-3 node owners if the earlier -tier fails to elect anyone, so every seat is guaranteed to fill. See +tier fails to elect anyone, ending in a governance backstop that can fill every seat. Final +completion requires governance to act at that backstop. See [DESIGN.md](DESIGN.md) for the full model and rationale. > Implemented against the modern KV table stack (`kv::table` / `kv::scoped_table` / `kv::global`) @@ -30,8 +31,8 @@ tier fails to elect anyone, so every seat is guaranteed to fill. See | Elimination | a candidate is out at `ceil(N/3)` NO (can no longer reach the threshold) | | Tier-1 | `N = 20` (the other tier-1 owners); the seat owner does not vote, no auto-yes | | Tier-2 / Tier-3 | `N =` full tier size; the proposer auto-yes counts for all 3 candidates; all other tier members vote | -| Timing | one `time_slot_sec` per attempt for both the nomination window and the voting window | -| Randomness | SHA-256 entropy accumulator over contract activity (block number excluded — "Variant B"); folds in seat + round for collision-free retries | +| Timing | one inclusive `time_slot_sec` window per nomination and vote; bounded to 30 days | +| Randomness | SHA-256 accumulator over authenticated election activity (block number and timestamp excluded); folds in seat + round for retries | ## Actions @@ -42,30 +43,36 @@ tier fails to elect anyone, so every seat is guaranteed to fill. See | `startinit(time_slot_sec, ordered_owners[21])` | contract | Freeze the tier-1 roster; close registration. | | `loadtier(tier, max_rows)` | contract | Batch-load the tier-2/3 snapshot from roa (resumable). | | `finalizeinit()` | contract | Verify snapshots vs `sysio.system::nodecount`; open seat 0. | -| `reset()` | contract | After DONE: bump the generation and reopen registration. | +| `reset()` | contract | After DONE: begin staged cleanup of ephemeral generation state. | +| `purge(max_rows)` | contract | Delete bounded cleanup batches; advance generation and reopen registration when complete. | | `repcandidate(proposer, c1, c2, c3)` | `proposer` | The active proposer nominates a 3-candidate slate. | | `vote(voter, v1, v2, v3)` | `voter` | Independent yes/no on each slate candidate. | -| `settle()` | anyone | Push a timed-out attempt forward; stir entropy. | +| `settle(caller)` | `caller` | Push a timed-out attempt forward; mix the authenticated caller into entropy. | +| `forceback()` | contract | Recovery path: move an elapsed active attempt directly to BACKSTOP. | | `forceassign(member)` | contract | Governance backstop when tier-3 is exhausted. | -| `stir()` | anyone | Advance the entropy accumulator. | +| `stir(caller)` | `caller` | Advance entropy and lazily settle elapsed state. | ## Tables (KV) | Table | Type | Scope | Contents | |-------|------|-------|----------| -| `config` | global | — | init progress, `time_slot_sec`, generation, tier sizes, load cursors | +| `config` | global | — | init progress, `time_slot_sec`, generation, tier sizes, loaded-row counts/next snapshot indices | | `state` | global | — | live cursor (seat/tier/phase/proposer), current slate + tallies, entropy accumulator | | `candidates` | scoped | generation | `account`, `handle`, `elected` | | `roster` / `tier2` / `tier3` | scoped | generation | frozen ordered node-owner snapshots (by-owner secondary index) | -| `ballots` | scoped | (generation, round) | one row per voter per attempt | -| `tried3` | scoped | (generation, seat) | tier-3 proposers already attempted for a seat | +| `state.voted_bitmap` | global field | — | bounded duplicate-vote bitmap (at most one bit per tier member) | +| `tier3remap` | scoped | (generation, seat) | lazy Fisher-Yates remap for O(1) no-repeat tier-3 selection | | `council` | scoped | generation | the 21 filled seats (owner, tier, proposer, member) | ## Lifecycle `addcandidate*` → `startinit` → `loadtier*` → `finalizeinit` → per seat -`repcandidate` + `vote*` (with `settle` cranks) escalating T1→T2→T3→`forceassign` as needed → -all 21 seats filled → `DONE` → optional `reset` for a new generation. +`repcandidate` + `vote*` (with authenticated cranks) escalating T1→T2→T3→`forceassign` as needed → +all 21 seats filled → `DONE` → `reset` → `purge*` → registration for the next generation. + +Candidate rows are billed to the self-registering candidate and registrations are capped at 1,000 +per generation. Council results are retained permanently; candidates, snapshots, and tier-3 remap +state are removed in bounded cleanup batches before the next generation opens. ## Build / status diff --git a/contracts/sysio.councl/include/sysio.councl/council_math.hpp b/contracts/sysio.councl/include/sysio.councl/council_math.hpp index 972057d401..71270c28b3 100644 --- a/contracts/sysio.councl/include/sysio.councl/council_math.hpp +++ b/contracts/sysio.councl/include/sysio.councl/council_math.hpp @@ -20,22 +20,26 @@ namespace sysio::councl_math { /// YES votes needed to elect, for an electorate of size `n`: floor(2n/3) + 1. -inline constexpr uint64_t win_threshold(uint64_t n) { return (2 * n) / 3 + 1; } +inline constexpr uint64_t win_threshold(uint64_t n) { + return (2 * n) / 3 + 1; +} /// NO votes that make a candidate impossible to elect (ceil(n/3)). /// Dual of win_threshold: `win_threshold(n) + (elim_threshold(n) - 1) == n`. -inline constexpr uint64_t elim_threshold(uint64_t n) { return n - (2 * n) / 3; } +inline constexpr uint64_t elim_threshold(uint64_t n) { + return n - (2 * n) / 3; +} /// Outcome of evaluating one voting round. enum class round_result : uint8_t { PENDING = 0, ///< keep collecting votes - WIN = 1, ///< `winner_index` (0..2) has reached the win threshold - FAIL = 2 ///< no candidate can win this round -> escalate + WIN = 1, ///< `winner_index` (0..2) has reached the win threshold + FAIL = 2 ///< no candidate can win this round -> escalate }; struct resolution { round_result result; - uint8_t winner_index; ///< only meaningful when result == WIN + uint8_t winner_index; ///< only meaningful when result == WIN }; /** @@ -50,20 +54,20 @@ struct resolution { * @param yes per-candidate YES tallies (includes tier-2/3 proposer auto-yes seeding) * @param no per-candidate NO tallies * @param N electorate size for this tier - * @param all_voted every eligible voter has cast a ballot + * @param all_voted every eligible voter has cast a vote * @param deadline_hit the voting window has elapsed */ -inline resolution resolve(const std::array& yes, - const std::array& no, - uint64_t N, - bool all_voted, - bool deadline_hit) { +inline constexpr resolution resolve(const std::array& yes, const std::array& no, uint64_t N, + bool all_voted, bool deadline_hit) { const uint64_t T = win_threshold(N); const uint64_t E = elim_threshold(N); int active = -1; for (int i = 0; i < 3; ++i) { - if (no[i] < E) { active = i; break; } + if (no[i] < E) { + active = i; + break; + } } if (active < 0) return {round_result::FAIL, 0}; // all three eliminated @@ -86,6 +90,8 @@ inline uint64_t seed_u64(const std::array& h) { } /// Map a seed to an index in [0, m). Returns 0 when m == 0 (caller must guard empty sets). -inline uint64_t bounded_index(uint64_t seed, uint64_t m) { return m ? seed % m : 0; } +inline uint64_t bounded_index(uint64_t seed, uint64_t m) { + return m ? seed % m : 0; +} } // namespace sysio::councl_math diff --git a/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp index 94ad4fa5dc..f09c3bc3dc 100644 --- a/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp +++ b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp @@ -6,53 +6,63 @@ * escalation ladder with strict-priority slate voting. See DESIGN.md for the full model. */ -#include -#include -#include -#include +#include +#include #include +#include +#include +#include +#include #include -#include - -#include - -#include #include namespace sysio { /// Contract-wide constants and identifiers for sysio.councl. namespace councl { - inline constexpr uint8_t SEATS = 21; ///< tier-1 owners == council seats - inline constexpr uint8_t T1_VOTERS = 20; ///< SEATS - 1 (seat owner never votes) - inline constexpr uint8_t SLATE_SIZE = 3; ///< candidates per repcandidate - inline constexpr uint8_t MIN_CANDIDATES = SEATS + 2;///< 23: <=20 elected before the last seat, +3 - inline constexpr size_t MAX_HANDLE_LEN = 32; ///< twitter-handle byte cap - - constexpr name ROA_ACCOUNT = "sysio.roa"_n; ///< owner of the nodeowners / roastate tables - constexpr name SYSTEM_ACCOUNT = "sysio"_n; ///< owner of the nodecount table; RAM pool payer - - // Election phase (state.phase). uint8 for a stable ABI; named to avoid magic literals. - inline constexpr uint8_t PH_AWAIT_REP = 0; ///< waiting for the active proposer's repcandidate - inline constexpr uint8_t PH_VOTING = 1; ///< a slate is open for voting - inline constexpr uint8_t PH_BACKSTOP = 2; ///< tier-3 exhausted; awaiting governance forceassign - inline constexpr uint8_t PH_DONE = 3; ///< all 21 seats filled - - // Init phase (config.init_phase). - inline constexpr uint8_t IP_REG = 0; ///< candidate registration open - inline constexpr uint8_t IP_LOADING = 1; ///< tier-2/3 snapshots being loaded - inline constexpr uint8_t IP_READY = 2; ///< election running / finished - - // filled_tier sentinel for a governance-assigned seat. - inline constexpr uint8_t TIER_GOV = 0; -} +inline constexpr uint8_t SEATS = 21; ///< tier-1 owners == council seats +inline constexpr uint8_t T1_VOTERS = 20; ///< SEATS - 1 (seat owner never votes) +inline constexpr uint8_t SLATE_SIZE = 3; ///< candidates per repcandidate +inline constexpr uint8_t MIN_CANDIDATES = SEATS + 2; ///< 23: <=20 elected before the last seat, +3 +inline constexpr uint32_t MAX_CANDIDATES = 1000; ///< hard bound on one generation's candidate pool +inline constexpr size_t MAX_HANDLE_LEN = 32; ///< candidate-handle byte cap +inline constexpr uint64_t MAX_TIME_SLOT_SEC = 30 * 24 * 60 * 60; ///< thirty-day operational safety cap + +constexpr name ROA_ACCOUNT = "sysio.roa"_n; ///< owner of the nodeowners / roastate tables +constexpr name SYSTEM_ACCOUNT = "sysio"_n; ///< owner of the nodecount table; RAM pool payer + +/// Lifecycle phase of the active election attempt. +enum class election_phase : uint8_t { + AWAIT_REP = 0, ///< waiting for the active proposer's nomination + VOTING = 1, ///< a slate is open for voting + BACKSTOP = 2, ///< awaiting governance assignment + DONE = 3 ///< all seats are filled +}; + +/// Lifecycle phase of election initialization and generation cleanup. +enum class init_phase : uint8_t { + REG = 0, ///< candidate registration is open + LOADING = 1, ///< tier snapshots are being loaded + READY = 2, ///< election is running or complete + CLEANING = 3 ///< prior-generation ephemeral rows are being purged +}; + +/// Tier responsible for an attempt or completed seat. +enum class election_tier : uint8_t { GOVERNANCE = 0, T1 = 1, T2 = 2, T3 = 3 }; + +/// Ordered cleanup stages used by the batched `purge` action. +enum class cleanup_stage : uint8_t { CANDIDATES = 0, ROSTER = 1, TIER2 = 2, TIER3 = 3, REMAP = 4, COMPLETE = 5 }; + +static_assert(T1_VOTERS == SEATS - 1, "tier-1 electorate must exclude exactly the seat owner"); +} // namespace councl /** * @brief The council election contract. * * Actions fall into three groups: registration (`addcandidate`/`rmcandidate`), staged init - * (`startinit`/`loadtier`/`finalizeinit`, plus `reset`), and the election - * (`repcandidate`/`vote`/`settle`/`forceassign`). `stir` is a permissionless entropy crank. + * (`startinit`/`loadtier`/`finalizeinit`, plus `reset`/`purge`), and the election + * (`repcandidate`/`vote`/`settle`/`forceback`/`forceassign`). `stir` is a public, + * caller-authenticated entropy crank. */ class [[sysio::contract("sysio.councl")]] council : public contract { public: @@ -60,8 +70,8 @@ class [[sysio::contract("sysio.councl")]] council : public contract { // ---- Registration ------------------------------------------------------- - /// Self-register as a council candidate. `handle` is a short (<= MAX_HANDLE_LEN) label - /// (assumed a twitter handle for now). Allowed only while registration is open. + /// Self-register as a council candidate and pay the row RAM. `handle` is a 1..32-byte label + /// restricted to ASCII alphanumeric characters plus `@`, `_`, `-`, and `.`. [[sysio::action]] void addcandidate(name account, std::string handle); @@ -87,33 +97,42 @@ class [[sysio::contract("sysio.councl")]] council : public contract { [[sysio::action]] void finalizeinit(); - /// Start a fresh election generation (after DONE): bump the generation and reopen registration. - /// Governance only. Prior-generation rows remain under their old scope and are ignored. + /// After DONE, enter staged cleanup for the completed generation. Governance only. Call + /// `purge` until cleanup advances the generation and reopens registration. [[sysio::action]] void reset(); + /// Delete up to `max_rows` prior-generation ephemeral rows and finish reset once empty. + /// Council results are deliberately retained as the permanent election history. + [[sysio::action]] + void purge(uint32_t max_rows); + // ---- Election ----------------------------------------------------------- /// The active proposer nominates a slate of 3 distinct, un-elected candidates. [[sysio::action]] void repcandidate(name proposer, name c1, name c2, name c3); - /// Cast an independent yes/no on each of the 3 current-slate candidates. One ballot per voter - /// per attempt; the proposer never votes on their own slate. + /// Cast an independent yes/no on each of the 3 current-slate candidates. One vote per voter + /// per attempt is enforced by a compact bitmap; the proposer never votes on their own slate. [[sysio::action]] void vote(name voter, bool v1, bool v2, bool v3); - /// Permissionless crank: push a timed-out attempt forward and stir entropy. + /// Public caller-authenticated crank: push a timed-out attempt forward and stir entropy. [[sysio::action]] - void settle(); + void settle(name caller); + + /// Governance recovery: move an elapsed active attempt directly to BACKSTOP. + [[sysio::action]] + void forceback(); /// Governance backstop: seat an un-elected candidate when tier-3 is exhausted (phase BACKSTOP). [[sysio::action]] void forceassign(name member); - /// Permissionless entropy crank: advance the accumulator (Variant B — no block number folded). + /// Public caller-authenticated entropy crank; also advances elapsed election state. [[sysio::action]] - void stir(); + void stir(name caller); // ----------------------------------------------------------------------- // Tables @@ -121,50 +140,50 @@ class [[sysio::contract("sysio.councl")]] council : public contract { /// Contract configuration + init progress singleton. struct [[sysio::table("config")]] config_state { - bool initialized = false; - uint8_t init_phase = councl::IP_REG; - bool reg_open = true; + councl::init_phase init_phase = councl::init_phase::REG; uint64_t time_slot_sec = 0; - uint8_t network_gen = 0; ///< roa network generation captured at startinit - uint64_t election_gen = 0; ///< scope for all per-election tables - uint32_t n2 = 0; ///< tier-2 snapshot size (set at finalize) - uint32_t n3 = 0; ///< tier-3 snapshot size - uint32_t t2_loaded = 0; ///< tier-2 load cursor - uint32_t t3_loaded = 0; ///< tier-3 load cursor - uint32_t cand_count = 0; ///< registered candidates (current generation) - - SYSLIB_SERIALIZE(config_state, - (initialized)(init_phase)(reg_open)(time_slot_sec)(network_gen) - (election_gen)(n2)(n3)(t2_loaded)(t3_loaded)(cand_count)) + uint8_t network_gen = 0; ///< roa network generation captured at startinit + uint64_t election_gen = 0; ///< scope for all per-election tables + uint32_t n2 = 0; ///< tier-2 snapshot size (set at finalize) + uint32_t n3 = 0; ///< tier-3 snapshot size + uint32_t t2_loaded = 0; ///< tier-2 loaded-row count and next snapshot index + uint32_t t3_loaded = 0; ///< tier-3 loaded-row count and next snapshot index + uint32_t cand_count = 0; ///< registered candidates (current generation) + councl::cleanup_stage cleanup_stage = councl::cleanup_stage::COMPLETE; + uint8_t cleanup_seat = 0; ///< remap scope currently being purged + + SYSLIB_SERIALIZE( + config_state, + (init_phase)(time_slot_sec)(network_gen)(election_gen)(n2)(n3)(t2_loaded)(t3_loaded)(cand_count)(cleanup_stage)(cleanup_seat)) }; using config_t = sysio::kv::global<"config"_n, config_state>; /// Live election cursor + current round tallies + entropy accumulator. struct [[sysio::table("state")]] election_state { - uint8_t phase = councl::PH_AWAIT_REP; - uint8_t active_seat = 0; ///< 0..20 - uint8_t tier = 1; ///< 1, 2, or 3 - name proposer{}; - uint64_t round_id = 0; ///< monotonic attempt counter (also selection nonce) - time_point round_open_ts{}; ///< when the current attempt opened (propose-deadline base) + councl::election_phase phase = councl::election_phase::AWAIT_REP; + uint8_t active_seat = 0; ///< 0..20 + councl::election_tier tier = councl::election_tier::T1; + name proposer{}; + uint64_t round_id = 0; ///< monotonic attempt counter (also selection nonce) + time_point round_open_ts{}; ///< when the current attempt opened (propose-deadline base) time_point vote_deadline{}; - uint64_t elect_N = 0; ///< electorate size of the current round - uint32_t eligible_voters = 0; ///< voters expected this round (excludes the proposer) - uint32_t votes_cast = 0; - uint32_t tried_count = 0; ///< tier-3 accounts tried for the current seat - uint8_t seats_filled = 0; + uint32_t elect_N = 0; ///< electorate size of the current round + uint32_t eligible_voters = 0; ///< voters expected this round (excludes the proposer) + uint32_t votes_cast = 0; + uint32_t tier3_available = 0; ///< untried tier-3 proposers for the current seat + uint8_t seats_filled = 0; + std::vector voted_bitmap; ///< one bit per frozen tier member; prevents duplicate votes // current slate + independent per-candidate tallies - name c1{}, c2{}, c3{}; - uint64_t yes1 = 0, yes2 = 0, yes3 = 0; - uint64_t no1 = 0, no2 = 0, no3 = 0; + name c1{}, c2{}, c3{}; + uint32_t yes1 = 0, yes2 = 0, yes3 = 0; + uint32_t no1 = 0, no2 = 0, no3 = 0; // entropy accumulator (Variant B) checksum256 acc{}; - uint64_t stir_count = 0; + uint64_t stir_count = 0; - SYSLIB_SERIALIZE(election_state, - (phase)(active_seat)(tier)(proposer)(round_id)(round_open_ts)(vote_deadline) - (elect_N)(eligible_voters)(votes_cast)(tried_count)(seats_filled) - (c1)(c2)(c3)(yes1)(yes2)(yes3)(no1)(no2)(no3)(acc)(stir_count)) + SYSLIB_SERIALIZE( + election_state, + (phase)(active_seat)(tier)(proposer)(round_id)(round_open_ts)(vote_deadline)(elect_N)(eligible_voters)(votes_cast)(tier3_available)(seats_filled)(voted_bitmap)(c1)(c2)(c3)(yes1)(yes2)(yes3)(no1)(no2)(no3)(acc)(stir_count)) }; using state_t = sysio::kv::global<"state"_n, election_state>; @@ -181,27 +200,30 @@ class [[sysio::contract("sysio.councl")]] council : public contract { // (one value struct per table name). The shape is shared via the SYSLIB_SERIALIZE field list. struct [[sysio::table("roster")]] roster_row { uint64_t idx; - name owner; + name owner; uint64_t by_owner() const { return owner.value; } SYSLIB_SERIALIZE(roster_row, (idx)(owner)) }; struct [[sysio::table("tier2")]] tier2_row { uint64_t idx; - name owner; + name owner; uint64_t by_owner() const { return owner.value; } SYSLIB_SERIALIZE(tier2_row, (idx)(owner)) }; struct [[sysio::table("tier3")]] tier3_row { uint64_t idx; - name owner; + name owner; uint64_t by_owner() const { return owner.value; } SYSLIB_SERIALIZE(tier3_row, (idx)(owner)) }; - using roster_t = sysio::kv::scoped_table<"roster"_n, index_key, roster_row, + using roster_t = sysio::kv::scoped_table< + "roster"_n, index_key, roster_row, sysio::kv::index<"byowner"_n, sysio::const_mem_fun>>; - using tier2_t = sysio::kv::scoped_table<"tier2"_n, index_key, tier2_row, + using tier2_t = sysio::kv::scoped_table< + "tier2"_n, index_key, tier2_row, sysio::kv::index<"byowner"_n, sysio::const_mem_fun>>; - using tier3_t = sysio::kv::scoped_table<"tier3"_n, index_key, tier3_row, + using tier3_t = sysio::kv::scoped_table< + "tier3"_n, index_key, tier3_row, sysio::kv::index<"byowner"_n, sysio::const_mem_fun>>; /// A registered candidate. @@ -211,70 +233,74 @@ class [[sysio::contract("sysio.councl")]] council : public contract { SYSLIB_SERIALIZE(cand_key, (account)) }; struct [[sysio::table("candidates")]] candidate_row { - name account; + name account; std::string handle; - bool elected = false; + bool elected = false; SYSLIB_SERIALIZE(candidate_row, (account)(handle)(elected)) }; using candidates_t = sysio::kv::scoped_table<"candidates"_n, cand_key, candidate_row>; - /// One voter's ballot in an attempt. Scoped by (generation, round_id); existence = has-voted. - struct voter_key { - uint64_t voter; - uint64_t primary_key() const { return voter; } - SYSLIB_SERIALIZE(voter_key, (voter)) + /// Fisher-Yates virtual-to-actual index remap for O(1) tier-3 selection. + struct [[sysio::table("tier3remap")]] remap_row { + uint64_t virtual_idx; + uint64_t actual_idx; + SYSLIB_SERIALIZE(remap_row, (virtual_idx)(actual_idx)) }; - struct [[sysio::table("ballots")]] ballot_row { - name voter; - bool v1, v2, v3; - SYSLIB_SERIALIZE(ballot_row, (voter)(v1)(v2)(v3)) - }; - using ballots_t = sysio::kv::scoped_table<"ballots"_n, voter_key, ballot_row>; - - /// A tier-3 index already attempted for the current seat. Scoped by (generation, seat). - struct [[sysio::table("tried3")]] tried_row { - uint64_t idx; - SYSLIB_SERIALIZE(tried_row, (idx)) - }; - using tried3_t = sysio::kv::scoped_table<"tried3"_n, index_key, tried_row>; + using tier3_remap_t = sysio::kv::scoped_table<"tier3remap"_n, index_key, remap_row>; /// A filled council seat (the 21 outputs). Scoped by generation. struct [[sysio::table("council")]] council_row { uint64_t seat; - name seat_owner; ///< roster[seat] — the tier-1 owner of this seat - uint8_t filled_tier; ///< 1/2/3, or TIER_GOV for a governance assignment - name proposer; ///< the account whose slate won - name member; ///< the elected candidate + name seat_owner; ///< roster[seat] — the tier-1 owner of this seat + councl::election_tier filled_tier; ///< tier that filled this seat, or GOVERNANCE + name proposer; ///< the account whose slate won + name member; ///< the elected candidate SYSLIB_SERIALIZE(council_row, (seat)(seat_owner)(filled_tier)(proposer)(member)) }; using council_t = sysio::kv::scoped_table<"council"_n, index_key, council_row>; private: - using NodeOwnerTier = opp::types::NodeOwnerTier; - - // Per-election table scope is the generation; per-round/seat tables fold the generation in. - static uint64_t gr_scope(uint64_t gen, uint64_t x) { return (gen << 40) | (x & ((uint64_t(1) << 40) - 1)); } + /// Fold a generation and bounded seat-like value into one scoped-table key. + static uint64_t gr_scope(uint64_t gen, uint64_t x); // Entropy + selection - void do_stir(election_state& st, name action_tag, name actor); - name select_from_tier(election_state& st, uint8_t tier, uint32_t avail, - const config_state& cfg); + /// Mix an authenticated action tag, actor, and monotonic stir count into the accumulator. + void do_stir(election_state& st, name action_tag, name actor); + /// Derive a deterministic virtual index for the current seat and round. + uint64_t selection_index(const election_state& st, uint32_t available) const; + /// Select the tier-2 proposer without mutating the frozen snapshot. + name select_tier2_proposer(const election_state& st, const config_state& cfg) const; + /// Select and remove one tier-3 proposer through the persistent Fisher-Yates remap. + name select_tier3_proposer(election_state& st, const config_state& cfg); // State machine - void settle_if_elapsed(election_state& st, const config_state& cfg); + /// Resolve completed voting or advance an elapsed nomination/voting attempt. + void resolve_or_settle(election_state& st, const config_state& cfg); + /// Evaluate the current slate and apply a win or failed-attempt transition when conclusive. void try_resolve(election_state& st, const config_state& cfg); + /// Persist a filled council seat and advance the election cursor. + void seat_member(election_state& st, const config_state& cfg, name member, councl::election_tier filled_tier, + name proposer); + /// Complete the current attempt with its strict-priority winner. void win_attempt(election_state& st, const config_state& cfg, name winner); + /// Escalate a failed attempt or enter the governance backstop when no proposer remains. void fail_attempt(election_state& st, const config_state& cfg); - void open_tier_attempt(election_state& st, const config_state& cfg, uint8_t tier, name proposer); + /// Open a fresh nomination attempt for the specified tier and proposer. + void open_tier_attempt(election_state& st, councl::election_tier tier, name proposer); + /// Advance to the next seat, or mark the election done after the final seat. void advance_seat(election_state& st, const config_state& cfg); // roa / nodecount helpers - uint8_t roa_network_gen() const; - uint32_t tier_count(uint8_t tier) const; // from sysio.system::nodecount + /// Read the current ROA network generation. + uint8_t roa_network_gen() const; + /// Read a tier owner count from `sysio.system::nodecount`. + uint32_t tier_count(councl::election_tier tier) const; // convenience - name roster_owner(const config_state& cfg, uint8_t seat) const; - bool is_member(const config_state& cfg, uint8_t tier, name who) const; + /// Return the frozen tier-1 owner associated with a council seat. + name roster_owner(const config_state& cfg, uint8_t seat) const; + /// Return a frozen tier member's stable snapshot index, or an invalid sentinel. + uint32_t member_index(const config_state& cfg, councl::election_tier tier, name who) const; }; } // namespace sysio diff --git a/contracts/sysio.councl/ricardian/sysio.councl.contract.md b/contracts/sysio.councl/ricardian/sysio.councl.contracts.md similarity index 57% rename from contracts/sysio.councl/ricardian/sysio.councl.contract.md rename to contracts/sysio.councl/ricardian/sysio.councl.contracts.md index bf0e647c9a..21979583ce 100644 --- a/contracts/sysio.councl/ricardian/sysio.councl.contract.md +++ b/contracts/sysio.councl/ricardian/sysio.councl.contracts.md @@ -6,12 +6,14 @@ title: Add Council Candidate summary: '{{nowrap account}} registers as a council candidate.' --- -{{account}} registers as a candidate for the council election with the short handle {{handle}}. +{{account}} registers as a candidate for the council election with the short handle {{handle}} and +pays the RAM for that candidate row. A generation accepts at most 1,000 candidates. ## Preconditions - The caller must be authorized as {{account}}. - Candidate registration must be open (before the election has started). - {{account}} must not already be a candidate. +- The handle must use the contract's allowed 1–32-byte ASCII character set.

rmcandidate

@@ -37,6 +39,7 @@ owners is frozen as the seat roster, and the roa network generation is captured. ## Preconditions - The caller must have contract-owner authorization. - At least 23 candidates must be registered. +- `time_slot_sec` must be between one second and 30 days, inclusive. - `ordered_owners` must be a permutation of exactly the 21 tier-1 node owners in sysio.roa.

loadtier

@@ -48,7 +51,8 @@ summary: 'Append tier-{{nowrap tier}} node owners into the frozen snapshot.' --- The contract owner appends up to {{max_rows}} tier-{{tier}} node owners from sysio.roa into the -frozen escalation snapshot, resuming from the load cursor. Called repeatedly until complete. +frozen escalation snapshot. Resume is identity-based, so newly observed owners are appended without +duplicating owners already loaded. Called repeatedly until complete.

finalizeinit

@@ -59,18 +63,31 @@ summary: 'Verify the tier snapshots and open the first seat.' --- The contract owner finalizes initialization: the tier-2 and tier-3 snapshots are verified complete -against the live node counts, and voting opens for the first council seat. +against the live node counts, and the first council seat's nomination window opens.

reset

--- spec_version: "0.2.0" -title: Reset For New Election -summary: 'Start a new election generation and reopen registration.' +title: Begin Election Reset +summary: 'Begin staged cleanup of the completed election generation.' --- -The contract owner starts a fresh election generation and reopens candidate registration. Only -valid once the previous election is complete. +The contract owner starts cleanup of ephemeral rows from a completed election. Council result rows +remain as permanent history. The contract owner must call `purge` until cleanup completes and +candidate registration reopens. + +

purge

+ +--- +spec_version: "0.2.0" +title: Purge Election State +summary: 'Delete up to {{max_rows}} ephemeral rows from the completed generation.' +--- + +The contract owner deletes at most {{max_rows}} candidates, roster entries, tier snapshots, and +tier-3 remap entries from the completed generation. When all ephemeral rows have been removed, the +generation advances and registration reopens. Council result rows are retained.

repcandidate

@@ -80,8 +97,8 @@ title: Nominate a Candidate Slate summary: '{{nowrap proposer}} nominates a slate of three candidates.' --- -{{proposer}} nominates a slate of three distinct, un-elected candidates ({{first_c}} is c1, -{{second_c}} is c2, {{third_c}} is c3) for the current seat, opening the voting round. +{{proposer}} nominates a slate of three distinct, un-elected candidates ({{c1}}, {{c2}}, and +{{c3}}) for the current seat, opening the voting round. ## Preconditions - The caller must be authorized as {{proposer}}. @@ -110,11 +127,23 @@ summary: '{{nowrap voter}} votes on the three current-slate candidates.' --- spec_version: "0.2.0" title: Settle Election State -summary: 'Advance a timed-out attempt and stir entropy.' +summary: '{{nowrap caller}} advances timed-out election state and stirs entropy.' +--- + +{{caller}} authorizes a public crank that resolves an elapsed attempt and advances the +election while mixing the authenticated caller into the accumulator. + +

forceback

+ +--- +spec_version: "0.2.0" +title: Governance Recovery Backstop +summary: 'Move an elapsed active attempt to governance backstop.' --- -A permissionless crank that resolves an elapsed attempt (a missed nomination window or a closed -voting window) and advances the election, while mixing entropy into the accumulator. +The contract owner moves an elapsed nomination or voting attempt directly to BACKSTOP. This is an +exceptional recovery path for an operationally stalled election and cannot be used before the +active attempt's inclusive deadline has passed.

forceassign

@@ -124,16 +153,17 @@ title: Governance Seat Assignment summary: 'Assign {{nowrap member}} to the current seat.' --- -The contract owner seats {{member}} for the current seat. Valid only when tier-3 has been exhausted -without electing a candidate (the governance backstop). +The contract owner seats {{member}} for the current seat. Valid only at the governance backstop, +reached either after tier-3 exhaustion or through `forceback` recovery of an elapsed attempt.

stir

--- spec_version: "0.2.0" title: Stir Entropy -summary: 'Advance the entropy accumulator.' +summary: '{{nowrap caller}} advances entropy and settles elapsed election state.' --- -A permissionless crank that advances the entropy accumulator used for pseudo-random tier-2/tier-3 -proposer selection. +{{caller}} authorizes a public crank that mixes the authenticated caller into the entropy +accumulator and also advances an elapsed election attempt. Block number and block timestamp are not +entropy inputs. diff --git a/contracts/sysio.councl/src/sysio.councl.cpp b/contracts/sysio.councl/src/sysio.councl.cpp index 79e4bdd239..df311cf7b4 100644 --- a/contracts/sysio.councl/src/sysio.councl.cpp +++ b/contracts/sysio.councl/src/sysio.councl.cpp @@ -1,10 +1,10 @@ +#include +#include +#include #include - #include // roa::roastate_t / roa::nodeowners_t — tier membership #include // sysiosystem::emissions::nodecountstate_t — tier counts -#include - -#include +#include #include #include @@ -14,20 +14,98 @@ using councl_math::resolve; using councl_math::round_result; namespace { - // System-owned rows bill to the sysio RAM pool (privileged-contract model, as sysio.chalg - // does): the council account stays at code+abi size while row growth draws from the pool. - constexpr name ram_payer = councl::SYSTEM_ACCOUNT; - - // Domain-separation tag for the entropy accumulator seed at election start. - constexpr name ACC_SEED_TAG = "councilseed"_n; - - /// sha256 over the packed tuple, returned as a raw 32-byte array (for the pure seed helpers). - template - std::array hash_tuple(const Tuple& t) { - auto packed = pack(t); - checksum256 h = sha256(packed.data(), packed.size()); - return h.extract_as_byte_array(); +// System-owned rows bill to the sysio RAM pool (privileged-contract model, as sysio.chalg +// does): the council account stays at code+abi size while row growth draws from the pool. +constexpr name ram_payer = councl::SYSTEM_ACCOUNT; + +// Domain-separation tag for the entropy accumulator seed at election start. +constexpr name ACC_SEED_TAG = "councilseed"_n; + +constexpr name ACTION_REPCANDIDATE = "repcandi"_n; ///< shortened on-chain tag for repcandidate +constexpr name ACTION_VOTE = "vote"_n; +constexpr name ACTION_SETTLE = "settle"_n; +constexpr name ACTION_FORCE_ASSIGN = "forceasgn"_n; ///< shortened on-chain tag for forceassign +constexpr name ACTION_FORCE_BACKSTOP = "forceback"_n; +constexpr name ACTION_STIR = "stir"_n; + +constexpr uint64_t GR_SCOPE_X_BITS = 40; +constexpr uint64_t GR_SCOPE_GEN_BITS = 64 - GR_SCOPE_X_BITS; +constexpr uint64_t GR_SCOPE_X_LIMIT = uint64_t{1} << GR_SCOPE_X_BITS; +constexpr uint64_t GR_SCOPE_GEN_LIMIT = uint64_t{1} << GR_SCOPE_GEN_BITS; +constexpr uint32_t INVALID_MEMBER_INDEX = std::numeric_limits::max(); +constexpr uint32_t BITS_PER_BYTE = 8; + +using NodeOwnerTier = opp::types::NodeOwnerTier; + +static_assert(councl::SEATS == sysiosystem::emissions::T1_MAX_NODE_OWNERS, + "council seats must match the system tier-1 owner cap"); + +/// Convert a council tier to its stable integer representation for ROA table comparisons. +constexpr uint8_t tier_integer(councl::election_tier tier) { + return magic_enum::enum_integer(tier); +} + +/// Return whether a candidate handle contains only UI-safe printable handle characters. +bool valid_handle(const std::string& handle) { + if (handle.empty() || handle.size() > councl::MAX_HANDLE_LEN) + return false; + for (const unsigned char ch : handle) { + const bool alnum = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9'); + if (!alnum && ch != '@' && ch != '_' && ch != '-' && ch != '.') + return false; + } + return true; +} + +/// Erase at most `max_rows` rows and return the number removed. +template +uint32_t erase_rows(Table& table, uint32_t max_rows) { + uint32_t erased = 0; + auto it = table.begin(); + while (it != table.end() && erased < max_rows) { + it = table.erase(it); + ++erased; } + return erased; +} + +/// Return the stable index of `owner` in a typed frozen snapshot, or the invalid sentinel. +template +uint32_t frozen_member_index(Table& table, name owner) { + auto by_owner = table.template get_index<"byowner"_n>(); + auto it = by_owner.find(owner.value); + return it == by_owner.end() ? INVALID_MEMBER_INDEX : static_cast(it->idx); +} + +/// Append at most `max_rows` not-yet-snapshotted owners of `raw_tier` in deterministic order. +template +uint32_t append_snapshot(Table& table, TierIndex& tier_index, uint8_t raw_tier, uint32_t already, uint32_t max_rows) { + auto by_owner = table.template get_index<"byowner"_n>(); + uint32_t written = 0; + for (auto it = tier_index.lower_bound(raw_tier); it != tier_index.end() && written < max_rows; ++it) { + if (it->tier != raw_tier) + break; + if (by_owner.find(it->owner.value) != by_owner.end()) + continue; + const uint64_t snapshot_index = static_cast(already) + written; + table.emplace(ram_payer, council::index_key{snapshot_index}, Row{snapshot_index, it->owner}); + ++written; + } + return written; +} + +/// Return SHA-256 over the canonical packed representation of a tuple. +template +checksum256 hash_tuple(const Tuple& t) { + auto packed = pack(t); + return sha256(packed.data(), packed.size()); +} +} // namespace + +uint64_t council::gr_scope(uint64_t gen, uint64_t x) { + check(gen < GR_SCOPE_GEN_LIMIT, "election generation exceeds scoped-table encoding"); + check(x < GR_SCOPE_X_LIMIT, "round or seat exceeds scoped-table encoding"); + return (gen << GR_SCOPE_X_BITS) | x; } // =========================================================================== @@ -35,8 +113,7 @@ namespace { // =========================================================================== void council::do_stir(election_state& st, name action_tag, name actor) { ++st.stir_count; - auto packed = pack(std::make_tuple(st.acc, action_tag, actor, st.stir_count)); - st.acc = sha256(packed.data(), packed.size()); + st.acc = hash_tuple(std::make_tuple(st.acc, action_tag, actor, st.stir_count)); } // =========================================================================== @@ -48,11 +125,22 @@ uint8_t council::roa_network_gen() const { return rs.get().network_gen; } -uint32_t council::tier_count(uint8_t tier) const { +uint32_t council::tier_count(councl::election_tier tier) const { sysiosystem::emissions::nodecountstate_t nc(councl::SYSTEM_ACCOUNT); check(nc.exists(), "sysio.system nodecount not initialized"); auto v = nc.get(); - return tier == 1 ? v.t1_count : (tier == 2 ? v.t2_count : v.t3_count); + switch (tier) { + case councl::election_tier::T1: + return v.t1_count; + case councl::election_tier::T2: + return v.t2_count; + case councl::election_tier::T3: + return v.t3_count; + case councl::election_tier::GOVERNANCE: + break; + } + check(false, "governance is not a node-owner tier"); + return 0; } // =========================================================================== @@ -63,132 +151,148 @@ name council::roster_owner(const config_state& cfg, uint8_t seat) const { return roster.get(index_key{seat}, "roster seat missing").owner; } -bool council::is_member(const config_state& cfg, uint8_t tier, name who) const { - if (tier == 1) { +uint32_t council::member_index(const config_state& cfg, councl::election_tier tier, name who) const { + if (tier == councl::election_tier::T1) { roster_t r(get_self(), cfg.election_gen); - auto idx = r.get_index<"byowner"_n>(); - return idx.find(who.value) != idx.end(); + return frozen_member_index(r, who); } - if (tier == 2) { + if (tier == councl::election_tier::T2) { tier2_t t(get_self(), cfg.election_gen); - auto idx = t.get_index<"byowner"_n>(); - return idx.find(who.value) != idx.end(); + return frozen_member_index(t, who); } + check(tier == councl::election_tier::T3, "invalid election tier for membership lookup"); tier3_t t(get_self(), cfg.election_gen); - auto idx = t.get_index<"byowner"_n>(); - return idx.find(who.value) != idx.end(); + return frozen_member_index(t, who); } // =========================================================================== // Pseudo-random tier proposer selection (§5) // =========================================================================== -name council::select_from_tier(election_state& st, uint8_t tier, uint32_t avail, - const config_state& cfg) { +uint64_t council::selection_index(const election_state& st, uint32_t available) const { + check(available > 0, "cannot select from an empty tier"); // Seed folds in the seat and round_id so successive selections (even in one block) differ. const uint64_t seed = councl_math::seed_u64( - hash_tuple(std::make_tuple(st.acc, static_cast(st.active_seat), st.round_id))); - const uint64_t j = councl_math::bounded_index(seed, avail); + hash_tuple(std::make_tuple(st.acc, static_cast(st.active_seat), st.round_id)).extract_as_byte_array()); + return councl_math::bounded_index(seed, available); +} - if (tier == 2) { - tier2_t t2(get_self(), cfg.election_gen); - return t2.get(index_key{j}, "tier-2 index out of range").owner; - } +name council::select_tier2_proposer(const election_state& st, const config_state& cfg) const { + tier2_t t2(get_self(), cfg.election_gen); + return t2.get(index_key{selection_index(st, cfg.n2)}, "tier-2 index out of range").owner; +} - // tier 3: walk indices skipping already-tried entries to the j-th survivor. - tier3_t t3(get_self(), cfg.election_gen); - tried3_t tried(get_self(), gr_scope(cfg.election_gen, st.active_seat)); - uint64_t survivor = 0; - for (uint64_t i = 0; i < cfg.n3; ++i) { - if (tried.contains(index_key{i})) continue; - if (survivor == j) { - tried.emplace(ram_payer, index_key{i}, tried_row{i}); - ++st.tried_count; - return t3.get(index_key{i}, "tier-3 index missing").owner; +name council::select_tier3_proposer(election_state& st, const config_state& cfg) { + // Lazy Fisher-Yates remap: select and remove one virtual slot in O(1) KV operations. + const uint32_t avail = st.tier3_available; + const uint64_t j = selection_index(st, avail); + tier3_remap_t remap(get_self(), gr_scope(cfg.election_gen, st.active_seat)); + const uint64_t last = avail - 1; + const auto selected_key = index_key{j}; + const auto last_key = index_key{last}; + const uint64_t actual = remap.contains(selected_key) ? remap.get(selected_key).actual_idx : j; + const uint64_t replacement = remap.contains(last_key) ? remap.get(last_key).actual_idx : last; + + if (j != last) { + if (remap.contains(selected_key)) { + remap.modify(ram_payer, selected_key, [&](auto& row) { row.actual_idx = replacement; }); + } else { + remap.emplace(ram_payer, selected_key, remap_row{j, replacement}); } - ++survivor; } - check(false, "tier-3 selection out of range"); - return name{}; + if (remap.contains(last_key)) + remap.erase(last_key); + + --st.tier3_available; + tier3_t t3(get_self(), cfg.election_gen); + return t3.get(index_key{actual}, "tier-3 index missing").owner; } // =========================================================================== // State machine internals // =========================================================================== -void council::open_tier_attempt(election_state& st, const config_state& cfg, uint8_t tier, - name proposer) { - st.tier = tier; - st.proposer = proposer; +void council::open_tier_attempt(election_state& st, councl::election_tier tier, name proposer) { + st.tier = tier; + st.proposer = proposer; ++st.round_id; - st.phase = councl::PH_AWAIT_REP; - st.round_open_ts = current_time_point(); - st.vote_deadline = time_point{}; - st.elect_N = 0; + st.phase = councl::election_phase::AWAIT_REP; + st.round_open_ts = current_time_point(); + st.vote_deadline = time_point{}; + st.elect_N = 0; st.eligible_voters = 0; - st.votes_cast = 0; + st.votes_cast = 0; + st.voted_bitmap.clear(); st.c1 = st.c2 = st.c3 = name{}; st.yes1 = st.yes2 = st.yes3 = 0; - st.no1 = st.no2 = st.no3 = 0; + st.no1 = st.no2 = st.no3 = 0; } void council::advance_seat(election_state& st, const config_state& cfg) { ++st.active_seat; - st.tried_count = 0; // tier-3 tried set is scoped per seat; just reset the live counter + st.tier3_available = cfg.n3; if (st.active_seat >= councl::SEATS) { - st.phase = councl::PH_DONE; + st.phase = councl::election_phase::DONE; return; } - open_tier_attempt(st, cfg, 1, roster_owner(cfg, st.active_seat)); + open_tier_attempt(st, councl::election_tier::T1, roster_owner(cfg, st.active_seat)); } void council::fail_attempt(election_state& st, const config_state& cfg) { - uint8_t next = (st.tier == 1) ? 2 : 3; // T1->T2, T2->T3, T3->T3 (retry) - for (;;) { - if (next == 2) { - if (cfg.n2 == 0) { next = 3; continue; } - name p = select_from_tier(st, 2, cfg.n2, cfg); - open_tier_attempt(st, cfg, 2, p); - return; - } - // tier 3 - const uint32_t avail = (cfg.n3 > st.tried_count) ? (cfg.n3 - st.tried_count) : 0; - if (cfg.n3 == 0 || avail == 0) { - st.phase = councl::PH_BACKSTOP; // tier-3 exhausted -> governance fills the seat - return; - } - name p = select_from_tier(st, 3, avail, cfg); - open_tier_attempt(st, cfg, 3, p); + if (st.tier == councl::election_tier::T1 && cfg.n2 > 0) { + name proposer = select_tier2_proposer(st, cfg); + open_tier_attempt(st, councl::election_tier::T2, proposer); + return; + } + + if (st.tier3_available == 0) { + st.phase = councl::election_phase::BACKSTOP; return; } + + name proposer = select_tier3_proposer(st, cfg); + open_tier_attempt(st, councl::election_tier::T3, proposer); } -void council::win_attempt(election_state& st, const config_state& cfg, name winner) { +void council::seat_member(election_state& st, const config_state& cfg, name member, councl::election_tier filled_tier, + name proposer) { candidates_t cands(get_self(), cfg.election_gen); - cands.modify(ram_payer, cand_key{winner.value}, - [&](auto& c) { c.elected = true; }, "winner is not a candidate"); + cands.modify( + same_payer, cand_key{member.value}, + [&](auto& candidate) { + check(!candidate.elected, "candidate already elected to a seat"); + candidate.elected = true; + }, + "member is not a candidate"); council_t council(get_self(), cfg.election_gen); - council.emplace(ram_payer, index_key{st.active_seat}, council_row{ - .seat = st.active_seat, - .seat_owner = roster_owner(cfg, st.active_seat), - .filled_tier = st.tier, - .proposer = st.proposer, - .member = winner, - }); + council.emplace(ram_payer, index_key{st.active_seat}, + council_row{ + .seat = st.active_seat, + .seat_owner = roster_owner(cfg, st.active_seat), + .filled_tier = filled_tier, + .proposer = proposer, + .member = member, + }); ++st.seats_filled; advance_seat(st, cfg); } +void council::win_attempt(election_state& st, const config_state& cfg, name winner) { + seat_member(st, cfg, winner, st.tier, st.proposer); +} + void council::try_resolve(election_state& st, const config_state& cfg) { - if (st.phase != councl::PH_VOTING) return; + if (st.phase != councl::election_phase::VOTING) + return; const bool all_voted = st.votes_cast >= st.eligible_voters; - const bool deadline = current_time_point() >= st.vote_deadline; + // Both nomination and voting windows are inclusive at the exact deadline. + const bool deadline = current_time_point() > st.vote_deadline; - auto r = resolve(std::array{st.yes1, st.yes2, st.yes3}, - std::array{st.no1, st.no2, st.no3}, + auto r = resolve(std::array{st.yes1, st.yes2, st.yes3}, std::array{st.no1, st.no2, st.no3}, st.elect_N, all_voted, deadline); - if (r.result == round_result::PENDING) return; + if (r.result == round_result::PENDING) + return; if (r.result == round_result::WIN) { name w = (r.winner_index == 0) ? st.c1 : (r.winner_index == 1 ? st.c2 : st.c3); win_attempt(st, cfg, w); @@ -197,11 +301,11 @@ void council::try_resolve(election_state& st, const config_state& cfg) { } } -void council::settle_if_elapsed(election_state& st, const config_state& cfg) { - if (st.phase == councl::PH_AWAIT_REP) { +void council::resolve_or_settle(election_state& st, const config_state& cfg) { + if (st.phase == councl::election_phase::AWAIT_REP) { if (current_time_point() > st.round_open_ts + sysio::seconds(cfg.time_slot_sec)) fail_attempt(st, cfg); // active proposer missed the nomination window - } else if (st.phase == councl::PH_VOTING) { + } else if (st.phase == councl::election_phase::VOTING) { try_resolve(st, cfg); } } @@ -213,12 +317,13 @@ void council::addcandidate(name account, std::string handle) { require_auth(account); config_t cg(get_self()); config_state cfg = cg.get_or_create(ram_payer, config_state{}); - check(cfg.init_phase == councl::IP_REG && cfg.reg_open, "candidate registration is closed"); - check(!handle.empty() && handle.size() <= councl::MAX_HANDLE_LEN, "handle length invalid"); + check(cfg.init_phase == councl::init_phase::REG, "candidate registration is closed"); + check(cfg.cand_count < councl::MAX_CANDIDATES, "candidate registration limit reached"); + check(valid_handle(handle), "handle contains invalid characters or length"); candidates_t cands(get_self(), cfg.election_gen); check(!cands.contains(cand_key{account.value}), "already a candidate"); - cands.emplace(ram_payer, cand_key{account.value}, candidate_row{account, handle, false}); + cands.emplace(account, cand_key{account.value}, candidate_row{account, handle, false}); ++cfg.cand_count; cg.set(cfg, ram_payer); @@ -227,8 +332,8 @@ void council::addcandidate(name account, std::string handle) { void council::rmcandidate(name account) { require_auth(get_self()); config_t cg(get_self()); - config_state cfg = cg.get("contract not initialized"); - check(cfg.init_phase == councl::IP_REG, "candidate registration is closed"); + config_state cfg = cg.get("candidate registry does not exist"); + check(cfg.init_phase == councl::init_phase::REG, "candidate registration is closed"); candidates_t cands(get_self(), cfg.election_gen); check(cands.contains(cand_key{account.value}), "not a candidate"); @@ -244,24 +349,26 @@ void council::rmcandidate(name account) { void council::startinit(uint64_t time_slot_sec, std::vector ordered_owners) { require_auth(get_self()); config_t cg(get_self()); - config_state cfg = cg.get("no candidates registered"); - check(cfg.init_phase == councl::IP_REG, "init already started"); + config_state cfg = cg.get("candidate registry does not exist"); + check(cfg.init_phase == councl::init_phase::REG, "election initialization is not available"); check(time_slot_sec > 0, "time_slot_sec must be positive"); - check(cfg.cand_count >= councl::MIN_CANDIDATES, "need at least 23 registered candidates"); - check(ordered_owners.size() == councl::SEATS, "ordered_owners must list all 21 tier-1 owners"); + check(time_slot_sec <= councl::MAX_TIME_SLOT_SEC, "time_slot_sec exceeds the safety limit"); + check(cfg.cand_count >= councl::MIN_CANDIDATES, "fewer candidates than required"); + check(ordered_owners.size() == councl::SEATS, "ordered_owners must list every council seat owner"); const uint8_t ng = roa_network_gen(); - const uint8_t T1 = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1); + const uint8_t t1_raw = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1); // Enumerate roa's tier-1 owners (the authoritative set the ordering must permute). roa::nodeowners_t no(councl::ROA_ACCOUNT, ng); auto t1_idx = no.get_index<"bytier"_n>(); std::vector t1; - for (auto it = t1_idx.lower_bound(static_cast(T1)); it != t1_idx.end(); ++it) { - if (it->tier != T1) break; + for (auto it = t1_idx.lower_bound(t1_raw); it != t1_idx.end(); ++it) { + if (it->tier != t1_raw) + break; t1.push_back(it->owner); } - check(t1.size() == councl::SEATS, "roa does not have exactly 21 tier-1 node owners"); + check(t1.size() == councl::SEATS, "roa tier-1 owner count does not match the council seat count"); // Freeze the ordered roster, verifying each entry is a distinct member of the roa tier-1 set. roster_t roster(get_self(), cfg.election_gen); @@ -269,27 +376,35 @@ void council::startinit(uint64_t time_slot_sec, std::vector ordered_owners for (uint64_t i = 0; i < ordered_owners.size(); ++i) { name o = ordered_owners[i]; bool in_t1 = false; - for (const auto& x : t1) { if (x == o) { in_t1 = true; break; } } + for (const auto& x : t1) { + if (x == o) { + in_t1 = true; + break; + } + } check(in_t1, "ordered_owners contains a non tier-1 owner"); check(by_owner.find(o.value) == by_owner.end(), "duplicate owner in ordered_owners"); roster.emplace(ram_payer, index_key{i}, roster_row{i, o}); } - cfg.network_gen = ng; + cfg.network_gen = ng; cfg.time_slot_sec = time_slot_sec; - cfg.reg_open = false; - cfg.init_phase = councl::IP_LOADING; - cfg.t2_loaded = 0; - cfg.t3_loaded = 0; + cfg.init_phase = councl::init_phase::LOADING; + cfg.t2_loaded = 0; + cfg.t3_loaded = 0; cg.set(cfg, ram_payer); } void council::loadtier(uint8_t tier, uint32_t max_rows) { require_auth(get_self()); - check(tier == 2 || tier == 3, "tier must be 2 or 3"); + auto election_tier = magic_enum::enum_cast(tier); + check(election_tier.has_value() && + (*election_tier == councl::election_tier::T2 || *election_tier == councl::election_tier::T3), + "tier must be T2 or T3"); + check(max_rows > 0, "max_rows must be positive"); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); - check(cfg.init_phase == councl::IP_LOADING, "not in the loading phase"); + check(cfg.init_phase == councl::init_phase::LOADING, "not in the loading phase"); roa::nodeowners_t no(councl::ROA_ACCOUNT, cfg.network_gen); auto idx = no.get_index<"bytier"_n>(); @@ -299,29 +414,21 @@ void council::loadtier(uint8_t tier, uint32_t max_rows) { // one owner and dropping another while finalizeinit's count cross-check still passes. // Identity dedup also absorbs such newcomers: whichever batch encounters them appends them, // so t{2,3}_loaded converges on the live nodecount and finalizeinit stays reachable. - const uint32_t already = (tier == 2) ? cfg.t2_loaded : cfg.t3_loaded; - uint32_t written = 0; + const uint32_t already = *election_tier == councl::election_tier::T2 ? cfg.t2_loaded : cfg.t3_loaded; + const uint8_t raw_tier = tier_integer(*election_tier); - if (tier == 2) { + if (*election_tier == councl::election_tier::T2) { tier2_t t2(get_self(), cfg.election_gen); - auto by_owner = t2.get_index<"byowner"_n>(); - for (auto it = idx.lower_bound(static_cast(2)); it != idx.end() && written < max_rows; ++it) { - if (it->tier != 2) break; - if (by_owner.find(it->owner.value) != by_owner.end()) continue; // already snapshotted - t2.emplace(ram_payer, index_key{already + written}, tier2_row{already + written, it->owner}); - ++written; - } + const uint32_t written = append_snapshot(t2, idx, raw_tier, already, max_rows); cfg.t2_loaded += written; + check(cfg.t2_loaded <= sysiosystem::emissions::T2_MAX_NODE_OWNERS, + "tier-2 snapshot exceeds the system owner cap"); } else { tier3_t t3(get_self(), cfg.election_gen); - auto by_owner = t3.get_index<"byowner"_n>(); - for (auto it = idx.lower_bound(static_cast(3)); it != idx.end() && written < max_rows; ++it) { - if (it->tier != 3) break; - if (by_owner.find(it->owner.value) != by_owner.end()) continue; // already snapshotted - t3.emplace(ram_payer, index_key{already + written}, tier3_row{already + written, it->owner}); - ++written; - } + const uint32_t written = append_snapshot(t3, idx, raw_tier, already, max_rows); cfg.t3_loaded += written; + check(cfg.t3_loaded <= sysiosystem::emissions::T3_MAX_NODE_OWNERS, + "tier-3 snapshot exceeds the system owner cap"); } cg.set(cfg, ram_payer); @@ -331,29 +438,29 @@ void council::finalizeinit() { require_auth(get_self()); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); - check(cfg.init_phase == councl::IP_LOADING, "not in the loading phase"); + check(cfg.init_phase == councl::init_phase::LOADING, "not in the loading phase"); // Completeness cross-check against sysio.system's live tier counts. - const uint32_t c2 = tier_count(2); - const uint32_t c3 = tier_count(3); + const uint32_t c2 = tier_count(councl::election_tier::T2); + const uint32_t c3 = tier_count(councl::election_tier::T3); + check(c2 <= sysiosystem::emissions::T2_MAX_NODE_OWNERS, "tier-2 count exceeds the system owner cap"); + check(c3 <= sysiosystem::emissions::T3_MAX_NODE_OWNERS, "tier-3 count exceeds the system owner cap"); check(cfg.t2_loaded == c2, "tier-2 snapshot incomplete"); check(cfg.t3_loaded == c3, "tier-3 snapshot incomplete"); - cfg.n2 = c2; - cfg.n3 = c3; - cfg.init_phase = councl::IP_READY; - cfg.initialized = true; + cfg.n2 = c2; + cfg.n3 = c3; + cfg.init_phase = councl::init_phase::READY; cg.set(cfg, ram_payer); // Open seat 0 with a fresh accumulator seeded from the generation. election_state st{}; - auto seed_bytes = pack(std::make_tuple(ACC_SEED_TAG, cfg.election_gen)); - st.acc = sha256(seed_bytes.data(), seed_bytes.size()); - st.stir_count = 0; - st.active_seat = 0; + st.acc = hash_tuple(std::make_tuple(ACC_SEED_TAG, cfg.election_gen)); + st.stir_count = 0; + st.active_seat = 0; st.seats_filled = 0; - st.tried_count = 0; - open_tier_attempt(st, cfg, 1, roster_owner(cfg, 0)); + st.tier3_available = cfg.n3; + open_tier_attempt(st, councl::election_tier::T1, roster_owner(cfg, 0)); state_t(get_self()).set(st, ram_payer); } @@ -361,17 +468,80 @@ void council::reset() { require_auth(get_self()); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); - check(cfg.init_phase == councl::IP_READY, "an election is still in progress"); + check(cfg.init_phase == councl::init_phase::READY, "reset requires a ready election generation"); state_t sg(get_self()); - check(!sg.exists() || sg.get().phase == councl::PH_DONE, "current election is not complete"); - - // Bump the generation and reopen registration; prior-gen rows stay under their old scope. - ++cfg.election_gen; - cfg.init_phase = councl::IP_REG; - cfg.reg_open = true; - cfg.initialized = false; - cfg.cand_count = 0; - cfg.n2 = cfg.n3 = cfg.t2_loaded = cfg.t3_loaded = 0; + check(!sg.exists() || sg.get().phase == councl::election_phase::DONE, "current election is not complete"); + + cfg.init_phase = councl::init_phase::CLEANING; + cfg.cleanup_stage = councl::cleanup_stage::CANDIDATES; + cfg.cleanup_seat = 0; + cg.set(cfg, ram_payer); +} + +void council::purge(uint32_t max_rows) { + require_auth(get_self()); + check(max_rows > 0, "max_rows must be positive"); + + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::init_phase::CLEANING, "generation cleanup is not active"); + + uint32_t remaining = max_rows; + while (remaining > 0 && cfg.cleanup_stage != councl::cleanup_stage::COMPLETE) { + uint32_t erased = 0; + switch (cfg.cleanup_stage) { + case councl::cleanup_stage::CANDIDATES: { + candidates_t table(get_self(), cfg.election_gen); + erased = erase_rows(table, remaining); + if (table.begin() == table.end()) + cfg.cleanup_stage = councl::cleanup_stage::ROSTER; + break; + } + case councl::cleanup_stage::ROSTER: { + roster_t table(get_self(), cfg.election_gen); + erased = erase_rows(table, remaining); + if (table.begin() == table.end()) + cfg.cleanup_stage = councl::cleanup_stage::TIER2; + break; + } + case councl::cleanup_stage::TIER2: { + tier2_t table(get_self(), cfg.election_gen); + erased = erase_rows(table, remaining); + if (table.begin() == table.end()) + cfg.cleanup_stage = councl::cleanup_stage::TIER3; + break; + } + case councl::cleanup_stage::TIER3: { + tier3_t table(get_self(), cfg.election_gen); + erased = erase_rows(table, remaining); + if (table.begin() == table.end()) + cfg.cleanup_stage = councl::cleanup_stage::REMAP; + break; + } + case councl::cleanup_stage::REMAP: { + tier3_remap_t table(get_self(), gr_scope(cfg.election_gen, cfg.cleanup_seat)); + erased = erase_rows(table, remaining); + if (table.begin() == table.end()) { + ++cfg.cleanup_seat; + if (cfg.cleanup_seat >= councl::SEATS) + cfg.cleanup_stage = councl::cleanup_stage::COMPLETE; + } + break; + } + case councl::cleanup_stage::COMPLETE: + break; + } + remaining -= erased; + } + + if (cfg.cleanup_stage == councl::cleanup_stage::COMPLETE) { + ++cfg.election_gen; + cfg.init_phase = councl::init_phase::REG; + cfg.time_slot_sec = 0; + cfg.cand_count = 0; + cfg.n2 = cfg.n3 = cfg.t2_loaded = cfg.t3_loaded = 0; + cfg.cleanup_seat = 0; + } cg.set(cfg, ram_payer); } @@ -382,17 +552,21 @@ void council::repcandidate(name proposer, name c1, name c2, name c3) { require_auth(proposer); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); - check(cfg.init_phase == councl::IP_READY, "election is not running"); + check(cfg.init_phase == councl::init_phase::READY, "election is not running"); state_t sg(get_self()); election_state st = sg.get("election state missing"); - do_stir(st, "repcandi"_n, proposer); - settle_if_elapsed(st, cfg); + do_stir(st, ACTION_REPCANDIDATE, proposer); + const uint64_t prior_round = st.round_id; + const auto prior_phase = st.phase; + resolve_or_settle(st, cfg); + if (st.round_id != prior_round || st.phase != prior_phase) { + sg.set(st, ram_payer); + return; // stale nomination acted only as a caller-authenticated settlement crank + } - check(st.phase == councl::PH_AWAIT_REP, "not accepting nominations right now"); + check(st.phase == councl::election_phase::AWAIT_REP, "not accepting nominations right now"); check(proposer == st.proposer, "not your turn to nominate"); - check(current_time_point() <= st.round_open_ts + sysio::seconds(cfg.time_slot_sec), - "nomination window has passed"); check(c1 != c2 && c1 != c3 && c2 != c3, "slate candidates must be distinct"); candidates_t cands(get_self(), cfg.election_gen); @@ -402,20 +576,26 @@ void council::repcandidate(name proposer, name c1, name c2, name c3) { } // Open the voting round; tier-2/3 seed the proposer's auto-yes on all three candidates. - st.c1 = c1; st.c2 = c2; st.c3 = c3; - if (st.tier == 1) { - st.elect_N = councl::T1_VOTERS; + st.c1 = c1; + st.c2 = c2; + st.c3 = c3; + uint32_t bitmap_members = 0; + if (st.tier == councl::election_tier::T1) { + st.elect_N = councl::T1_VOTERS; st.eligible_voters = councl::T1_VOTERS; + bitmap_members = councl::SEATS; st.yes1 = st.yes2 = st.yes3 = 0; } else { - const uint32_t N = (st.tier == 2) ? cfg.n2 : cfg.n3; - st.elect_N = N; + const uint32_t N = st.tier == councl::election_tier::T2 ? cfg.n2 : cfg.n3; + st.elect_N = N; st.eligible_voters = (N > 0) ? (N - 1) : 0; + bitmap_members = N; st.yes1 = st.yes2 = st.yes3 = 1; // proposer auto-yes } + st.voted_bitmap.assign((bitmap_members + BITS_PER_BYTE - 1) / BITS_PER_BYTE, 0); st.no1 = st.no2 = st.no3 = 0; - st.votes_cast = 0; - st.phase = councl::PH_VOTING; + st.votes_cast = 0; + st.phase = councl::election_phase::VOTING; st.vote_deadline = current_time_point() + sysio::seconds(cfg.time_slot_sec); try_resolve(st, cfg); // resolves immediately when the electorate is trivially small @@ -426,78 +606,108 @@ void council::vote(name voter, bool v1, bool v2, bool v3) { require_auth(voter); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); - check(cfg.init_phase == councl::IP_READY, "election is not running"); + check(cfg.init_phase == councl::init_phase::READY, "election is not running"); state_t sg(get_self()); election_state st = sg.get("election state missing"); - do_stir(st, "vote"_n, voter); - settle_if_elapsed(st, cfg); + do_stir(st, ACTION_VOTE, voter); + const uint64_t prior_round = st.round_id; + const auto prior_phase = st.phase; + resolve_or_settle(st, cfg); + if (st.round_id != prior_round || st.phase != prior_phase) { + sg.set(st, ram_payer); + return; // stale vote acted only as a caller-authenticated settlement crank + } - check(st.phase == councl::PH_VOTING, "voting is not open"); + check(st.phase == councl::election_phase::VOTING, "voting is not open"); check(voter != st.proposer, "the proposer cannot vote on their own slate"); - check(is_member(cfg, st.tier, voter), "not eligible to vote in this tier"); - - ballots_t ballots(get_self(), gr_scope(cfg.election_gen, st.round_id)); - check(!ballots.contains(voter_key{voter.value}), "already voted in this round"); - ballots.emplace(ram_payer, voter_key{voter.value}, ballot_row{voter, v1, v2, v3}); - - if (v1) ++st.yes1; else ++st.no1; - if (v2) ++st.yes2; else ++st.no2; - if (v3) ++st.yes3; else ++st.no3; + const uint32_t voter_index = member_index(cfg, st.tier, voter); + check(voter_index != INVALID_MEMBER_INDEX, "not eligible to vote in this tier"); + const uint32_t byte_index = voter_index / BITS_PER_BYTE; + const uint8_t bit_mask = uint8_t{1} << (voter_index % BITS_PER_BYTE); + check(byte_index < st.voted_bitmap.size(), "voter index exceeds the frozen tier snapshot"); + check((st.voted_bitmap[byte_index] & bit_mask) == 0, "already voted in this round"); + st.voted_bitmap[byte_index] |= bit_mask; + + if (v1) + ++st.yes1; + else + ++st.no1; + if (v2) + ++st.yes2; + else + ++st.no2; + if (v3) + ++st.yes3; + else + ++st.no3; ++st.votes_cast; try_resolve(st, cfg); sg.set(st, ram_payer); } -void council::settle() { +void council::settle(name caller) { + require_auth(caller); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); - check(cfg.init_phase == councl::IP_READY, "election is not running"); + check(cfg.init_phase == councl::init_phase::READY, "election is not running"); state_t sg(get_self()); election_state st = sg.get("election state missing"); - do_stir(st, "settle"_n, get_self()); - settle_if_elapsed(st, cfg); + check(st.phase != councl::election_phase::DONE, "election is complete"); + do_stir(st, ACTION_SETTLE, caller); + resolve_or_settle(st, cfg); sg.set(st, ram_payer); } -void council::forceassign(name member) { +void council::forceback() { require_auth(get_self()); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::init_phase::READY, "election is not running"); state_t sg(get_self()); election_state st = sg.get("election state missing"); - do_stir(st, "forceasgn"_n, get_self()); - check(st.phase == councl::PH_BACKSTOP, "not awaiting a governance assignment"); + check(st.phase == councl::election_phase::AWAIT_REP || st.phase == councl::election_phase::VOTING, + "no active attempt is eligible for governance recovery"); - candidates_t cands(get_self(), cfg.election_gen); - auto cr = cands.get(cand_key{member.value}, "member is not a candidate"); - check(!cr.elected, "candidate already elected to a seat"); - cands.modify(ram_payer, cand_key{member.value}, - [&](auto& c) { c.elected = true; }, "member is not a candidate"); + const bool elapsed = st.phase == councl::election_phase::AWAIT_REP + ? current_time_point() > st.round_open_ts + sysio::seconds(cfg.time_slot_sec) + : current_time_point() > st.vote_deadline; + check(elapsed, "the active attempt has not elapsed"); - council_t council(get_self(), cfg.election_gen); - council.emplace(ram_payer, index_key{st.active_seat}, council_row{ - .seat = st.active_seat, - .seat_owner = roster_owner(cfg, st.active_seat), - .filled_tier = councl::TIER_GOV, - .proposer = get_self(), - .member = member, - }); - ++st.seats_filled; - advance_seat(st, cfg); + do_stir(st, ACTION_FORCE_BACKSTOP, get_self()); + st.phase = councl::election_phase::BACKSTOP; + sg.set(st, ram_payer); +} + +void council::forceassign(name member) { + require_auth(get_self()); + config_t cg(get_self()); + config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::init_phase::READY, "election is not running"); + + state_t sg(get_self()); + election_state st = sg.get("election state missing"); + do_stir(st, ACTION_FORCE_ASSIGN, get_self()); + check(st.phase == councl::election_phase::BACKSTOP, "not awaiting a governance assignment"); + + seat_member(st, cfg, member, councl::election_tier::GOVERNANCE, get_self()); sg.set(st, ram_payer); } -void council::stir() { +void council::stir(name caller) { + require_auth(caller); config_t cg(get_self()); config_state cfg = cg.get("contract not initialized"); + check(cfg.init_phase == councl::init_phase::READY, "election is not running"); state_t sg(get_self()); check(sg.exists(), "election has not started"); election_state st = sg.get(); - do_stir(st, "stir"_n, get_self()); + check(st.phase != councl::election_phase::DONE, "election is complete"); + do_stir(st, ACTION_STIR, caller); + resolve_or_settle(st, cfg); sg.set(st, ram_payer); } diff --git a/contracts/sysio.councl/sysio.councl.abi b/contracts/sysio.councl/sysio.councl.abi index 2075b506fc..f27970267c 100644 --- a/contracts/sysio.councl/sysio.councl.abi +++ b/contracts/sysio.councl/sysio.councl.abi @@ -17,28 +17,6 @@ } ] }, - { - "name": "ballot_row", - "base": "", - "fields": [ - { - "name": "voter", - "type": "name" - }, - { - "name": "v1", - "type": "bool" - }, - { - "name": "v2", - "type": "bool" - }, - { - "name": "v3", - "type": "bool" - } - ] - }, { "name": "cand_key", "base": "", @@ -71,17 +49,9 @@ "name": "config_state", "base": "", "fields": [ - { - "name": "initialized", - "type": "bool" - }, { "name": "init_phase", - "type": "uint8" - }, - { - "name": "reg_open", - "type": "bool" + "type": "init_phase" }, { "name": "time_slot_sec", @@ -114,6 +84,14 @@ { "name": "cand_count", "type": "uint32" + }, + { + "name": "cleanup_stage", + "type": "cleanup_stage" + }, + { + "name": "cleanup_seat", + "type": "uint8" } ] }, @@ -131,7 +109,7 @@ }, { "name": "filled_tier", - "type": "uint8" + "type": "election_tier" }, { "name": "proposer", @@ -149,7 +127,7 @@ "fields": [ { "name": "phase", - "type": "uint8" + "type": "election_phase" }, { "name": "active_seat", @@ -157,7 +135,7 @@ }, { "name": "tier", - "type": "uint8" + "type": "election_tier" }, { "name": "proposer", @@ -177,7 +155,7 @@ }, { "name": "elect_N", - "type": "uint64" + "type": "uint32" }, { "name": "eligible_voters", @@ -188,13 +166,17 @@ "type": "uint32" }, { - "name": "tried_count", + "name": "tier3_available", "type": "uint32" }, { "name": "seats_filled", "type": "uint8" }, + { + "name": "voted_bitmap", + "type": "bytes" + }, { "name": "c1", "type": "name" @@ -209,27 +191,27 @@ }, { "name": "yes1", - "type": "uint64" + "type": "uint32" }, { "name": "yes2", - "type": "uint64" + "type": "uint32" }, { "name": "yes3", - "type": "uint64" + "type": "uint32" }, { "name": "no1", - "type": "uint64" + "type": "uint32" }, { "name": "no2", - "type": "uint64" + "type": "uint32" }, { "name": "no3", - "type": "uint64" + "type": "uint32" }, { "name": "acc", @@ -256,6 +238,11 @@ } ] }, + { + "name": "forceback", + "base": "", + "fields": [] + }, { "name": "index_key", "base": "", @@ -280,6 +267,30 @@ } ] }, + { + "name": "purge", + "base": "", + "fields": [ + { + "name": "max_rows", + "type": "uint32" + } + ] + }, + { + "name": "remap_row", + "base": "", + "fields": [ + { + "name": "virtual_idx", + "type": "uint64" + }, + { + "name": "actual_idx", + "type": "uint64" + } + ] + }, { "name": "repcandidate", "base": "", @@ -334,7 +345,12 @@ { "name": "settle", "base": "", - "fields": [] + "fields": [ + { + "name": "caller", + "type": "name" + } + ] }, { "name": "startinit", @@ -353,7 +369,12 @@ { "name": "stir", "base": "", - "fields": [] + "fields": [ + { + "name": "caller", + "type": "name" + } + ] }, { "name": "tier2_row", @@ -383,16 +404,6 @@ } ] }, - { - "name": "tried_row", - "base": "", - "fields": [ - { - "name": "idx", - "type": "uint64" - } - ] - }, { "name": "vote", "base": "", @@ -414,84 +425,76 @@ "type": "bool" } ] - }, - { - "name": "voter_key", - "base": "", - "fields": [ - { - "name": "voter", - "type": "uint64" - } - ] } ], "actions": [ { "name": "addcandidate", "type": "addcandidate", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Add Council Candidate\nsummary: '{{nowrap account}} registers as a council candidate.'\n---\n\n{{account}} registers as a candidate for the council election with the short handle {{handle}} and\npays the RAM for that candidate row. A generation accepts at most 1,000 candidates.\n\n## Preconditions\n- The caller must be authorized as {{account}}.\n- Candidate registration must be open (before the election has started).\n- {{account}} must not already be a candidate.\n- The handle must use the contract's allowed 1–32-byte ASCII character set." }, { "name": "finalizeinit", "type": "finalizeinit", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Finalize Election Initialization\nsummary: 'Verify the tier snapshots and open the first seat.'\n---\n\nThe contract owner finalizes initialization: the tier-2 and tier-3 snapshots are verified complete\nagainst the live node counts, and the first council seat's nomination window opens." }, { "name": "forceassign", "type": "forceassign", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Governance Seat Assignment\nsummary: 'Assign {{nowrap member}} to the current seat.'\n---\n\nThe contract owner seats {{member}} for the current seat. Valid only at the governance backstop,\nreached either after tier-3 exhaustion or through `forceback` recovery of an elapsed attempt." + }, + { + "name": "forceback", + "type": "forceback", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Governance Recovery Backstop\nsummary: 'Move an elapsed active attempt to governance backstop.'\n---\n\nThe contract owner moves an elapsed nomination or voting attempt directly to BACKSTOP. This is an\nexceptional recovery path for an operationally stalled election and cannot be used before the\nactive attempt's inclusive deadline has passed." }, { "name": "loadtier", "type": "loadtier", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Load Tier Snapshot\nsummary: 'Append tier-{{nowrap tier}} node owners into the frozen snapshot.'\n---\n\nThe contract owner appends up to {{max_rows}} tier-{{tier}} node owners from sysio.roa into the\nfrozen escalation snapshot. Resume is identity-based, so newly observed owners are appended without\nduplicating owners already loaded. Called repeatedly until complete." + }, + { + "name": "purge", + "type": "purge", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Purge Election State\nsummary: 'Delete up to {{max_rows}} ephemeral rows from the completed generation.'\n---\n\nThe contract owner deletes at most {{max_rows}} candidates, roster entries, tier snapshots, and\ntier-3 remap entries from the completed generation. When all ephemeral rows have been removed, the\ngeneration advances and registration reopens. Council result rows are retained." }, { "name": "repcandidate", "type": "repcandidate", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Nominate a Candidate Slate\nsummary: '{{nowrap proposer}} nominates a slate of three candidates.'\n---\n\n{{proposer}} nominates a slate of three distinct, un-elected candidates ({{c1}}, {{c2}}, and\n{{c3}}) for the current seat, opening the voting round.\n\n## Preconditions\n- The caller must be authorized as {{proposer}}.\n- {{proposer}} must be the active proposer for the current seat.\n- The nomination window must not have elapsed.\n- The three candidates must be distinct, registered, and not already elected." }, { "name": "reset", "type": "reset", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Begin Election Reset\nsummary: 'Begin staged cleanup of the completed election generation.'\n---\n\nThe contract owner starts cleanup of ephemeral rows from a completed election. Council result rows\nremain as permanent history. The contract owner must call `purge` until cleanup completes and\ncandidate registration reopens." }, { "name": "rmcandidate", "type": "rmcandidate", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Remove Council Candidate\nsummary: 'Remove candidate {{nowrap account}} before the election starts.'\n---\n\nThe contract owner removes {{account}} from the candidate pool. Allowed only while registration is open." }, { "name": "settle", "type": "settle", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Settle Election State\nsummary: '{{nowrap caller}} advances timed-out election state and stirs entropy.'\n---\n\n{{caller}} authorizes a public crank that resolves an elapsed attempt and advances the\nelection while mixing the authenticated caller into the accumulator." }, { "name": "startinit", "type": "startinit", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Start Election Initialization\nsummary: 'Freeze the tier-1 roster and begin an election.'\n---\n\nThe contract owner begins an election: registration closes, the ordered list of 21 tier-1 node\nowners is frozen as the seat roster, and the roa network generation is captured.\n\n## Preconditions\n- The caller must have contract-owner authorization.\n- At least 23 candidates must be registered.\n- `time_slot_sec` must be between one second and 30 days, inclusive.\n- `ordered_owners` must be a permutation of exactly the 21 tier-1 node owners in sysio.roa." }, { "name": "stir", "type": "stir", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Stir Entropy\nsummary: '{{nowrap caller}} advances entropy and settles elapsed election state.'\n---\n\n{{caller}} authorizes a public crank that mixes the authenticated caller into the entropy\naccumulator and also advances an elapsed election attempt. Block number and block timestamp are not\nentropy inputs." }, { "name": "vote", "type": "vote", - "ricardian_contract": "" + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Vote on the Current Slate\nsummary: '{{nowrap voter}} votes on the three current-slate candidates.'\n---\n\n{{voter}} casts an independent yes/no vote on each of the three candidates in the current slate.\n\n## Preconditions\n- The caller must be authorized as {{voter}}.\n- Voting must be open for the current slate.\n- {{voter}} must be an eligible voter for the active tier and must not be the proposer.\n- {{voter}} must not have already voted in this round." } ], "tables": [ - { - "name": "ballots", - "type": "ballot_row", - "index_type": "i64", - "key_names": ["scope","voter"], - "key_types": ["name","uint64"], - "table_id": 23362 - }, { "name": "candidates", "type": "candidate_row", @@ -570,15 +573,113 @@ ] }, { - "name": "tried3", - "type": "tried_row", + "name": "tier3remap", + "type": "remap_row", "index_type": "i64", "key_names": ["scope","idx"], "key_types": ["name","uint64"], - "table_id": 26046 + "table_id": 27642 } ], "ricardian_clauses": [], "variants": [], - "action_results": [] + "action_results": [], + "enums": [ + { + "name": "cleanup_stage", + "type": "uint8", + "values": [ + { + "name": "CANDIDATES", + "value": 0 + }, + { + "name": "ROSTER", + "value": 1 + }, + { + "name": "TIER2", + "value": 2 + }, + { + "name": "TIER3", + "value": 3 + }, + { + "name": "REMAP", + "value": 4 + }, + { + "name": "COMPLETE", + "value": 5 + } + ] + }, + { + "name": "election_phase", + "type": "uint8", + "values": [ + { + "name": "AWAIT_REP", + "value": 0 + }, + { + "name": "VOTING", + "value": 1 + }, + { + "name": "BACKSTOP", + "value": 2 + }, + { + "name": "DONE", + "value": 3 + } + ] + }, + { + "name": "election_tier", + "type": "uint8", + "values": [ + { + "name": "GOVERNANCE", + "value": 0 + }, + { + "name": "T1", + "value": 1 + }, + { + "name": "T2", + "value": 2 + }, + { + "name": "T3", + "value": 3 + } + ] + }, + { + "name": "init_phase", + "type": "uint8", + "values": [ + { + "name": "REG", + "value": 0 + }, + { + "name": "LOADING", + "value": 1 + }, + { + "name": "READY", + "value": 2 + }, + { + "name": "CLEANING", + "value": 3 + } + ] + } + ] } \ No newline at end of file diff --git a/contracts/sysio.councl/sysio.councl.wasm b/contracts/sysio.councl/sysio.councl.wasm index 0cda94f41acb1f90761f152ca45ba2eb1883b3e5..b1cddbadeb38693c9b32c1f3a41192a66fe4843e 100755 GIT binary patch literal 43929 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 ID?RW314ZzEX#fBK literal 31365 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 diff --git a/contracts/tests/council_math_tests.cpp b/contracts/tests/council_math_tests.cpp index 8c0fb8698f..662a8709ea 100644 --- a/contracts/tests/council_math_tests.cpp +++ b/contracts/tests/council_math_tests.cpp @@ -14,30 +14,35 @@ * of the seed→index mapping rather than golden hash values (see DESIGN.md §5, §12). */ -#include -#include - +#include #include +#include #include +#include using namespace sysio::councl_math; namespace { - resolution R(std::array yes, std::array no, - uint64_t N, bool all_voted, bool deadline_hit) { - return resolve(yes, no, N, all_voted, deadline_hit); - } +resolution R(std::array yes, std::array no, uint64_t N, bool all_voted, bool deadline_hit) { + return resolve(yes, no, N, all_voted, deadline_hit); } +} // namespace BOOST_AUTO_TEST_SUITE(council_math_tests) BOOST_AUTO_TEST_CASE(thresholds) { - BOOST_CHECK_EQUAL(win_threshold(20), 14u); BOOST_CHECK_EQUAL(elim_threshold(20), 7u); - BOOST_CHECK_EQUAL(win_threshold(84), 57u); BOOST_CHECK_EQUAL(elim_threshold(84), 28u); - BOOST_CHECK_EQUAL(win_threshold(1000), 667u);BOOST_CHECK_EQUAL(elim_threshold(1000), 334u); - BOOST_CHECK_EQUAL(win_threshold(1), 1u); BOOST_CHECK_EQUAL(elim_threshold(1), 1u); - BOOST_CHECK_EQUAL(win_threshold(2), 2u); BOOST_CHECK_EQUAL(elim_threshold(2), 1u); - BOOST_CHECK_EQUAL(win_threshold(3), 3u); BOOST_CHECK_EQUAL(elim_threshold(3), 1u); + BOOST_CHECK_EQUAL(win_threshold(20), 14u); + BOOST_CHECK_EQUAL(elim_threshold(20), 7u); + BOOST_CHECK_EQUAL(win_threshold(84), 57u); + BOOST_CHECK_EQUAL(elim_threshold(84), 28u); + BOOST_CHECK_EQUAL(win_threshold(1000), 667u); + BOOST_CHECK_EQUAL(elim_threshold(1000), 334u); + BOOST_CHECK_EQUAL(win_threshold(1), 1u); + BOOST_CHECK_EQUAL(elim_threshold(1), 1u); + BOOST_CHECK_EQUAL(win_threshold(2), 2u); + BOOST_CHECK_EQUAL(elim_threshold(2), 1u); + BOOST_CHECK_EQUAL(win_threshold(3), 3u); + BOOST_CHECK_EQUAL(elim_threshold(3), 1u); // Dual: floor(2n/3)+1 YES to win, ceil(n/3) NO to eliminate, summing to n+1. for (uint64_t n = 1; n <= 2000; ++n) @@ -92,19 +97,69 @@ BOOST_AUTO_TEST_CASE(degenerate_and_full_turnout) { BOOST_CHECK_EQUAL(f.winner_index, 0u); } +BOOST_AUTO_TEST_CASE(win_result_always_meets_active_threshold) { + for (uint64_t n = 1; n <= 100; ++n) { + const uint64_t threshold = win_threshold(n); + const uint64_t eliminated = elim_threshold(n); + const std::array active_yes = {0, threshold - 1, threshold, std::min(n, threshold + 1)}; + for (uint8_t eliminated_prefix = 0; eliminated_prefix <= 3; ++eliminated_prefix) { + for (const uint64_t yes_count : active_yes) { + for (const bool all_voted : {false, true}) { + for (const bool deadline_hit : {false, true}) { + std::array yes = {n, n, n}; + std::array no{}; + for (uint8_t i = 0; i < eliminated_prefix; ++i) + no[i] = eliminated; + if (eliminated_prefix < 3) + yes[eliminated_prefix] = yes_count; + + const auto result = resolve(yes, no, n, all_voted, deadline_hit); + if (result.result == round_result::WIN) { + BOOST_REQUIRE_LT(result.winner_index, 3u); + BOOST_CHECK_EQUAL(result.winner_index, eliminated_prefix); + BOOST_CHECK_LT(no[result.winner_index], eliminated); + BOOST_CHECK_GE(yes[result.winner_index], threshold); + } else if (eliminated_prefix < 3 && yes_count < threshold) { + BOOST_CHECK(result.result != round_result::WIN); + } + } + } + } + } + } +} + BOOST_AUTO_TEST_CASE(seed_helpers) { std::array h1{}; - for (int i = 0; i < 32; ++i) h1[static_cast(i)] = static_cast(i * 7 + 1); + for (int i = 0; i < 32; ++i) + h1[static_cast(i)] = static_cast(i * 7 + 1); std::array h2 = h1; h2[0] ^= 0xFF; - BOOST_CHECK_EQUAL(seed_u64(h1), seed_u64(h1)); // deterministic - BOOST_CHECK(seed_u64(h1) != seed_u64(h2)); // input-sensitive + BOOST_CHECK_EQUAL(seed_u64(h1), seed_u64(h1)); // deterministic + BOOST_CHECK(seed_u64(h1) != seed_u64(h2)); // input-sensitive for (uint64_t m = 1; m <= 1000; ++m) BOOST_CHECK_LT(bounded_index(seed_u64(h1), m), m); // always in range - BOOST_CHECK_EQUAL(bounded_index(12345, 0), 0u); // empty-set guard + BOOST_CHECK_EQUAL(bounded_index(12345, 0), 0u); // empty-set guard } +BOOST_AUTO_TEST_CASE(seed_big_endian_golden_vectors) { + std::array zeros{}; + BOOST_CHECK_EQUAL(seed_u64(zeros), UINT64_C(0)); + + std::array ascending{}; + for (uint8_t i = 0; i < 8; ++i) + ascending[i] = i; + BOOST_CHECK_EQUAL(seed_u64(ascending), UINT64_C(0x0001020304050607)); + + std::array ones{}; + ones.fill(0xFF); + BOOST_CHECK_EQUAL(seed_u64(ones), UINT64_MAX); +} + +static_assert(resolve({1, 0, 0}, {0, 0, 0}, 1, false, false).result == round_result::WIN, + "the resolver must remain constexpr"); + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.councl_tests.cpp b/contracts/tests/sysio.councl_tests.cpp index d95471f65d..10043af861 100644 --- a/contracts/tests/sysio.councl_tests.cpp +++ b/contracts/tests/sysio.councl_tests.cpp @@ -1,38 +1,29 @@ /// Integration tests for sysio.councl — the council election contract. /// -/// SKELETON: these compile and run only once sysio.councl.wasm / sysio.councl.abi exist (build the -/// contract under BUILD_SYSTEM_CONTRACTS=ON with the Wire CDT toolchain, then commit the artifacts -/// and drop the add_subdirectory guard in contracts/CMakeLists.txt). The fixture mirrors -/// sysio.dispute_tests.cpp: it bootstraps sysio.roa (node owners / tiers) and sysio.system -/// (nodecount), then deploys sysio.councl. +/// The fixture mirrors sysio.dispute_tests.cpp: it bootstraps sysio.roa (node owners / tiers) and +/// sysio.system (nodecount), then deploys the CDT-built sysio.councl artifact. /// /// The escalation tests are deliberately *seed-agnostic*: instead of predicting which tier-2/3 /// account the entropy accumulator selects, they read `state.proposer` back and drive that account. /// This keeps the suite stable when the seed formula is tweaked (see DESIGN.md §5, §12). /// -/// Coverage: -/// * registration -- self-register, duplicate, handle bound, auth, rmcandidate -/// * startinit guards -- <23 candidates, roster size/permutation vs roa tier-1, network_gen -/// * staged load -- loadtier batching + finalizeinit completeness cross-check -/// * tier-1 happy path -- 14/20 yes on c1 fills seat 0 and advances -/// * strict priority -- c1 eliminated (7 no) then c2 wins -/// * repcandidate/vote guards -/// * escalation -- nomination timeout / voting timeout -> tier-2 (read-back proposer) -/// * backstop -- empty tier-2/3 -> BACKSTOP -> forceassign -/// * full election -- drive all 21 seats to DONE +/// Coverage includes bounded candidate-paid registration, staged snapshot loading (including ROA +/// churn and the maximum tier-3 size), strict-priority T1/T2/T3 voting, compact duplicate-vote +/// tracking, timeout boundaries and settlement-only stale actions, deterministic no-repeat +/// selection, governance recovery/backstop, and complete cleanup-separated generations. -#include -#include -#include +#include "contracts.hpp" +#include #include - #include #include +#include +#include +#include +#include #include -#include "contracts.hpp" - using namespace sysio::testing; using namespace sysio; using namespace sysio::chain; @@ -41,9 +32,18 @@ using mvo = fc::mutable_variant_object; namespace { -// Mirror of the contract's phase constants (sysio.councl.hpp, namespace councl). -constexpr uint8_t PH_AWAIT_REP = 0, PH_VOTING = 1, PH_BACKSTOP = 2, PH_DONE = 3; -constexpr uint8_t IP_REG = 0, IP_LOADING = 1, IP_READY = 2; +// ABI enum spellings generated from sysio.councl.hpp. +constexpr auto PH_AWAIT_REP = "AWAIT_REP"; +constexpr auto PH_VOTING = "VOTING"; +constexpr auto PH_BACKSTOP = "BACKSTOP"; +constexpr auto PH_DONE = "DONE"; +constexpr auto IP_REG = "REG"; +constexpr auto IP_LOADING = "LOADING"; +constexpr auto IP_READY = "READY"; +constexpr auto IP_CLEANING = "CLEANING"; +constexpr auto TIER_T1 = "T1"; +constexpr auto TIER_T2 = "T2"; +constexpr auto TIER_T3 = "T3"; /// Build a valid account name of the form `` (lowercase a-z only), e.g. /// name_idx("own", 0) == "owna". Good for up to 26 distinct names per prefix. @@ -52,14 +52,27 @@ name name_idx(const std::string& prefix, size_t i) { return name(prefix + static_cast('a' + i)); } +/// Build one of 31^4 deterministic six-character account names for large-bound tests. +name bulk_name(char prefix, size_t i) { + static constexpr std::string_view alphabet = "12345abcdefghijklmnopqrstuvwxyz"; + BOOST_REQUIRE_LT(i, alphabet.size() * alphabet.size() * alphabet.size() * alphabet.size()); + std::string value(6, '1'); + value[0] = prefix; + for (size_t pos = value.size() - 1; pos > 1; --pos) { + value[pos] = alphabet[i % alphabet.size()]; + i /= alphabet.size(); + } + return name(value); +} + } // anonymous namespace class sysio_councl_tester : public tester { public: static constexpr auto COUNCL_ACCOUNT = "sysio.councl"_n; - static constexpr auto ROA_ACCOUNT = "sysio.roa"_n; - static constexpr uint64_t GEN0 = 0; - static constexpr uint64_t TIME_SLOT = 60; // seconds per attempt window + static constexpr auto ROA_ACCOUNT = "sysio.roa"_n; + static constexpr uint64_t GEN0 = 0; + static constexpr uint64_t TIME_SLOT = 60; // seconds per attempt window // 21 tier-1 owners, plus pools of tier-2 / tier-3 owners and candidates. std::vector t1_owners; // exactly 21 @@ -80,13 +93,21 @@ class sysio_councl_tester : public tester { // requires roa's tier-1 set to be exactly 21 owners — so nodedaddy takes the first roster // slot and only 20 fresh accounts are created/registered here. t1_owners.push_back(NODE_DADDY); - for (size_t i = 0; i < 20; ++i) t1_owners.push_back(name_idx("own", i)); + for (size_t i = 0; i < 20; ++i) + t1_owners.push_back(name_idx("own", i)); for (const auto& o : t1_owners) if (o != NODE_DADDY) create_account(o, config::system_account_name, false, true, /*include_roa_policy=*/false); - for (size_t i = 0; i < 26; ++i) candidates_.push_back(name_idx("cnd", i)); + for (size_t i = 0; i < 26; ++i) + candidates_.push_back(name_idx("cnd", i)); for (const auto& c : candidates_) create_account(c, config::system_account_name, false, true, /*include_roa_policy=*/false); + // Candidate rows are intentionally billed to the self-registering account. The system + // newaccount path grants only enough RAM for the account object, so give each fixture + // candidate a small finite allowance that can cover its own council registration row. + auto& resource_limits = control->get_mutable_resource_limits_manager(); + for (const auto& c : candidates_) + resource_limits.set_account_limits(c, 4096, -1, -1, false); produce_blocks(2); // sysio.roa is a genesis system account already running this build's code; just load its abi. @@ -120,55 +141,73 @@ class sysio_councl_tester : public tester { } void setup_emission_config() { - auto cfg = mvo() - ("t1_allocation", int64_t(7500000000000000))("t2_allocation", int64_t(1000000000000000)) - ("t3_allocation", int64_t(100000000000000)) - ("t1_duration", uint32_t(12u*30u*24u*3600u))("t2_duration", uint32_t(24u*30u*24u*3600u)) - ("t3_duration", uint32_t(36u*30u*24u*3600u)) - ("min_claimable", int64_t(10000000000)) - ("t5_distributable", int64_t(375000000000000000LL))("t5_floor", int64_t(125000000000000000LL)) - ("target_annual_decay_bps", uint16_t(6940)) - ("annual_initial_emission", int64_t(563150000000000LL*365)) - ("annual_max_emission", int64_t(3000000000000000LL*365)) - ("annual_min_emission", int64_t(100000000000000LL*365)) - ("compute_bps", uint16_t(4000))("capex_bps", uint16_t(2000))("governance_bps", uint16_t(1000)) - ("producer_bps", uint16_t(7000))("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) - ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); - push(config::system_account_name, system_abi, config::system_account_name, - "setemitcfg"_n, mvo()("cfg", cfg)); + auto cfg = mvo()("t1_allocation", int64_t(7500000000000000))("t2_allocation", int64_t(1000000000000000))( + "t3_allocation", int64_t(100000000000000))("t1_duration", uint32_t(12u * 30u * 24u * 3600u))( + "t2_duration", uint32_t(24u * 30u * 24u * 3600u))("t3_duration", uint32_t(36u * 30u * 24u * 3600u))( + "min_claimable", int64_t(10000000000))("t5_distributable", int64_t(375000000000000000LL))( + "t5_floor", int64_t(125000000000000000LL))("target_annual_decay_bps", uint16_t(6940))( + "annual_initial_emission", int64_t(563150000000000LL * 365))("annual_max_emission", + int64_t(3000000000000000LL * 365))( + "annual_min_emission", int64_t(100000000000000LL * 365))("compute_bps", uint16_t(4000))( + "capex_bps", uint16_t(2000))("governance_bps", uint16_t(1000))("producer_bps", uint16_t(7000))( + "batch_op_bps", uint16_t(3000))("standby_end_rank", uint32_t(28))("epoch_log_retention_count", uint32_t(8640))( + "pay_cadence_epochs", uint16_t(1)); + push(config::system_account_name, system_abi, config::system_account_name, "setemitcfg"_n, mvo()("cfg", cfg)); produce_blocks(); } /// Register `owner` at `tier` in sysio.roa::nodeowners (forcereg -> regnodeowner also bumps /// sysio.system::nodecount.t{tier}_count via addnodeowner). - void register_node_owner(name owner, uint8_t tier) { - BOOST_REQUIRE_EQUAL(success(), push(ROA_ACCOUNT, roa_abi, ROA_ACCOUNT, "forcereg"_n, mvo() - ("owner", owner.to_string())("tier", tier))); + void forcereg_owner(name owner, uint8_t tier) { + BOOST_REQUIRE_EQUAL(success(), push(ROA_ACCOUNT, roa_abi, ROA_ACCOUNT, "forcereg"_n, + mvo()("owner", owner.to_string())("tier", tier))); } /// Register all 21 tier-1 owners, plus any tier-2 / tier-3 owners requested. /// NODE_DADDY is skipped: genesis already forcereg'd it, and regnodeowner rejects re-registration. void register_tiers(size_t n_t2 = 0, size_t n_t3 = 0) { for (const auto& o : t1_owners) - if (o != NODE_DADDY) register_node_owner(o, 1); - for (size_t i = 0; i < n_t2; ++i) { auto o = name_idx("two", i); mk(o); register_node_owner(o, 2); t2_owners.push_back(o); } - for (size_t i = 0; i < n_t3; ++i) { auto o = name_idx("thr", i); mk(o); register_node_owner(o, 3); t3_owners.push_back(o); } + if (o != NODE_DADDY) + forcereg_owner(o, 1); + for (size_t i = 0; i < n_t2; ++i) { + auto o = name_idx("two", i); + mk(o); + forcereg_owner(o, 2); + t2_owners.push_back(o); + } + for (size_t i = 0; i < n_t3; ++i) { + auto o = name_idx("thr", i); + mk(o); + forcereg_owner(o, 3); + t3_owners.push_back(o); + } + } + + void mk(name a) { + create_account(a, config::system_account_name, false, true, false); + produce_block(); } - void mk(name a) { create_account(a, config::system_account_name, false, true, false); produce_block(); } + /// Create a candidate account with a finite allowance sufficient for its self-paid row. + void mk_candidate(name candidate) { + create_account(candidate, config::system_account_name, false, true, false); + control->get_mutable_resource_limits_manager().set_account_limits(candidate, 4096, -1, -1, false); + produce_block(); + } // ── generic action push (lands each action in its own block for distinct TaPoS) ───────────── - action_result push(name contract, abi_serializer& ser, name signer, - name action_name, const fc::variant_object& data) { + action_result push(name contract, abi_serializer& ser, name signer, name action_name, + const fc::variant_object& data) { try { std::string action_type = ser.get_action_type(action_name); action act; act.account = contract; - act.name = action_name; - act.data = ser.variant_to_binary(action_type, data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - act.authorization = std::vector{{signer, config::active_name}}; + act.name = action_name; + act.data = + ser.variant_to_binary(action_type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + act.authorization = std::vector{ + {signer, config::active_name} + }; signed_transaction trx; trx.actions.emplace_back(std::move(act)); set_transaction_headers(trx); @@ -183,39 +222,51 @@ class sysio_councl_tester : public tester { // ── councl action wrappers ──────────────────────────────────────────────── action_result addcandidate(name acct, const std::string& handle) { - return push(COUNCL_ACCOUNT, councl_abi, acct, "addcandidate"_n, mvo()("account", acct.to_string())("handle", handle)); + return push(COUNCL_ACCOUNT, councl_abi, acct, "addcandidate"_n, + mvo()("account", acct.to_string())("handle", handle)); } action_result rmcandidate(name acct) { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "rmcandidate"_n, mvo()("account", acct.to_string())); } action_result startinit(uint64_t slot, const std::vector& owners) { - fc::variants v; for (const auto& o : owners) v.emplace_back(o.to_string()); + fc::variants v; + for (const auto& o : owners) + v.emplace_back(o.to_string()); return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "startinit"_n, mvo()("time_slot_sec", slot)("ordered_owners", v)); } action_result loadtier(uint8_t tier, uint32_t max_rows) { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "loadtier"_n, mvo()("tier", tier)("max_rows", max_rows)); } - action_result finalizeinit() { - return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "finalizeinit"_n, mvo()); - } + action_result finalizeinit() { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "finalizeinit"_n, mvo()); } action_result reset() { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "reset"_n, mvo()); } + action_result purge(uint32_t max_rows) { + return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "purge"_n, mvo()("max_rows", max_rows)); + } action_result repcandidate(name proposer, name c1, name c2, name c3) { - return push(COUNCL_ACCOUNT, councl_abi, proposer, "repcandidate"_n, - mvo()("proposer", proposer.to_string())("c1", c1.to_string())("c2", c2.to_string())("c3", c3.to_string())); + return push( + COUNCL_ACCOUNT, councl_abi, proposer, "repcandidate"_n, + mvo()("proposer", proposer.to_string())("c1", c1.to_string())("c2", c2.to_string())("c3", c3.to_string())); } action_result vote(name voter, bool v1, bool v2, bool v3) { return push(COUNCL_ACCOUNT, councl_abi, voter, "vote"_n, mvo()("voter", voter.to_string())("v1", v1)("v2", v2)("v3", v3)); } - action_result settle() { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "settle"_n, mvo()); } + action_result settle(name caller = COUNCL_ACCOUNT) { + return push(COUNCL_ACCOUNT, councl_abi, caller, "settle"_n, mvo()("caller", caller.to_string())); + } + action_result stir(name caller) { + return push(COUNCL_ACCOUNT, councl_abi, caller, "stir"_n, mvo()("caller", caller.to_string())); + } + action_result forceback() { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "forceback"_n, mvo()); } action_result forceassign(name member) { return push(COUNCL_ACCOUNT, councl_abi, COUNCL_ACCOUNT, "forceassign"_n, mvo()("member", member.to_string())); } // ── convenience: bring the contract to READY with `slot`, `n_t2`/`n_t3` extra tiers ────────── void register_candidates(size_t n) { - for (size_t i = 0; i < n; ++i) BOOST_REQUIRE_EQUAL(success(), addcandidate(candidates_[i], "handle")); + for (size_t i = 0; i < n; ++i) + BOOST_REQUIRE_EQUAL(success(), addcandidate(candidates_[i], "handle")); } void init_ready(size_t n_candidates = 23, size_t n_t2 = 0, size_t n_t3 = 0) { register_candidates(n_candidates); @@ -229,39 +280,81 @@ class sysio_councl_tester : public tester { // ── state / config readers ──────────────────────────────────────────────── fc::variant get_state() { auto data = get_row_by_account(COUNCL_ACCOUNT, COUNCL_ACCOUNT, "state"_n, "state"_n); - return data.empty() ? fc::variant() : councl_abi.binary_to_variant("election_state", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); + return data.empty() ? fc::variant() + : councl_abi.binary_to_variant( + "election_state", data, abi_serializer::create_yield_function(abi_serializer_max_time)); } fc::variant get_config() { auto data = get_row_by_account(COUNCL_ACCOUNT, COUNCL_ACCOUNT, "config"_n, "config"_n); - return data.empty() ? fc::variant() : councl_abi.binary_to_variant("config_state", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); + return data.empty() ? fc::variant() + : councl_abi.binary_to_variant( + "config_state", data, abi_serializer::create_yield_function(abi_serializer_max_time)); } - uint8_t phase() { return get_state()["phase"].as(); } - uint8_t tier() { return get_state()["tier"].as(); } - uint8_t active_seat() { return get_state()["active_seat"].as(); } + std::string phase() { return get_state()["phase"].as_string(); } + std::string tier() { return get_state()["tier"].as_string(); } + std::string init_phase() { return get_config()["init_phase"].as_string(); } + uint8_t active_seat() { return get_state()["active_seat"].as(); } uint8_t seats_filled() { return get_state()["seats_filled"].as(); } - name proposer() { return name(get_state()["proposer"].as_string()); } + uint64_t election_gen() { return get_config()["election_gen"].as(); } + uint64_t round_id() { return get_state()["round_id"].as(); } + uint32_t votes_cast() { return get_state()["votes_cast"].as(); } + uint32_t tier3_available() { return get_state()["tier3_available"].as(); } + uint64_t yes1() { return get_state()["yes1"].as(); } + uint64_t stir_count() { return get_state()["stir_count"].as(); } + std::string accumulator() { return get_state()["acc"].as_string(); } + name proposer() { return name(get_state()["proposer"].as_string()); } /// Elected member for a filled seat, or the empty name if the seat row is absent. /// Per-election tables are scoped by the generation, so the scope name's value is election_gen. - name council_member(uint64_t seat) { - auto data = get_row_by_id(COUNCL_ACCOUNT, name(GEN0), "council"_n, seat); - if (data.empty()) return name{}; + name council_member(uint64_t seat, uint64_t generation = GEN0) { + auto data = get_row_by_id(COUNCL_ACCOUNT, name(generation), "council"_n, seat); + if (data.empty()) + return name{}; auto v = councl_abi.binary_to_variant("council_row", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); + abi_serializer::create_yield_function(abi_serializer_max_time)); return name(v["member"].as_string()); } + /// Return whether a generation still retains a candidate row for `candidate`. + bool candidate_exists(name candidate, uint64_t generation = GEN0) { + return !get_row_by_id(COUNCL_ACCOUNT, name(generation), "candidates"_n, candidate.value).empty(); + } + + /// Return whether a generation still retains its frozen roster row at `seat`. + bool roster_exists(uint64_t seat, uint64_t generation = GEN0) { + return !get_row_by_id(COUNCL_ACCOUNT, name(generation), "roster"_n, seat).empty(); + } + /// Owner of tier-2 snapshot row `idx`, or the empty name if the row is absent. - name tier2_owner(uint64_t idx) { - auto data = get_row_by_id(COUNCL_ACCOUNT, name(GEN0), "tier2"_n, idx); - if (data.empty()) return name{}; + name tier2_owner(uint64_t idx, uint64_t generation = GEN0) { + auto data = get_row_by_id(COUNCL_ACCOUNT, name(generation), "tier2"_n, idx); + if (data.empty()) + return name{}; auto v = councl_abi.binary_to_variant("tier2_row", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); + abi_serializer::create_yield_function(abi_serializer_max_time)); + return name(v["owner"].as_string()); + } + + /// Owner of tier-3 snapshot row `idx`, or the empty name if the row is absent. + name tier3_owner(uint64_t idx, uint64_t generation = GEN0) { + auto data = get_row_by_id(COUNCL_ACCOUNT, name(generation), "tier3"_n, idx); + if (data.empty()) + return name{}; + auto v = councl_abi.binary_to_variant("tier3_row", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); return name(v["owner"].as_string()); } + /// Return whether any lazy Fisher-Yates remap row exists for a generation and seat. + bool tier3_remap_exists(uint64_t generation, uint8_t seat, uint32_t tier3_size) { + constexpr uint64_t SEAT_SCOPE_BITS = 40; + const uint64_t scope = (generation << SEAT_SCOPE_BITS) | seat; + for (uint64_t idx = 0; idx < tier3_size; ++idx) + if (!get_row_by_id(COUNCL_ACCOUNT, name(scope), "tier3remap"_n, idx).empty()) + return true; + return false; + } + /// Elapse one full attempt window so a nomination/voting deadline passes, then settle. void elapse_and_settle() { produce_block(fc::seconds(TIME_SLOT + 1)); @@ -271,76 +364,172 @@ class sysio_councl_tester : public tester { /// The 20 tier-1 owners other than the active proposer (tier-1 electorate). std::vector tier1_voters_excluding(name p) { std::vector v; - for (const auto& o : t1_owners) if (o != p) v.push_back(o); + for (const auto& o : t1_owners) + if (o != p) + v.push_back(o); return v; } + + /// Return the members of `owners` other than `excluded`. + std::vector excluding(const std::vector& owners, name excluded) { + std::vector result; + for (const auto owner : owners) + if (owner != excluded) + result.push_back(owner); + return result; + } }; // =========================================================================== BOOST_AUTO_TEST_SUITE(sysio_councl_tests) // ── registration ────────────────────────────────────────────────────────── -BOOST_FIXTURE_TEST_CASE(registration, sysio_councl_tester) { try { - BOOST_REQUIRE_EQUAL(success(), addcandidate(candidates_[0], "alice")); - BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 1u); - - // duplicate - BOOST_REQUIRE_EQUAL(error("assertion failure with message: already a candidate"), - addcandidate(candidates_[0], "alice")); - // handle too long (> 32 bytes) - BOOST_REQUIRE_EQUAL(error("assertion failure with message: handle length invalid"), - addcandidate(candidates_[1], std::string(33, 'x'))); - // wrong auth: data.account = candidates_[3] but the tx is signed by candidates_[2]. - BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[2], "addcandidate"_n, - mvo()("account", candidates_[3].to_string())("handle", "x")) != success()); - - // rmcandidate by governance - BOOST_REQUIRE_EQUAL(success(), rmcandidate(candidates_[0])); - BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 0u); -} FC_LOG_AND_RETHROW() } +BOOST_FIXTURE_TEST_CASE(registration, sysio_councl_tester) { + try { + const int64_t ram_before = control->get_resource_limits_manager().get_account_ram_usage(candidates_[0]); + BOOST_REQUIRE_EQUAL(success(), addcandidate(candidates_[0], "alice")); + const int64_t ram_after = control->get_resource_limits_manager().get_account_ram_usage(candidates_[0]); + BOOST_CHECK_GT(ram_after, ram_before); // the candidate, not governance, paid for the row + BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 1u); + + // duplicate + BOOST_REQUIRE_EQUAL(error("assertion failure with message: already a candidate"), + addcandidate(candidates_[0], "alice")); + // handle too long (> 32 bytes) + BOOST_REQUIRE_EQUAL(error("assertion failure with message: handle contains invalid characters or length"), + addcandidate(candidates_[1], std::string(33, 'x'))); + // wrong auth: data.account = candidates_[3] but the tx is signed by candidates_[2]. + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[2], "addcandidate"_n, + mvo()("account", candidates_[3].to_string())("handle", "x")) != success()); + + // rmcandidate by governance + BOOST_REQUIRE_EQUAL(success(), rmcandidate(candidates_[0])); + BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 0u); + BOOST_CHECK_EQUAL(control->get_resource_limits_manager().get_account_ram_usage(candidates_[0]), ram_before); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(registration_is_capped_at_1000, sysio_councl_tester) { + try { + for (const auto candidate : candidates_) + BOOST_REQUIRE_EQUAL(success(), addcandidate(candidate, "handle")); + for (size_t i = candidates_.size(); i < 1000; ++i) { + const name candidate = bulk_name('x', i); + mk_candidate(candidate); + BOOST_REQUIRE_EQUAL(success(), addcandidate(candidate, "handle")); + } + BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 1000u); + + const name overflow = bulk_name('x', 1000); + mk_candidate(overflow); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate registration limit reached"), + addcandidate(overflow, "handle")); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(registration_rejects_unsafe_handle_bytes, sysio_councl_tester) { + try { + BOOST_REQUIRE_EQUAL(error("assertion failure with message: handle contains invalid characters or length"), + addcandidate(candidates_[0], "bad\nhandle")); + BOOST_REQUIRE_EQUAL(success(), addcandidate(candidates_[0], "@safe_handle-1.0")); + } + FC_LOG_AND_RETHROW() +} // ── startinit guards ────────────────────────────────────────────────────── -BOOST_FIXTURE_TEST_CASE(startinit_requires_23_candidates, sysio_councl_tester) { try { - register_candidates(22); - register_tiers(); - BOOST_REQUIRE_EQUAL(error("assertion failure with message: need at least 23 registered candidates"), - startinit(TIME_SLOT, t1_owners)); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(startinit_roster_must_permute_tier1, sysio_councl_tester) { try { - register_candidates(23); - register_tiers(); - // wrong size - std::vector short_roster(t1_owners.begin(), t1_owners.end() - 1); - BOOST_REQUIRE_EQUAL(error("assertion failure with message: ordered_owners must list all 21 tier-1 owners"), - startinit(TIME_SLOT, short_roster)); - // right size but contains a non-tier-1 account (a candidate) - std::vector bad = t1_owners; bad.back() = candidates_[0]; - BOOST_REQUIRE_EQUAL(error("assertion failure with message: ordered_owners contains a non tier-1 owner"), - startinit(TIME_SLOT, bad)); - // happy - BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); - BOOST_REQUIRE_EQUAL(get_config()["init_phase"].as(), IP_LOADING); -} FC_LOG_AND_RETHROW() } +BOOST_FIXTURE_TEST_CASE(startinit_requires_23_candidates, sysio_councl_tester) { + try { + register_candidates(22); + register_tiers(); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: fewer candidates than required"), + startinit(TIME_SLOT, t1_owners)); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(startinit_roster_must_permute_tier1, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(); + // wrong size + std::vector short_roster(t1_owners.begin(), t1_owners.end() - 1); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: ordered_owners must list every council seat owner"), + startinit(TIME_SLOT, short_roster)); + // right size but contains a non-tier-1 account (a candidate) + std::vector bad = t1_owners; + bad.back() = candidates_[0]; + BOOST_REQUIRE_EQUAL(error("assertion failure with message: ordered_owners contains a non tier-1 owner"), + startinit(TIME_SLOT, bad)); + // happy + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(init_phase(), IP_LOADING); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(startinit_bounds_time_slot, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(); + constexpr uint64_t MAX_SLOT = 30u * 24u * 60u * 60u; + BOOST_REQUIRE_EQUAL(error("assertion failure with message: time_slot_sec must be positive"), + startinit(0, t1_owners)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: time_slot_sec exceeds the safety limit"), + startinit(MAX_SLOT + 1, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), startinit(MAX_SLOT, t1_owners)); + } + FC_LOG_AND_RETHROW() +} + +/// Every governance-controlled registration and initialization boundary must reject a non-contract signer. +BOOST_FIXTURE_TEST_CASE(initialization_actions_require_contract_auth, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(/*n_t2=*/1, /*n_t3=*/1); + + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[1], "rmcandidate"_n, + mvo()("account", candidates_[0].to_string())) != success()); + + fc::variants owners; + for (const auto owner : t1_owners) + owners.emplace_back(owner.to_string()); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "startinit"_n, + mvo()("time_slot_sec", TIME_SLOT)("ordered_owners", owners)) != success()); + + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "loadtier"_n, + mvo()("tier", uint8_t{2})("max_rows", uint32_t{1000})) != success()); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "finalizeinit"_n, mvo()) != success()); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + } + FC_LOG_AND_RETHROW() +} // ── staged load + finalize ──────────────────────────────────────────────── -BOOST_FIXTURE_TEST_CASE(staged_load_and_finalize, sysio_councl_tester) { try { - register_candidates(23); - register_tiers(/*n_t2=*/5, /*n_t3=*/9); - BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); - // batch tier-2 in two calls, tier-3 in one - BOOST_REQUIRE_EQUAL(success(), loadtier(2, 3)); - BOOST_REQUIRE_EQUAL(success(), loadtier(2, 3)); // remaining 2 - // finalize before tier-3 loaded -> incomplete - BOOST_REQUIRE_EQUAL(error("assertion failure with message: tier-3 snapshot incomplete"), finalizeinit()); - BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); - BOOST_REQUIRE_EQUAL(success(), finalizeinit()); - BOOST_REQUIRE_EQUAL(get_config()["n2"].as(), 5u); - BOOST_REQUIRE_EQUAL(get_config()["n3"].as(), 9u); - BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); - BOOST_REQUIRE_EQUAL(active_seat(), 0); - BOOST_REQUIRE_EQUAL(proposer().to_string(), t1_owners[0].to_string()); -} FC_LOG_AND_RETHROW() } +BOOST_FIXTURE_TEST_CASE(staged_load_and_finalize, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(/*n_t2=*/5, /*n_t3=*/9); + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + // batch tier-2 in two calls, tier-3 in one + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 3)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 3)); // remaining 2 + // finalize before tier-3 loaded -> incomplete + BOOST_REQUIRE_EQUAL(error("assertion failure with message: tier-3 snapshot incomplete"), finalizeinit()); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(get_config()["n2"].as(), 5u); + BOOST_REQUIRE_EQUAL(get_config()["n3"].as(), 9u); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + BOOST_REQUIRE_EQUAL(active_seat(), 0); + BOOST_REQUIRE_EQUAL(proposer().to_string(), t1_owners[0].to_string()); + } + FC_LOG_AND_RETHROW() +} // ── staged load: roa tier churn between loadtier batches ────────────────── /// REGRESSION: loadtier must resume by *identity* (skip owners already snapshotted), not by @@ -350,139 +539,498 @@ BOOST_FIXTURE_TEST_CASE(staged_load_and_finalize, sysio_councl_tester) { try { /// passed. Identity-based dedup instead absorbs the newcomer in the next batch, keeping the /// frozen snapshot a faithful, duplicate-free copy of roa's tier-2 set (DESIGN.md §11: the /// election is immune to roa churn only if the snapshot itself is sound). -BOOST_FIXTURE_TEST_CASE(loadtier_roa_churn_mid_load, sysio_councl_tester) { try { - register_candidates(23); - register_tiers(); // the 21 tier-1 owners only +BOOST_FIXTURE_TEST_CASE(loadtier_roa_churn_mid_load, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(); // the 21 tier-1 owners only - // Two tier-2 owners present at startinit; bytier enumerates them in name order. - name twob{"twob"}, twoc{"twoc"}; - mk(twob); register_node_owner(twob, 2); - mk(twoc); register_node_owner(twoc, 2); + // Two tier-2 owners present at startinit; bytier enumerates them in name order. + name twob{"twob"}, twoc{"twoc"}; + mk(twob); + forcereg_owner(twob, 2); + mk(twoc); + forcereg_owner(twoc, 2); - BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); - BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1)); // snapshots "twob" into idx 0 + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1)); // snapshots "twob" into idx 0 - // Churn mid-load: a new tier-2 owner that sorts BEFORE the already-loaded "twob". - name twoa{"twoa"}; - mk(twoa); register_node_owner(twoa, 2); + // Churn mid-load: a new tier-2 owner that sorts BEFORE the already-loaded "twob". + name twoa{"twoa"}; + mk(twoa); + forcereg_owner(twoa, 2); - BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); // resumes by skip-count past "twoa" - BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); - BOOST_REQUIRE_EQUAL(success(), finalizeinit()); // t2_loaded == nodecount either way + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); // identity resume absorbs "twoa" + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); // t2_loaded == nodecount either way - // Faithful snapshot: rows 0..2 hold exactly {twoa, twob, twoc}, no duplicates. - std::set snapshot; - for (uint64_t i = 0; i < 3; ++i) { - name o = tier2_owner(i); - BOOST_REQUIRE_MESSAGE(snapshot.insert(o).second, - "duplicate owner in tier-2 snapshot: " + o.to_string()); + // Faithful snapshot: rows 0..2 hold exactly {twoa, twob, twoc}, no duplicates. + std::set snapshot; + for (uint64_t i = 0; i < 3; ++i) { + name o = tier2_owner(i); + BOOST_REQUIRE_MESSAGE(snapshot.insert(o).second, "duplicate owner in tier-2 snapshot: " + o.to_string()); + } + BOOST_CHECK_MESSAGE(snapshot == std::set({twoa, twob, twoc}), "tier-2 snapshot is not the roa tier-2 set"); } - BOOST_CHECK_MESSAGE(snapshot == std::set({twoa, twob, twoc}), - "tier-2 snapshot is not the roa tier-2 set"); -} FC_LOG_AND_RETHROW() } + FC_LOG_AND_RETHROW() +} // ── tier-1 happy path: 14 yes on c1 fills seat 0 ────────────────────────── -BOOST_FIXTURE_TEST_CASE(tier1_seat0_win, sysio_councl_tester) { try { - init_ready(); - name p = proposer(); // == t1_owners[0] - name a = candidates_[0], b = candidates_[1], c = candidates_[2]; - BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); - BOOST_REQUIRE_EQUAL(phase(), PH_VOTING); - - // 14 of the other 20 vote yes on c1 -> win the instant the 14th lands. - auto voters = tier1_voters_excluding(p); - for (int i = 0; i < 14; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], true, false, false)); - - BOOST_REQUIRE_EQUAL(council_member(0).to_string(), a.to_string()); - BOOST_REQUIRE_EQUAL(seats_filled(), 1); - BOOST_REQUIRE_EQUAL(active_seat(), 1); // advanced to next seat - BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); -} FC_LOG_AND_RETHROW() } +BOOST_FIXTURE_TEST_CASE(tier1_seat0_win, sysio_councl_tester) { + try { + init_ready(); + name p = proposer(); // == t1_owners[0] + name a = candidates_[0], b = candidates_[1], c = candidates_[2]; + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + BOOST_REQUIRE_EQUAL(phase(), PH_VOTING); + + // 14 of the other 20 vote yes on c1 -> win the instant the 14th lands. + auto voters = tier1_voters_excluding(p); + for (int i = 0; i < 14; ++i) + BOOST_REQUIRE_EQUAL(success(), vote(voters[i], true, false, false)); + + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), a.to_string()); + BOOST_REQUIRE_EQUAL(seats_filled(), 1); + BOOST_REQUIRE_EQUAL(active_seat(), 1); // advanced to next seat + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate already elected to a seat"), + repcandidate(proposer(), a, b, c)); + } + FC_LOG_AND_RETHROW() +} // ── strict priority: c1 eliminated (7 no) then c2 wins ──────────────────── -BOOST_FIXTURE_TEST_CASE(strict_priority_promotes_c2, sysio_councl_tester) { try { - init_ready(); - name p = proposer(); - name a = candidates_[0], b = candidates_[1], c = candidates_[2]; - BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); - auto voters = tier1_voters_excluding(p); - // 7 voters vote NO on c1 (eliminates it) and YES on c2; then 7 more YES on c2 -> c2 at 14. - for (int i = 0; i < 7; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], false, true, false)); - for (int i = 7; i < 14; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], false, true, false)); - BOOST_REQUIRE_EQUAL(council_member(0).to_string(), b.to_string()); -} FC_LOG_AND_RETHROW() } +BOOST_FIXTURE_TEST_CASE(strict_priority_promotes_c2, sysio_councl_tester) { + try { + init_ready(); + name p = proposer(); + name a = candidates_[0], b = candidates_[1], c = candidates_[2]; + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + auto voters = tier1_voters_excluding(p); + // 7 voters vote NO on c1 (eliminates it) and YES on c2; then 7 more YES on c2 -> c2 at 14. + for (int i = 0; i < 7; ++i) + BOOST_REQUIRE_EQUAL(success(), vote(voters[i], false, true, false)); + for (int i = 7; i < 14; ++i) + BOOST_REQUIRE_EQUAL(success(), vote(voters[i], false, true, false)); + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), b.to_string()); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(strict_priority_promotes_c3, sysio_councl_tester) { + try { + init_ready(); + name p = proposer(); + name a = candidates_[0], b = candidates_[1], c = candidates_[2]; + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + auto voters = tier1_voters_excluding(p); + for (int i = 0; i < 14; ++i) + BOOST_REQUIRE_EQUAL(success(), vote(voters[i], false, false, true)); + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), c.to_string()); + } + FC_LOG_AND_RETHROW() +} // ── repcandidate / vote guards ──────────────────────────────────────────── -BOOST_FIXTURE_TEST_CASE(repcandidate_and_vote_guards, sysio_councl_tester) { try { - init_ready(); - name p = proposer(); - name a = candidates_[0], b = candidates_[1], c = candidates_[2]; - - // not your turn - name not_p = (t1_owners[1] == p) ? t1_owners[2] : t1_owners[1]; - BOOST_REQUIRE_EQUAL(error("assertion failure with message: not your turn to nominate"), - repcandidate(not_p, a, b, c)); - // distinctness - BOOST_REQUIRE_EQUAL(error("assertion failure with message: slate candidates must be distinct"), - repcandidate(p, a, a, b)); - // unregistered candidate - BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate not registered"), - repcandidate(p, a, b, candidates_[25])); // [25] not registered by init_ready (23) - - // open a valid slate, then vote guards - BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); - BOOST_REQUIRE_EQUAL(error("assertion failure with message: the proposer cannot vote on their own slate"), - vote(p, true, false, false)); - auto voters = tier1_voters_excluding(p); - BOOST_REQUIRE_EQUAL(success(), vote(voters[0], true, false, false)); - BOOST_REQUIRE_EQUAL(error("assertion failure with message: already voted in this round"), - vote(voters[0], false, false, false)); -} FC_LOG_AND_RETHROW() } +BOOST_FIXTURE_TEST_CASE(repcandidate_and_vote_guards, sysio_councl_tester) { + try { + init_ready(); + name p = proposer(); + name a = candidates_[0], b = candidates_[1], c = candidates_[2]; -// ── escalation on nomination timeout: seat 0 -> tier 2 ──────────────────── -BOOST_FIXTURE_TEST_CASE(escalation_to_tier2_on_timeout, sysio_councl_tester) { try { - init_ready(/*n_candidates=*/23, /*n_t2=*/5, /*n_t3=*/0); - BOOST_REQUIRE_EQUAL(tier(), 1); - // tier-1 proposer never nominates; window elapses; settle escalates to tier 2. - elapse_and_settle(); - BOOST_REQUIRE_EQUAL(tier(), 2); - BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); - // The selected tier-2 proposer is read back (seed-agnostic) and must be one of the tier-2 owners. - name p2 = proposer(); - bool is_t2 = false; for (const auto& o : t2_owners) if (o == p2) is_t2 = true; - BOOST_REQUIRE(is_t2); -} FC_LOG_AND_RETHROW() } + // not your turn + name not_p = (t1_owners[1] == p) ? t1_owners[2] : t1_owners[1]; + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not your turn to nominate"), + repcandidate(not_p, a, b, c)); + // distinctness + BOOST_REQUIRE_EQUAL(error("assertion failure with message: slate candidates must be distinct"), + repcandidate(p, a, a, b)); + // unregistered candidate + BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate not registered"), + repcandidate(p, a, b, candidates_[25])); // [25] not registered by init_ready (23) -// ── governance backstop when tiers 2 & 3 are empty ──────────────────────── -BOOST_FIXTURE_TEST_CASE(backstop_forceassign, sysio_councl_tester) { try { - init_ready(/*n_candidates=*/23, /*n_t2=*/0, /*n_t3=*/0); // no escalation targets - elapse_and_settle(); // tier-1 nomination times out -> no tier2/3 -> BACKSTOP - BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); - // governance seats an un-elected candidate - BOOST_REQUIRE_EQUAL(success(), forceassign(candidates_[0])); - BOOST_REQUIRE_EQUAL(council_member(0).to_string(), candidates_[0].to_string()); - BOOST_REQUIRE_EQUAL(active_seat(), 1); -} FC_LOG_AND_RETHROW() } - -// ── full election: drive all 21 seats to DONE via tier-1 wins ───────────── -BOOST_FIXTURE_TEST_CASE(full_election_to_done, sysio_councl_tester) { try { - init_ready(/*n_candidates=*/26); // >= 21 winners + slate headroom - size_t next_cand = 0; - auto take = [&]() { return candidates_[next_cand++]; }; - - for (int seat = 0; seat < 21; ++seat) { - BOOST_REQUIRE_EQUAL(active_seat(), seat); - name p = proposer(); - name a = take(), b = take(), c = take(); // 3 fresh un-elected candidates - // return the 2 losers to the pool for reuse on later seats (only the winner is consumed) - next_cand -= 2; + // open a valid slate, then vote guards BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: the proposer cannot vote on their own slate"), + vote(p, true, false, false)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not eligible to vote in this tier"), + vote(candidates_[10], true, false, false)); auto voters = tier1_voters_excluding(p); - for (int i = 0; i < 14; ++i) BOOST_REQUIRE_EQUAL(success(), vote(voters[i], true, false, false)); - BOOST_REQUIRE_EQUAL(council_member(seat).to_string(), a.to_string()); + BOOST_REQUIRE_EQUAL(success(), vote(voters[0], true, false, false)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: already voted in this round"), + vote(voters[0], false, false, false)); + } + FC_LOG_AND_RETHROW() +} + +// ── escalation on nomination timeout: seat 0 -> tier 2 ──────────────────── +BOOST_FIXTURE_TEST_CASE(escalation_to_tier2_on_timeout, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/5, /*n_t3=*/0); + BOOST_REQUIRE_EQUAL(tier(), TIER_T1); + // tier-1 proposer never nominates; window elapses; settle escalates to tier 2. + elapse_and_settle(); + BOOST_REQUIRE_EQUAL(tier(), TIER_T2); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + // The selected tier-2 proposer is read back (seed-agnostic) and must be one of the tier-2 owners. + name p2 = proposer(); + bool is_t2 = false; + for (const auto& o : t2_owners) + if (o == p2) + is_t2 = true; + BOOST_REQUIRE(is_t2); + } + FC_LOG_AND_RETHROW() +} + +// ── tier-2 voting: proposer auto-yes plus two voters reaches 3/4 ────────── +BOOST_FIXTURE_TEST_CASE(tier2_auto_yes_and_win, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/4, /*n_t3=*/0); + elapse_and_settle(); + BOOST_REQUIRE_EQUAL(tier(), TIER_T2); + name p2 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p2, candidates_[0], candidates_[1], candidates_[2])); + BOOST_REQUIRE_EQUAL(yes1(), 1u); // proposer auto-yes + + auto voters = excluding(t2_owners, p2); + BOOST_REQUIRE_EQUAL(success(), vote(voters[0], true, false, false)); + BOOST_REQUIRE_EQUAL(success(), vote(voters[1], true, false, false)); + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), candidates_[0].to_string()); + } + FC_LOG_AND_RETHROW() +} + +// ── tier-2 failure escalates to tier 3 ─────────────────────────────────── +BOOST_FIXTURE_TEST_CASE(tier2_failure_escalates_to_tier3, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/3, /*n_t3=*/3); + elapse_and_settle(); + name p2 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p2, candidates_[0], candidates_[1], candidates_[2])); + auto voters2 = excluding(t2_owners, p2); + BOOST_REQUIRE_EQUAL(success(), vote(voters2[0], false, false, false)); + BOOST_REQUIRE_EQUAL(tier(), TIER_T3); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + BOOST_REQUIRE_EQUAL(tier3_available(), 2u); + } + FC_LOG_AND_RETHROW() +} + +// ── tier-3 retries are unique within a seat and terminate at BACKSTOP ──── +BOOST_FIXTURE_TEST_CASE(tier3_unique_retries_and_exhaustion, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/0, /*n_t3=*/3); + elapse_and_settle(); + BOOST_REQUIRE_EQUAL(tier(), TIER_T3); + + std::set selected; + for (int attempt = 0; attempt < 3; ++attempt) { + name p3 = proposer(); + BOOST_REQUIRE(selected.insert(p3).second); + BOOST_REQUIRE_EQUAL(tier3_available(), static_cast(2 - attempt)); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p3, candidates_[0], candidates_[1], candidates_[2])); + auto voters3 = excluding(t3_owners, p3); + BOOST_REQUIRE_EQUAL(success(), vote(voters3[0], false, false, false)); + } + BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); + BOOST_REQUIRE_EQUAL(selected.size(), 3u); + } + FC_LOG_AND_RETHROW() +} + +// ── a single tier-3 owner auto-wins and may propose again for another seat ─ +BOOST_FIXTURE_TEST_CASE(tier3_single_owner_reusable_on_next_seat, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/0, /*n_t3=*/1); + elapse_and_settle(); + name p3 = proposer(); + BOOST_REQUIRE_EQUAL(p3.to_string(), t3_owners[0].to_string()); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p3, candidates_[0], candidates_[1], candidates_[2])); + BOOST_REQUIRE_EQUAL(active_seat(), 1u); // N==1 proposer auto-yes elected c1 immediately + + elapse_and_settle(); + BOOST_REQUIRE_EQUAL(tier(), TIER_T3); + BOOST_REQUIRE_EQUAL(proposer().to_string(), p3.to_string()); + } + FC_LOG_AND_RETHROW() +} + +// ── a stale ballot commits settlement but is not recorded in the next round +BOOST_FIXTURE_TEST_CASE(late_vote_is_settlement_only, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/2, /*n_t3=*/0); + name p1 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p1, candidates_[0], candidates_[1], candidates_[2])); + auto voters1 = tier1_voters_excluding(p1); + BOOST_REQUIRE_EQUAL(success(), vote(voters1[0], true, false, false)); + const uint64_t old_round = round_id(); + + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), vote(voters1[1], true, true, true)); + BOOST_REQUIRE_GT(round_id(), old_round); + BOOST_REQUIRE_EQUAL(tier(), TIER_T2); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + BOOST_REQUIRE_EQUAL(votes_cast(), 0u); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(late_nomination_is_settlement_only, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/2, /*n_t3=*/0); + const name stale_proposer = proposer(); + const uint64_t old_round = round_id(); + + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), repcandidate(stale_proposer, candidates_[0], candidates_[1], candidates_[2])); + BOOST_REQUIRE_GT(round_id(), old_round); + BOOST_REQUIRE_EQUAL(tier(), TIER_T2); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + BOOST_REQUIRE_EQUAL(votes_cast(), 0u); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(full_turnout_failure_escalates, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/4, /*n_t3=*/1); + elapse_and_settle(); + const name p2 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p2, candidates_[0], candidates_[1], candidates_[2])); + const auto voters = excluding(t2_owners, p2); + BOOST_REQUIRE_EQUAL(voters.size(), 3u); + BOOST_REQUIRE_EQUAL(success(), vote(voters[0], false, false, true)); + BOOST_REQUIRE_EQUAL(success(), vote(voters[1], false, true, false)); + BOOST_REQUIRE_EQUAL(success(), vote(voters[2], true, false, false)); + BOOST_REQUIRE_EQUAL(tier(), TIER_T3); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + } + FC_LOG_AND_RETHROW() +} + +// ── authenticated stir advances entropy and also cranks elapsed state ──── +BOOST_FIXTURE_TEST_CASE(stir_uses_authenticated_caller_and_settles, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/2, /*n_t3=*/0); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "stir"_n, + mvo()("caller", candidates_[1].to_string())) != success()); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "settle"_n, + mvo()("caller", candidates_[1].to_string())) != success()); + const uint64_t before = stir_count(); + BOOST_REQUIRE_EQUAL(success(), stir(candidates_[0])); + BOOST_REQUIRE_EQUAL(stir_count(), before + 1); + + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), stir(candidates_[1])); + BOOST_REQUIRE_EQUAL(tier(), TIER_T2); + } + FC_LOG_AND_RETHROW() +} + +// ── governance may recover only an elapsed active attempt ──────────────── +BOOST_FIXTURE_TEST_CASE(governance_forceback_requires_elapsed_attempt, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/3, /*n_t3=*/3); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "forceback"_n, mvo()) != success()); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: the active attempt has not elapsed"), forceback()); + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), forceback()); + BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); + BOOST_REQUIRE_EQUAL(success(), forceassign(candidates_[0])); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(selection_replays_deterministically, sysio_councl_tester) { + try { + sysio_councl_tester replay; + init_ready(/*n_candidates=*/23, /*n_t2=*/4, /*n_t3=*/4); + replay.init_ready(/*n_candidates=*/23, /*n_t2=*/4, /*n_t3=*/4); + + elapse_and_settle(); + replay.elapse_and_settle(); + BOOST_REQUIRE_EQUAL(proposer().to_string(), replay.proposer().to_string()); + BOOST_REQUIRE_EQUAL(accumulator(), replay.accumulator()); + + const name p2 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p2, candidates_[0], candidates_[1], candidates_[2])); + BOOST_REQUIRE_EQUAL(success(), + replay.repcandidate(p2, replay.candidates_[0], replay.candidates_[1], replay.candidates_[2])); + const auto voters2 = excluding(t2_owners, p2); + for (size_t i = 0; i < 2; ++i) { + BOOST_REQUIRE_EQUAL(success(), vote(voters2[i], false, false, false)); + BOOST_REQUIRE_EQUAL(success(), replay.vote(voters2[i], false, false, false)); + } + BOOST_REQUIRE_EQUAL(proposer().to_string(), replay.proposer().to_string()); + BOOST_REQUIRE_EQUAL(accumulator(), replay.accumulator()); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(maximum_tier3_snapshot_and_retries, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(); + for (size_t i = 0; i < 1000; ++i) { + const name owner = bulk_name('y', i); + mk(owner); + forcereg_owner(owner, 3); + t3_owners.push_back(owner); + } + + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(get_config()["n3"].as(), 1000u); + + elapse_and_settle(); + std::set selected; + for (uint32_t attempt = 0; attempt < 1000; ++attempt) { + BOOST_REQUIRE_EQUAL(tier(), TIER_T3); + BOOST_REQUIRE(selected.insert(proposer()).second); + BOOST_REQUIRE_EQUAL(tier3_available(), 999u - attempt); + elapse_and_settle(); + } + BOOST_REQUIRE_EQUAL(selected.size(), 1000u); + BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); + } + FC_LOG_AND_RETHROW() +} + +// ── nomination and voting windows are inclusive at the exact deadline ──── +BOOST_FIXTURE_TEST_CASE(deadline_boundary_is_inclusive, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/2, /*n_t3=*/0); + const auto until_exact_deadline = fc::milliseconds(TIME_SLOT * 1000 - config::block_interval_ms); + + // The next action executes one block interval later, exactly at the nomination deadline. + produce_block(until_exact_deadline); + name p1 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p1, candidates_[0], candidates_[1], candidates_[2])); + BOOST_REQUIRE_EQUAL(phase(), PH_VOTING); + + // Exactly at the voting deadline, settle must leave the round open. + produce_block(until_exact_deadline); + BOOST_REQUIRE_EQUAL(success(), settle()); + BOOST_REQUIRE_EQUAL(phase(), PH_VOTING); + + // One block interval later, the same round is elapsed and escalates. + produce_block(); + BOOST_REQUIRE_EQUAL(success(), settle()); + BOOST_REQUIRE_EQUAL(tier(), TIER_T2); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); } - BOOST_REQUIRE_EQUAL(phase(), PH_DONE); - BOOST_REQUIRE_EQUAL(seats_filled(), 21); -} FC_LOG_AND_RETHROW() } + FC_LOG_AND_RETHROW() +} + +// ── governance backstop when tiers 2 & 3 are empty ──────────────────────── +BOOST_FIXTURE_TEST_CASE(backstop_forceassign, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/0, /*n_t3=*/0); // no escalation targets + elapse_and_settle(); // tier-1 nomination times out -> no tier2/3 -> BACKSTOP + BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[1], "forceassign"_n, + mvo()("member", candidates_[0].to_string())) != success()); + // governance seats an un-elected candidate + BOOST_REQUIRE_EQUAL(success(), forceassign(candidates_[0])); + BOOST_REQUIRE_EQUAL(council_member(0).to_string(), candidates_[0].to_string()); + BOOST_REQUIRE_EQUAL(active_seat(), 1); + } + FC_LOG_AND_RETHROW() +} + +// ── full two-generation election with staged cleanup and history retention ─ +BOOST_FIXTURE_TEST_CASE(full_election_reset_cleanup_and_second_generation, sysio_councl_tester) { + try { + auto drive_generation = [&](uint64_t generation) { + size_t next_cand = 0; + auto take = [&]() { return candidates_[next_cand++]; }; + for (int seat = 0; seat < 21; ++seat) { + BOOST_REQUIRE_EQUAL(active_seat(), seat); + name p = proposer(); + name a = take(), b = take(), c = take(); + next_cand -= 2; // only the winner is consumed; the two losers remain reusable + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, a, b, c)); + auto voters = tier1_voters_excluding(p); + for (int i = 0; i < 14; ++i) + BOOST_REQUIRE_EQUAL(success(), vote(voters[i], true, false, false)); + BOOST_REQUIRE_EQUAL(council_member(seat, generation).to_string(), a.to_string()); + } + BOOST_REQUIRE_EQUAL(phase(), PH_DONE); + BOOST_REQUIRE_EQUAL(seats_filled(), 21); + }; + + init_ready(/*n_candidates=*/26); + const int64_t candidate_ram_with_row = + control->get_resource_limits_manager().get_account_ram_usage(candidates_[0]); + drive_generation(/*generation=*/0); + + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "reset"_n, mvo()) != success()); + BOOST_REQUIRE_EQUAL(success(), reset()); + BOOST_REQUIRE_EQUAL(init_phase(), IP_CLEANING); + BOOST_REQUIRE(push(COUNCL_ACCOUNT, councl_abi, candidates_[0], "purge"_n, mvo()("max_rows", uint32_t{10})) != + success()); + for (int calls = 0; init_phase() == IP_CLEANING && calls < 20; ++calls) + BOOST_REQUIRE_EQUAL(success(), purge(/*max_rows=*/10)); + BOOST_REQUIRE_EQUAL(init_phase(), IP_REG); + BOOST_REQUIRE_EQUAL(election_gen(), 1u); + BOOST_REQUIRE(!council_member(0, 0).to_string().empty()); // permanent history retained + BOOST_REQUIRE(!candidate_exists(candidates_[0], 0)); + BOOST_REQUIRE(!roster_exists(0, 0)); + BOOST_CHECK_LT(control->get_resource_limits_manager().get_account_ram_usage(candidates_[0]), + candidate_ram_with_row); + + register_candidates(26); + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(init_phase(), IP_READY); + drive_generation(/*generation=*/1); + } + FC_LOG_AND_RETHROW() +} + +/// Cleanup must reclaim non-empty tier snapshots and per-seat tier-3 remaps while retaining history. +BOOST_FIXTURE_TEST_CASE(cleanup_reclaims_all_ephemeral_table_categories, sysio_councl_tester) { + try { + constexpr uint32_t TIER2_SIZE = 2; + constexpr uint32_t TIER3_SIZE = 9; + init_ready(/*n_candidates=*/26, TIER2_SIZE, TIER3_SIZE); + + // Reach tier 3 for seat zero and leave a real, non-empty lazy Fisher-Yates remap behind. + elapse_and_settle(); // tier 1 -> tier 2 + elapse_and_settle(); // tier 2 -> tier 3 + while (!tier3_remap_exists(GEN0, 0, TIER3_SIZE) && tier3_available() > 1) + elapse_and_settle(); + BOOST_REQUIRE(tier3_remap_exists(GEN0, 0, TIER3_SIZE)); + + // Governance closes the active attempt, then fills every seat so reset becomes available. + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), forceback()); + BOOST_REQUIRE_EQUAL(success(), forceassign(candidates_[0])); + for (uint8_t seat = 1; seat < 21; ++seat) { + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), forceback()); + BOOST_REQUIRE_EQUAL(success(), forceassign(candidates_[seat])); + } + BOOST_REQUIRE_EQUAL(phase(), PH_DONE); + + BOOST_REQUIRE(!tier2_owner(0).to_string().empty()); + BOOST_REQUIRE(!tier3_owner(0).to_string().empty()); + BOOST_REQUIRE(tier3_remap_exists(GEN0, 0, TIER3_SIZE)); + BOOST_REQUIRE_EQUAL(success(), reset()); + for (int calls = 0; init_phase() == IP_CLEANING && calls < 100; ++calls) + BOOST_REQUIRE_EQUAL(success(), purge(/*max_rows=*/2)); + + BOOST_REQUIRE_EQUAL(init_phase(), IP_REG); + BOOST_REQUIRE(tier2_owner(0).to_string().empty()); + BOOST_REQUIRE(tier3_owner(0).to_string().empty()); + BOOST_REQUIRE(!tier3_remap_exists(GEN0, 0, TIER3_SIZE)); + BOOST_REQUIRE(!council_member(0, GEN0).to_string().empty()); + } + FC_LOG_AND_RETHROW() +} BOOST_AUTO_TEST_SUITE_END()