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..e28642ac64 --- /dev/null +++ b/contracts/sysio.councl/CMakeLists.txt @@ -0,0 +1,48 @@ +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}) + target_ricardian_directory(${contract_name} ${CMAKE_CURRENT_SOURCE_DIR}/ricardian) + 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} + 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 new file mode 100644 index 0000000000..b1fce4e2b7 --- /dev/null +++ b/contracts/sysio.councl/DESIGN.md @@ -0,0 +1,391 @@ +# sysio.councl — Design and implementation + +> 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. 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: + +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; 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 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 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 | 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 + +```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; // 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) + + // 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; } +} + +static_assert(councl::T1_VOTERS == councl::SEATS - 1); +``` + +## 5. Randomness — entropy accumulator (Variant B) + +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; +uint64_t stir_count; + +void stir(name action_tag, name actor) { + acc = sha256(pack(acc, action_tag, actor, ++stir_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; +} +``` + +`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 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. +- 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:** 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) + +- **`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 (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. 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. 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 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 = 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 `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`, + `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 `resolve_or_settle()`. `check(phase == VOTING)`; + `check(voter` is eligible for the current tier and `!= proposer)`; + `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(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) +- **`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 + 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_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). + +## 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, no repeats)→BACKSTOP │ │ + └──────────────────── (next attempt) ────────────┘ active_seat==21? + 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 +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 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 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 + 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:** 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 + +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 (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; 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 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`). +- 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. 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 + +- **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..f4acb6ed77 --- /dev/null +++ b/contracts/sysio.councl/README.md @@ -0,0 +1,84 @@ +# 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, 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`) +> 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 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 + +| 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: 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(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(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, 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) | +| `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 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 + +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..71270c28b3 --- /dev/null +++ b/contracts/sysio.councl/include/sysio.councl/council_math.hpp @@ -0,0 +1,97 @@ +#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 vote + * @param deadline_hit the voting window has elapsed + */ +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 (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..f09c3bc3dc --- /dev/null +++ b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp @@ -0,0 +1,306 @@ +#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 + +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 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`/`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: + using contract::contract; + + // ---- Registration ------------------------------------------------------- + + /// 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); + + /// 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, + /// 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); + + /// Finalize init: verify the tier-2/3 snapshots are complete and open seat 0. Governance only. + [[sysio::action]] + void finalizeinit(); + + /// 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 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); + + /// Public caller-authenticated crank: push a timed-out attempt forward and stir entropy. + [[sysio::action]] + 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); + + /// Public caller-authenticated entropy crank; also advances elapsed election state. + [[sysio::action]] + void stir(name caller); + + // ----------------------------------------------------------------------- + // Tables + // ----------------------------------------------------------------------- + + /// Contract configuration + init progress singleton. + struct [[sysio::table("config")]] config_state { + 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 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 { + 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{}; + 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{}; + 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; + + 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>; + + /// 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>; + + /// 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)) + }; + 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 + 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: + /// 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 + /// 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 + /// 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); + /// 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 + /// 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 + /// 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.contracts.md b/contracts/sysio.councl/ricardian/sysio.councl.contracts.md new file mode 100644 index 0000000000..21979583ce --- /dev/null +++ b/contracts/sysio.councl/ricardian/sysio.councl.contracts.md @@ -0,0 +1,169 @@ +

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

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

+ +--- +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. Resume is identity-based, so newly observed owners are appended without +duplicating owners already loaded. 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 the first council seat's nomination window opens. + +

reset

+ +--- +spec_version: "0.2.0" +title: Begin Election Reset +summary: 'Begin staged cleanup of the completed election generation.' +--- + +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

+ +--- +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 ({{c1}}, {{c2}}, and +{{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: '{{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.' +--- + +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

+ +--- +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 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: '{{nowrap caller}} advances entropy and settles elapsed election state.' +--- + +{{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 new file mode 100644 index 0000000000..df311cf7b4 --- /dev/null +++ b/contracts/sysio.councl/src/sysio.councl.cpp @@ -0,0 +1,714 @@ +#include +#include +#include +#include +#include // roa::roastate_t / roa::nodeowners_t — tier membership +#include // sysiosystem::emissions::nodecountstate_t — tier counts +#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; + +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; +} + +// =========================================================================== +// Entropy accumulator (Variant B — block number intentionally excluded) +// =========================================================================== +void council::do_stir(election_state& st, name action_tag, name actor) { + ++st.stir_count; + st.acc = hash_tuple(std::make_tuple(st.acc, action_tag, actor, st.stir_count)); +} + +// =========================================================================== +// 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(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(); + 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; +} + +// =========================================================================== +// 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; +} + +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); + return frozen_member_index(r, who); + } + if (tier == councl::election_tier::T2) { + tier2_t t(get_self(), cfg.election_gen); + 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); + return frozen_member_index(t, who); +} + +// =========================================================================== +// Pseudo-random tier proposer selection (§5) +// =========================================================================== +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)).extract_as_byte_array()); + return councl_math::bounded_index(seed, available); +} + +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; +} + +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}); + } + } + 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, councl::election_tier tier, name proposer) { + st.tier = tier; + st.proposer = proposer; + ++st.round_id; + 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.voted_bitmap.clear(); + 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.tier3_available = cfg.n3; + if (st.active_seat >= councl::SEATS) { + st.phase = councl::election_phase::DONE; + return; + } + 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) { + 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::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( + 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 = 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::election_phase::VOTING) + return; + + const bool all_voted = st.votes_cast >= st.eligible_voters; + // 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}, + 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::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::election_phase::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::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(account, 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("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"); + 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("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(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_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(t1_raw); it != t1_idx.end(); ++it) { + if (it->tier != t1_raw) + break; + t1.push_back(it->owner); + } + 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); + 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.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()); + 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::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>(); + // 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 = *election_tier == councl::election_tier::T2 ? cfg.t2_loaded : cfg.t3_loaded; + const uint8_t raw_tier = tier_integer(*election_tier); + + if (*election_tier == councl::election_tier::T2) { + tier2_t t2(get_self(), cfg.election_gen); + 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); + 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); +} + +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::init_phase::LOADING, "not in the loading phase"); + + // Completeness cross-check against sysio.system's live tier counts. + 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::init_phase::READY; + cg.set(cfg, ram_payer); + + // Open seat 0 with a fresh accumulator seeded from the generation. + election_state st{}; + 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.tier3_available = cfg.n3; + open_tier_attempt(st, councl::election_tier::T1, 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::init_phase::READY, "reset requires a ready election generation"); + state_t sg(get_self()); + 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); +} + +// =========================================================================== +// 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::init_phase::READY, "election is not running"); + + state_t sg(get_self()); + election_state st = sg.get("election state missing"); + 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::election_phase::AWAIT_REP, "not accepting nominations right now"); + check(proposer == st.proposer, "not your turn to nominate"); + 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; + 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 == 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::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 + 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::init_phase::READY, "election is not running"); + + state_t sg(get_self()); + election_state st = sg.get("election state missing"); + 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::election_phase::VOTING, "voting is not open"); + check(voter != st.proposer, "the proposer cannot vote on their own slate"); + 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(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()); + election_state st = sg.get("election state missing"); + 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::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"); + check(st.phase == councl::election_phase::AWAIT_REP || st.phase == councl::election_phase::VOTING, + "no active attempt is eligible for governance recovery"); + + 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"); + + 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(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(); + 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); +} + +} // namespace sysio diff --git a/contracts/sysio.councl/sysio.councl.abi b/contracts/sysio.councl/sysio.councl.abi new file mode 100644 index 0000000000..f27970267c --- /dev/null +++ b/contracts/sysio.councl/sysio.councl.abi @@ -0,0 +1,685 @@ +{ + "____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": "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": "init_phase", + "type": "init_phase" + }, + { + "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": "cleanup_stage", + "type": "cleanup_stage" + }, + { + "name": "cleanup_seat", + "type": "uint8" + } + ] + }, + { + "name": "council_row", + "base": "", + "fields": [ + { + "name": "seat", + "type": "uint64" + }, + { + "name": "seat_owner", + "type": "name" + }, + { + "name": "filled_tier", + "type": "election_tier" + }, + { + "name": "proposer", + "type": "name" + }, + { + "name": "member", + "type": "name" + } + ] + }, + { + "name": "election_state", + "base": "", + "fields": [ + { + "name": "phase", + "type": "election_phase" + }, + { + "name": "active_seat", + "type": "uint8" + }, + { + "name": "tier", + "type": "election_tier" + }, + { + "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": "uint32" + }, + { + "name": "eligible_voters", + "type": "uint32" + }, + { + "name": "votes_cast", + "type": "uint32" + }, + { + "name": "tier3_available", + "type": "uint32" + }, + { + "name": "seats_filled", + "type": "uint8" + }, + { + "name": "voted_bitmap", + "type": "bytes" + }, + { + "name": "c1", + "type": "name" + }, + { + "name": "c2", + "type": "name" + }, + { + "name": "c3", + "type": "name" + }, + { + "name": "yes1", + "type": "uint32" + }, + { + "name": "yes2", + "type": "uint32" + }, + { + "name": "yes3", + "type": "uint32" + }, + { + "name": "no1", + "type": "uint32" + }, + { + "name": "no2", + "type": "uint32" + }, + { + "name": "no3", + "type": "uint32" + }, + { + "name": "acc", + "type": "checksum256" + }, + { + "name": "stir_count", + "type": "uint64" + } + ] + }, + { + "name": "finalizeinit", + "base": "", + "fields": [] + }, + { + "name": "forceassign", + "base": "", + "fields": [ + { + "name": "member", + "type": "name" + } + ] + }, + { + "name": "forceback", + "base": "", + "fields": [] + }, + { + "name": "index_key", + "base": "", + "fields": [ + { + "name": "idx", + "type": "uint64" + } + ] + }, + { + "name": "loadtier", + "base": "", + "fields": [ + { + "name": "tier", + "type": "uint8" + }, + { + "name": "max_rows", + "type": "uint32" + } + ] + }, + { + "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": "", + "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": "caller", + "type": "name" + } + ] + }, + { + "name": "startinit", + "base": "", + "fields": [ + { + "name": "time_slot_sec", + "type": "uint64" + }, + { + "name": "ordered_owners", + "type": "name[]" + } + ] + }, + { + "name": "stir", + "base": "", + "fields": [ + { + "name": "caller", + "type": "name" + } + ] + }, + { + "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": "vote", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "v1", + "type": "bool" + }, + { + "name": "v2", + "type": "bool" + }, + { + "name": "v3", + "type": "bool" + } + ] + } + ], + "actions": [ + { + "name": "addcandidate", + "type": "addcandidate", + "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": "---\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": "---\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": "---\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": "---\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": "---\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": "---\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": "---\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": "---\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": "---\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": "---\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": "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": "tier3remap", + "type": "remap_row", + "index_type": "i64", + "key_names": ["scope","idx"], + "key_types": ["name","uint64"], + "table_id": 27642 + } + ], + "ricardian_clauses": [], + "variants": [], + "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 new file mode 100755 index 0000000000..b1cddbadeb Binary files /dev/null and b/contracts/sysio.councl/sysio.councl.wasm differ 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..662a8709ea --- /dev/null +++ b/contracts/tests/council_math_tests.cpp @@ -0,0 +1,165 @@ +/** + * @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 +#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); +} +} // 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); + + // 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(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); + 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_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 new file mode 100644 index 0000000000..10043af861 --- /dev/null +++ b/contracts/tests/sysio.councl_tests.cpp @@ -0,0 +1,1036 @@ +/// Integration tests for sysio.councl — the council election contract. +/// +/// 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 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 "contracts.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; + +using mvo = fc::mutable_variant_object; + +namespace { + +// 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. +name name_idx(const std::string& prefix, size_t i) { + BOOST_REQUIRE_LT(i, 26u); + 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 + + // 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); + // 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. + 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 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) + 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(); + } + + /// 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) { + 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 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())); + } + 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(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")); + } + 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)); + } + 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(); } + 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, 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)); + 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, 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)); + 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)); + 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; + } + + /// 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 { + 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: 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() +} + +// ── staged load: roa tier churn between loadtier batches ────────────────── +/// 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 + + // 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 + + // 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)); // 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()); + } + 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(); + 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_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)); + 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); + 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); + } + 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()