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.chalg/README.md b/contracts/sysio.chalg/README.md index 7517aa3ae1..cdd4addb25 100644 --- a/contracts/sysio.chalg/README.md +++ b/contracts/sysio.chalg/README.md @@ -15,14 +15,14 @@ OPP envelope dispute resolution and slash-execution contract. | Table | Type | Description | |-------|------|-------------| -| `disputes` | `kv::table` | One row per disputed (outpost, epoch): candidate checksums, status, winning checksum, opened-at / deadline | +| `disputes` | `kv::table` | One row per disputed (outpost, epoch): candidate checksums, frozen Tier-1 electorate/quorum, status, winning checksum, opened-at / deadline | | `disputevote` | `kv::scoped_table` (scope = `dispute_id`) | One Tier-1 node-owner vote per row: owner, chosen checksum | ## Actions | Action | Auth | Description | |--------|------|-------------| -| `opendispute` | `sysio.msgch` | Open a dispute for an (outpost, epoch) carrying 3+ candidate envelope versions; pauses the epoch | +| `opendispute` | `sysio.msgch` | Open a dispute, snapshot its Tier-1 electorate/quorum, and pause the epoch | | `votedispute` | `owner` (Tier-1) | Cast a Tier-1 node-owner vote for the canonical envelope checksum | | `chkdispute` | permissionless | Tally the votes; on resolution dispatch the winner and unpause the epoch | | `slashop` | `sysio.chalg` or `sysio.epoch` | Execute a slash on an operator via `sysio.opreg` | @@ -31,23 +31,24 @@ OPP envelope dispute resolution and slash-execution contract. 1. **Open**: `sysio.msgch::evalcons` sees the active batch operators deliver 3+ distinct envelope versions for one (outpost, epoch) with no majority, and calls `opendispute` inline. The dispute - records the candidate checksums and pauses `sysio.epoch`. -2. **Vote**: Tier-1 node owners (looked up in `sysio.roa::nodeowners`, scoped by the active - `network_gen` from `sysio.roa::roastate`) call `votedispute` with one of the candidate checksums. - One vote per owner. -3. **Tally**: anyone cranks `chkdispute`. With `N = sysio.system::nodecount.t1_count` and - `Q = floor(N/2)+1`: a checksum reaching `Q` votes wins at any time (fast path); after the 24h + records the candidate checksums, snapshots the active ROA generation's Tier-1 electorate and + fixed quorum, and pauses `sysio.epoch`. +2. **Vote**: owners in the dispute's frozen Tier-1 electorate call `votedispute` with one of the + candidate checksums. Later ROA registrations cannot join an in-flight dispute. One vote per owner. +3. **Tally**: anyone cranks `chkdispute`. With `N` equal to the snapshotted electorate size and + fixed `Q = floor(N/2)+1`, a checksum reaching `Q` votes wins at any time (fast path); after the 24h deadline the bar relaxes to a quorum of cast votes (`cast >= Q`) plus a strict majority of cast (`2*votes > cast`). No plurality / tie-break -- an undecided tally keeps waiting for votes. -4. **Resolve**: the winning checksum is recorded, dispatched via `sysio.msgch::resolvedisp`, and - `sysio.epoch` is unpaused. The next `sysio.epoch::advance` then slashes every operator that - delivered a non-canonical checksum for the epoch (via `slashop`), deduped across outposts. +4. **Resolve**: the winning checksum is recorded and dispatched via `sysio.msgch::resolvedisp`. + `sysio.epoch` is unpaused when the final open dispute resolves. The next + `sysio.epoch::advance` then slashes every operator that delivered a non-canonical checksum for + the epoch (via `slashop`), deduped across outposts. ## Dependencies - Triggered by `sysio.msgch::evalcons`; dispatches the winning envelope via `sysio.msgch::resolvedisp` -- Tier-1 voter eligibility from `sysio.roa::nodeowners` / `roastate`; live Tier-1 count from - `sysio.system::nodecount` +- Tier-1 electorate snapshot from `sysio.roa::nodeowners`; the active `network_gen` is read through + the shared canonical ROA accessor - Slashes via `sysio.opreg::slash` (opreg routes the unlocked bond to the matching LP and defers the locked portion through `sysio.uwrit::release`) - Pauses / unpauses via `sysio.epoch::pause` / `sysio.epoch::unpause` diff --git a/contracts/sysio.chalg/src/sysio.chalg.cpp b/contracts/sysio.chalg/src/sysio.chalg.cpp index 7c0a844425..543a7e6182 100644 --- a/contracts/sysio.chalg/src/sysio.chalg.cpp +++ b/contracts/sysio.chalg/src/sysio.chalg.cpp @@ -1,6 +1,6 @@ #include -#include // T1 electorate snapshot at opendispute: sysio.roa::nodeowners / roastate +#include // authoritative T1 electorate snapshot at opendispute #include #include @@ -62,9 +62,7 @@ void chalg::opendispute(uint64_t chain_code, // snapshot for the dispute's whole life, so they cannot diverge from each other, and later // registrations or a generation rotation cannot change an in-flight dispute's electorate or // quorum. - roa::roastate_t roastate(ROA_ACCOUNT); - check(roastate.exists(), "roa state not initialized"); - const uint8_t network_gen = roastate.get().network_gen; + const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT); roa::nodeowners_t nodeowners(ROA_ACCOUNT, network_gen); auto by_tier = nodeowners.get_index<"bytier"_n>(); diff --git a/contracts/sysio.chalg/sysio.chalg.wasm b/contracts/sysio.chalg/sysio.chalg.wasm index bcd8f15bb1..35b049021e 100755 Binary files a/contracts/sysio.chalg/sysio.chalg.wasm and b/contracts/sysio.chalg/sysio.chalg.wasm differ diff --git a/contracts/sysio.councl/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..3631e04d15 --- /dev/null +++ b/contracts/sysio.councl/DESIGN.md @@ -0,0 +1,430 @@ +# 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.roa`.** Generation-scoped `nodeowners` rows are authoritative for + both membership and counts. ROA enforces the shared caps (`T1_MAX = 21`, `T2_MAX = 84`, + `T3_MAX = 1000`) before inserting a row. `sysio.system::nodecount` remains an optional emissions + mirror and is deliberately not an election authority because pre-`setemitcfg` registrations are + absent from it. +- **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 unbound nomination/vote commits settlement only; optional `expected_round` makes clients fail loudly instead. | +| 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` aborts `LOADING` while preserving candidate registration, aborts an active `READY` election while deleting partial results, or retires `DONE` while retaining history. READY cleanup advances `election_gen`. | +| 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; // system RAM payer + + // 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); +static_assert(win_threshold(councl::T1_VOTERS) + 1 == win_threshold(councl::SEATS)); +static_assert(elim_threshold(councl::T1_VOTERS) == elim_threshold(councl::SEATS)); +``` + +## 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 while allowing any account to advance liveness, but cheap identities +mean authentication does not make multi-account grinding economically expensive. More sharply, a +single actor controlling several eligible identities can simulate the deterministic post-action +accumulator and selected proposer for every identity *offline*, then submit only the favorable +identity in that same transition-triggering action. This same-call choice is an accepted limitation +of Variant B, not merely a choice of transaction timing. + +## 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. Node owners may also register as candidates, so the lone +proposer can nominate and immediately seat themselves; the contract intentionally does not impose +an electorate-size floor or proposer/candidate exclusion. + +## 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. +- Governance can also call `reset` at any time in `READY` to abandon a misconfigured or + operationally impractical election immediately. Batched purge deletes partial council results, + advances the generation, and reopens registration; this is distinct from `forceback`, which + preserves the election and fills only the current seat. +- 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, persistent ROA scan cursors and + completion flags, candidate count, cleanup mode, 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, persistent source cursors, and + scan-completion flags. +- **`loadtier(uint8 tier, uint32 max_rows)`** — `require_auth(get_self())`, `LOADING` only. + Inspects at most `max_rows` ROA owner rows in deterministic primary-owner order and appends the + matching tier-2 or tier-3 identities into the snapshot. The persistent last-inspected cursor + bounds reads as well as writes, including calls where most source rows belong to another tier. + Reaching end sets that tier's scan-complete flag. A subsequent call starts a new deduplicating + pass, allowing owners inserted earlier than the prior cursor to be absorbed after a failed + completeness check without quadratic wrap scans. +- **`finalizeinit()`** — `require_auth(get_self())`, `LOADING` only. + 1. Require both source scans complete, then count generation-scoped ROA tier-2/tier-3 rows and + check them against the deduplicated snapshot sizes. This works even when emissions were never + configured and detects membership inserted during a staged scan. + 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 in `LOADING` or `READY`. Enter `CLEANING`; do not advance the + generation yet. A LOADING abort preserves candidate rows and returns to `REG` in the same + generation after removing roster/snapshots. An active READY abort also removes partial council + rows and advances the generation. A DONE reset advances the generation but retains council + history. +- **`purge(max_rows)`** — contract auth. Delete at most `max_rows` rows across resumable, + mode-specific cleanup stages. Unknown stage values abort explicitly instead of spinning. + Completion removes the global election state before reopening `REG`. + +### Election +- **`repcandidate(name proposer, name c1, name c2, name c3, optional expected_round)`** — + `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, optional expected_round)`** — + `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()`. +- For both nomination and voting, a supplied `expected_round` must match before settlement and + causes a deadline-crossing transition to fail/roll back. Omitting it preserves the permissionless + settlement-only behavior. Each action also asserts its own inclusive deadline after settlement, + so future state-machine changes cannot accidentally accept a late payload. +- **`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()`**: if `seats_filled == SEATS` → `phase = DONE` while `active_seat` remains 20; + otherwise increment it and start a fresh T1 attempt (`++round_id`, `AWAIT_REP`, timers reset). + +## 10. State machine + +``` +REG ──startinit──> LOADING ──loadtier*──> finalizeinit ──> READY + │ + └──reset──> CLEANING ──purge*──> REG + │ + ┌──────────────── 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) ────────────┘ seats_filled==21? + elapsed attempt ──forceback──> BACKSTOP ──forceassign──> win_attempt + │ yes → DONE + └─ no → seat k+1 (AWAIT_REP) + +READY(active) ──reset──> CLEANING ──purge*──> REG (next generation, partial results removed) +DONE ──────────reset──> CLEANING ──purge*──> REG (next generation, history retained) +``` + +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 public KV tables. The + snapshots at init make the running election immune to mid-election ROA + churn. `finalizeinit` also verifies that the captured ROA generation is still current; if a + generation or count divergence prevents finalization, governance can rescan or abort LOADING + without making candidates repay registration RAM. +- **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 ephemeral rows are purged before the next generation. Council history grows by exactly 21 + retained rows per completed generation; partial rows from an active abort are deleted. + +## 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; source-read-bounded `loadtier` batching and + churn-safe rescan; ROA-authoritative finalization without emissions config; LOADING abort that + preserves paid candidate registration; ROA cap enforcement when `nodecount` lags. +- 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, retained completed + council history, removed global state, and active-abort deletion of partial results. +- Late nomination/vote settlement-only behavior plus fail-loud `expected_round`; 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..d5b22f274f --- /dev/null +++ b/contracts/sysio.councl/README.md @@ -0,0 +1,90 @@ +# 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 generation-scoped `sysio.roa` authority shared with `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 | Inspect a bounded source batch while loading a resumable tier-2/3 snapshot. | +| `finalizeinit()` | contract | Verify completed snapshots vs generation-scoped ROA rows; open seat 0. | +| `reset()` | contract | Abort LOADING/active READY or retire DONE; begin mode-specific staged cleanup. | +| `purge(max_rows)` | contract | Delete bounded cleanup batches; advance generation and reopen registration when complete. | +| `repcandidate(proposer, c1, c2, c3, expected_round?)` | `proposer` | Nominate a slate, optionally fail-loud if the round changes. | +| `vote(voter, v1, v2, v3, expected_round?)` | `voter` | Vote on the slate, optionally bound to the expected round. | +| `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, generations, tier sizes, bounded-scan cursors/flags, cleanup mode/position | +| `state` | global | — | live cursor, current slate + tallies, bounded `voted_bitmap`, entropy accumulator | +| `candidates` | scoped | generation | `account`, `handle`, `elected` | +| `roster` / `tier2` / `tier3` | scoped | generation | frozen ordered node-owner snapshots (by-owner secondary index) | +| `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) | + +`voted_bitmap` is a field of the `state` singleton, not a separate table row. It holds at most one +bit per frozen tier 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. +If staged loading cannot be finalized, governance can take `LOADING` → `reset` → `purge*` → +registration in the same generation without making candidates repay their rows. Governance can +also abort an active READY election; that path advances the generation and deletes partial council +results, while DONE cleanup retains completed history. + +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..f723034886 --- /dev/null +++ b/contracts/sysio.councl/include/sysio.councl/sysio.councl.hpp @@ -0,0 +1,332 @@ +#pragma once + +/** + * @file sysio.councl.hpp + * @brief Council election contract — fills 21 council seats via a tier-1 → tier-2 → tier-3 + * escalation ladder with strict-priority slate voting. See DESIGN.md for the full model. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sysio { + +/// Contract-wide constants and identifiers for sysio.councl. +namespace councl { +inline constexpr uint8_t SEATS = 21; ///< tier-1 owners == council seats +inline constexpr uint8_t T1_VOTERS = 20; ///< SEATS - 1 (seat owner never votes) +inline constexpr uint8_t SLATE_SIZE = 3; ///< candidates per repcandidate +inline constexpr uint8_t MIN_CANDIDATES = SEATS + 2; ///< 23: <=20 elected before the last seat, +3 +inline constexpr 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; ///< system 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 +}; + +/// Why the current cleanup was started; determines which generation data is retained. +enum class cleanup_mode : uint8_t { + NONE = 0, ///< no cleanup is active + INIT_ABORT = 1, ///< discard only staged snapshots and preserve candidate registration + ACTIVE_ABORT = 2, ///< discard an unfinished election, including partial council results + COMPLETED = 3 ///< retire a completed generation while retaining council history +}; + +/// 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, + COUNCIL = 5, + COMPLETE = 6 +}; + +static_assert(T1_VOTERS == SEATS - 1, "tier-1 electorate must exclude exactly the seat owner"); +static_assert(SLATE_SIZE == 3, "the fixed action and state schema require a three-candidate slate"); +} // 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); + + /// Inspect at most `max_rows` roa owner rows while appending tier-`tier` (2 or 3) owners into + /// the frozen snapshot. The persistent source cursor makes both reads and writes bounded; + /// call repeatedly until that tier's scan-complete flag is set. 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(); + + /// Abort LOADING or any READY election and enter staged cleanup. A LOADING abort preserves the + /// candidate registry and generation; READY cleanup advances the generation. Governance only. + [[sysio::action]] + void reset(); + + /// Delete up to `max_rows` mode-specific cleanup rows and finish reset once empty. Completed + /// council results are retained; partial results from an active abort are deleted. + [[sysio::action]] + void purge(uint32_t max_rows); + + // ---- Election ----------------------------------------------------------- + + /// The active proposer nominates a slate of 3 distinct, un-elected candidates. When supplied, + /// `expected_round` makes a stale or deadline-crossing request fail instead of acting as a + /// settlement-only crank. + [[sysio::action]] + void repcandidate(name proposer, name c1, name c2, name c3, std::optional expected_round); + + /// 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. + /// `expected_round` provides optional fail-loud round binding. + [[sysio::action]] + void vote(name voter, bool v1, bool v2, bool v3, std::optional expected_round); + + /// 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 + uint64_t t2_cursor = 0; ///< last roa primary owner inspected by the tier-2 scan + uint64_t t3_cursor = 0; ///< last roa primary owner inspected by the tier-3 scan + bool t2_scan_complete = false; ///< tier-2 scan reached the end of the roa owner scope + bool t3_scan_complete = false; ///< tier-3 scan reached the end of the roa owner scope + uint32_t cand_count = 0; ///< registered candidates (current generation) + councl::cleanup_mode cleanup_mode = councl::cleanup_mode::NONE; + 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)(t2_cursor)(t3_cursor) + (t2_scan_complete)(t3_scan_complete)(cand_count)(cleanup_mode)(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 helpers + /// Read the current ROA network generation. + uint8_t roa_network_gen() const; + /// Count generation-scoped ROA owners in a tier. + uint32_t tier_count(uint8_t network_gen, 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..89d5695611 --- /dev/null +++ b/contracts/sysio.councl/ricardian/sysio.councl.contracts.md @@ -0,0 +1,179 @@ +

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 inspects at most {{max_rows}} node-owner rows from sysio.roa while appending +tier-{{tier}} identities into the frozen escalation snapshot. A persistent source cursor bounds +reads as well as writes, and identity deduplication allows a later pass to absorb newly observed +owners. Called repeatedly until the tier scan is 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 source scans must be complete, +their snapshot sizes are verified against authoritative generation-scoped sysio.roa rows, and the +first council seat's nomination window opens. + +

reset

+ +--- +spec_version: "0.2.0" +title: Begin Election Reset +summary: 'Abort initialization or an election, or clean a completed generation.' +--- + +The contract owner starts cleanup while initialization is loading or while an election is active +or complete. A loading abort preserves candidate registrations and reopens registration in the +same generation. An active-election abort advances the generation and removes partial council +results. A completed election advances the generation while retaining its council history. The +contract owner must call `purge` until cleanup completes. + +

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}} rows from the mode-specific candidate, roster, +tier-snapshot, remap, and optional partial-council cleanup stages. Completion removes live election +state and reopens registration. Completed council history is retained; partial results from an +aborted active election are not. + +

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. Optional {{expected_round}} binds the +request to a specific round; if the round is stale or elapses during lazy settlement, the action +fails and rolls back instead of acting as a settlement-only crank. Omitting it preserves the +settlement-only behavior. + +## 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. +Optional {{expected_round}} binds the ballot to a specific round and makes a stale or +deadline-crossing ballot fail instead of silently settling the election. + +## 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..5a49190cb5 --- /dev/null +++ b/contracts/sysio.councl/src/sysio.councl.cpp @@ -0,0 +1,797 @@ +#include +#include +#include +#include +#include +#include +#include // roa::roastate_t / roa::nodeowners_t — tier membership +#include // shared node-owner caps +#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(); + +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"); +static_assert(councl_math::win_threshold(councl::T1_VOTERS) + 1 == councl_math::win_threshold(councl::SEATS), + "tier-1 and proposer-auto-yes tiers must require the same number of voter approvals"); +static_assert(councl_math::elim_threshold(councl::T1_VOTERS) == councl_math::elim_threshold(councl::SEATS), + "tier-1 and proposer-auto-yes tiers must retain equivalent rejection thresholds"); + +/// 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); +} + +static_assert(tier_integer(councl::election_tier::T1) == + magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1) && + tier_integer(councl::election_tier::T2) == + magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T2) && + tier_integer(councl::election_tier::T3) == + magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T3), + "council and protobuf node-owner tiers must retain identical wire values"); + +/// 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; +} + +/// Result of one bounded table-erasure pass. +struct erase_result { + uint32_t erased; + bool drained; +}; + +/// Erase at most `max_rows` rows and report both the number removed and whether the table drained. +template +erase_result 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(std::move(it)); + ++erased; + } + return erase_result{erased, it == table.end()}; +} + +/// 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); +} + +/// Result of one source-read-bounded snapshot pass. +struct snapshot_result { + uint32_t scanned; + uint32_t written; + uint64_t cursor; + bool complete; +}; + +/// Inspect at most `max_rows` ROA rows and append matching, not-yet-snapshotted owners. +template +snapshot_result append_snapshot(Table& table, roa::nodeowners_t& owners, uint8_t raw_tier, uint32_t already, + uint64_t cursor, uint32_t max_rows) { + auto by_owner = table.template get_index<"byowner"_n>(); + uint32_t scanned = 0; + uint32_t written = 0; + + // The persistent primary-key cursor makes max_rows a real read bound. Reaching end marks this + // pass complete. If finalize later detects a newly inserted earlier identity, another loadtier + // call restarts at begin and the by-owner index absorbs only the newcomer. + auto it = cursor == 0 ? owners.begin() : owners.upper_bound(roa::nodeowner_key{cursor}); + while (it != owners.end() && scanned < max_rows) { + const auto owner = *it; + ++it; + ++scanned; + cursor = owner.owner.value; + if (owner.tier == raw_tier && by_owner.find(owner.owner.value) == by_owner.end()) { + const uint64_t snapshot_index = static_cast(already) + written; + table.emplace(RAM_PAYER, council::index_key{snapshot_index}, Row{snapshot_index, owner.owner}); + ++written; + } + } + const bool complete = it == owners.end(); + return snapshot_result{scanned, written, complete ? 0 : cursor, complete}; +} + +/// Return SHA-256 over the canonical packed representation of the supplied values. +template +checksum256 hash_args(const Args&... args) { + auto packed = pack(std::tie(args...)); + 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_args(st.acc, action_tag, actor, st.stir_count); +} + +// =========================================================================== +// Cross-contract reads (sysio.roa) +// =========================================================================== +uint8_t council::roa_network_gen() const { + return roa::current_network_gen(councl::ROA_ACCOUNT); +} + +uint32_t council::tier_count(uint8_t network_gen, councl::election_tier tier) const { + check(tier != councl::election_tier::GOVERNANCE, "governance is not a node-owner tier"); + return roa::nodeowner_count(councl::ROA_ACCOUNT, network_gen, tier_integer(tier)); +} + +// =========================================================================== +// 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 active_seat = st.active_seat; + const uint64_t seed = + councl_math::seed_u64(hash_args(st.acc, 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 auto selected = remap.try_get(selected_key); + const auto last_value = j == last ? selected : remap.try_get(last_key); + const uint64_t actual = selected ? selected->actual_idx : j; + const uint64_t replacement = last_value ? last_value->actual_idx : last; + + if (j != last) + remap.set(RAM_PAYER, selected_key, remap_row{j, replacement}); + if (last_value) + 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) { + if (st.seats_filled >= councl::SEATS) { + st.phase = councl::election_phase::DONE; + return; + } + ++st.active_seat; + st.tier3_available = cfg.n3; + 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); + cands.emplace(account, cand_key{account.value}, candidate_row{account, handle, false}, "already a candidate"); + + ++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); + cands.erase(cand_key{account.value}, "not a candidate"); + + --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"); + + // Compare sorted in-memory copies once instead of point-reading the byowner index while it is + // being populated. Preserve ordered_owners itself as the governance-selected seat order. + auto sorted_owners = ordered_owners; + std::sort(sorted_owners.begin(), sorted_owners.end()); + check(std::adjacent_find(sorted_owners.begin(), sorted_owners.end()) == sorted_owners.end(), + "duplicate owner in ordered_owners"); + std::sort(t1.begin(), t1.end()); + check(sorted_owners == t1, "ordered_owners contains a non tier-1 owner"); + + roster_t roster(get_self(), cfg.election_gen); + for (uint64_t i = 0; i < ordered_owners.size(); ++i) { + const name owner = ordered_owners[i]; + roster.emplace(RAM_PAYER, index_key{i}, roster_row{i, owner}); + } + + 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; + cfg.t2_cursor = cfg.t3_cursor = 0; + cfg.t2_scan_complete = cfg.t3_scan_complete = false; + 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); + // Resume in primary owner order from the last inspected identity. max_rows bounds source reads, + // even when most rows belong to another tier or have already been snapshotted. + 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) { + if (cfg.t2_scan_complete) { + cfg.t2_cursor = 0; + cfg.t2_scan_complete = false; + } + tier2_t t2(get_self(), cfg.election_gen); + const auto result = append_snapshot(t2, no, raw_tier, already, cfg.t2_cursor, max_rows); + cfg.t2_loaded += result.written; + cfg.t2_cursor = result.cursor; + cfg.t2_scan_complete = result.complete; + // Defense in depth: normal ROA registration enforces the same system cap before this row + // can exist, but reject corrupt or incompatible cross-contract state explicitly. + check(cfg.t2_loaded <= sysiosystem::emissions::T2_MAX_NODE_OWNERS, + "tier-2 snapshot exceeds the system owner cap"); + } else { + if (cfg.t3_scan_complete) { + cfg.t3_cursor = 0; + cfg.t3_scan_complete = false; + } + tier3_t t3(get_self(), cfg.election_gen); + const auto result = append_snapshot(t3, no, raw_tier, already, cfg.t3_cursor, max_rows); + cfg.t3_loaded += result.written; + cfg.t3_cursor = result.cursor; + cfg.t3_scan_complete = result.complete; + // Defense in depth; see the tier-2 cap check above. + 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"); + + // The snapshot scope and tier values together guarantee identity as well as count completeness. + check(cfg.network_gen == roa_network_gen(), "roa network generation changed during initialization"); + check(cfg.t2_scan_complete, "tier-2 source scan incomplete"); + check(cfg.t3_scan_complete, "tier-3 source scan incomplete"); + const uint32_t c2 = tier_count(cfg.network_gen, councl::election_tier::T2); + const uint32_t c3 = tier_count(cfg.network_gen, 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_args(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"); + if (cfg.init_phase == councl::init_phase::LOADING) { + // Candidate registration predates the failed snapshot and remains valid. Purge only staged + // roster/tier rows and reopen REG in the same generation. + cfg.cleanup_mode = councl::cleanup_mode::INIT_ABORT; + cfg.cleanup_stage = councl::cleanup_stage::ROSTER; + } else { + check(cfg.init_phase == councl::init_phase::READY, + "reset requires a loading or active election generation"); + state_t sg(get_self()); + const election_state st = sg.get("election state missing"); + cfg.cleanup_mode = st.phase == councl::election_phase::DONE ? councl::cleanup_mode::COMPLETED + : councl::cleanup_mode::ACTIVE_ABORT; + cfg.cleanup_stage = councl::cleanup_stage::CANDIDATES; + } + + cfg.init_phase = councl::init_phase::CLEANING; + 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"); + check(cfg.cleanup_mode == councl::cleanup_mode::INIT_ABORT || + cfg.cleanup_mode == councl::cleanup_mode::ACTIVE_ABORT || + cfg.cleanup_mode == councl::cleanup_mode::COMPLETED, + "unknown cleanup mode"); + + 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); + const auto result = erase_rows(table, remaining); + erased = result.erased; + if (result.drained) + cfg.cleanup_stage = councl::cleanup_stage::ROSTER; + break; + } + case councl::cleanup_stage::ROSTER: { + roster_t table(get_self(), cfg.election_gen); + const auto result = erase_rows(table, remaining); + erased = result.erased; + if (result.drained) + cfg.cleanup_stage = councl::cleanup_stage::TIER2; + break; + } + case councl::cleanup_stage::TIER2: { + tier2_t table(get_self(), cfg.election_gen); + const auto result = erase_rows(table, remaining); + erased = result.erased; + if (result.drained) + cfg.cleanup_stage = councl::cleanup_stage::TIER3; + break; + } + case councl::cleanup_stage::TIER3: { + tier3_t table(get_self(), cfg.election_gen); + const auto result = erase_rows(table, remaining); + erased = result.erased; + if (result.drained) { + cfg.cleanup_stage = cfg.cleanup_mode == councl::cleanup_mode::INIT_ABORT + ? councl::cleanup_stage::COMPLETE + : councl::cleanup_stage::REMAP; + } + break; + } + case councl::cleanup_stage::REMAP: { + tier3_remap_t table(get_self(), gr_scope(cfg.election_gen, cfg.cleanup_seat)); + const auto result = erase_rows(table, remaining); + erased = result.erased; + if (result.drained) { + ++cfg.cleanup_seat; + if (cfg.cleanup_seat >= councl::SEATS) { + cfg.cleanup_stage = cfg.cleanup_mode == councl::cleanup_mode::ACTIVE_ABORT + ? councl::cleanup_stage::COUNCIL + : councl::cleanup_stage::COMPLETE; + } + } + break; + } + case councl::cleanup_stage::COUNCIL: { + council_t table(get_self(), cfg.election_gen); + const auto result = erase_rows(table, remaining); + erased = result.erased; + if (result.drained) + cfg.cleanup_stage = councl::cleanup_stage::COMPLETE; + break; + } + case councl::cleanup_stage::COMPLETE: + break; + default: + check(false, "unknown cleanup stage"); + } + remaining -= erased; + } + + if (cfg.cleanup_stage == councl::cleanup_stage::COMPLETE) { + check(cfg.cleanup_mode != councl::cleanup_mode::NONE, "cleanup mode is not set"); + const bool preserve_registry = cfg.cleanup_mode == councl::cleanup_mode::INIT_ABORT; + if (!preserve_registry) { + ++cfg.election_gen; + cfg.cand_count = 0; + } + cfg.init_phase = councl::init_phase::REG; + cfg.time_slot_sec = 0; + cfg.n2 = cfg.n3 = cfg.t2_loaded = cfg.t3_loaded = 0; + cfg.t2_cursor = cfg.t3_cursor = 0; + cfg.t2_scan_complete = cfg.t3_scan_complete = false; + cfg.cleanup_mode = councl::cleanup_mode::NONE; + cfg.cleanup_seat = 0; + state_t(get_self()).remove(); + } + cg.set(cfg, RAM_PAYER); +} + +// =========================================================================== +// Election +// =========================================================================== +void council::repcandidate(name proposer, name c1, name c2, name c3, std::optional expected_round) { + 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"); + if (expected_round.has_value()) + check(*expected_round == st.round_id, "round does not match expected_round"); + 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) { + check(!expected_round.has_value(), "round elapsed before nomination could be applied"); + 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(current_time_point() <= st.round_open_ts + sysio::seconds(cfg.time_slot_sec), + "nomination deadline has elapsed"); + 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); + const std::array slate{c1, c2, c3}; + for (name c : slate) { + 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 + CHAR_BIT - 1) / CHAR_BIT, 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, std::optional expected_round) { + 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"); + if (expected_round.has_value()) + check(*expected_round == st.round_id, "round does not match expected_round"); + 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) { + check(!expected_round.has_value(), "round elapsed before vote could be applied"); + 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(current_time_point() <= st.vote_deadline, "voting deadline has elapsed"); + 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 / CHAR_BIT; + const uint8_t bit_mask = uint8_t{1} << (voter_index % CHAR_BIT); + 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()); + election_state st = sg.get("election has not started"); + 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..f9020df1c3 --- /dev/null +++ b/contracts/sysio.councl/sysio.councl.abi @@ -0,0 +1,739 @@ +{ + "____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": "t2_cursor", + "type": "uint64" + }, + { + "name": "t3_cursor", + "type": "uint64" + }, + { + "name": "t2_scan_complete", + "type": "bool" + }, + { + "name": "t3_scan_complete", + "type": "bool" + }, + { + "name": "cand_count", + "type": "uint32" + }, + { + "name": "cleanup_mode", + "type": "cleanup_mode" + }, + { + "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": "expected_round", + "type": "uint64?" + } + ] + }, + { + "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" + }, + { + "name": "expected_round", + "type": "uint64?" + } + ] + } + ], + "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 source scans must be complete,\ntheir snapshot sizes are verified against authoritative generation-scoped sysio.roa rows, and the\nfirst 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 inspects at most {{max_rows}} node-owner rows from sysio.roa while appending\ntier-{{tier}} identities into the frozen escalation snapshot. A persistent source cursor bounds\nreads as well as writes, and identity deduplication allows a later pass to absorb newly observed\nowners. Called repeatedly until the tier scan is 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}} rows from the mode-specific candidate, roster,\ntier-snapshot, remap, and optional partial-council cleanup stages. Completion removes live election\nstate and reopens registration. Completed council history is retained; partial results from an\naborted active election are not." + }, + { + "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. Optional {{expected_round}} binds the\nrequest to a specific round; if the round is stale or elapses during lazy settlement, the action\nfails and rolls back instead of acting as a settlement-only crank. Omitting it preserves the\nsettlement-only behavior.\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: 'Abort initialization or an election, or clean a completed generation.'\n---\n\nThe contract owner starts cleanup while initialization is loading or while an election is active\nor complete. A loading abort preserves candidate registrations and reopens registration in the\nsame generation. An active-election abort advances the generation and removes partial council\nresults. A completed election advances the generation while retaining its council history. The\ncontract owner must call `purge` until cleanup completes." + }, + { + "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.\nOptional {{expected_round}} binds the ballot to a specific round and makes a stale or\ndeadline-crossing ballot fail instead of silently settling the election.\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_mode", + "type": "uint8", + "values": [ + { + "name": "NONE", + "value": 0 + }, + { + "name": "INIT_ABORT", + "value": 1 + }, + { + "name": "ACTIVE_ABORT", + "value": 2 + }, + { + "name": "COMPLETED", + "value": 3 + } + ] + }, + { + "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": "COUNCIL", + "value": 5 + }, + { + "name": "COMPLETE", + "value": 6 + } + ] + }, + { + "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..d62880a065 Binary files /dev/null and b/contracts/sysio.councl/sysio.councl.wasm differ diff --git a/contracts/sysio.roa/sysio.roa.cpp b/contracts/sysio.roa/sysio.roa.cpp index 6f53d8fb3f..b2b2f1a508 100644 --- a/contracts/sysio.roa/sysio.roa.cpp +++ b/contracts/sysio.roa/sysio.roa.cpp @@ -817,6 +817,19 @@ namespace sysio { auto node_key = nodeowner_key{owner.value}; check(!nodeowners.contains(node_key), "This account is already registered."); + // ROA rows are the authoritative membership set. Enforce the tier caps here instead of + // relying on sysio.system::nodecount, which is an optional emissions-distribution mirror + // and deliberately misses registrations made before setemitcfg. + uint32_t tier_cap = 0; + switch (tier) { + case 1: tier_cap = sysiosystem::emissions::T1_MAX_NODE_OWNERS; break; + case 2: tier_cap = sysiosystem::emissions::T2_MAX_NODE_OWNERS; break; + case 3: tier_cap = sysiosystem::emissions::T3_MAX_NODE_OWNERS; break; + default: check(false, "Tier level must be between 1 and 3"); + } + check(nodeowner_count(get_self(), state.network_gen, tier) < tier_cap, + "node owner tier cap reached"); + // Get the total SYS allocation for this tier asset total_sys_allocation = get_allocation_for_tier(tier); diff --git a/contracts/sysio.roa/sysio.roa.hpp b/contracts/sysio.roa/sysio.roa.hpp index 3d4dfa333f..78d54eb3d8 100644 --- a/contracts/sysio.roa/sysio.roa.hpp +++ b/contracts/sysio.roa/sysio.roa.hpp @@ -277,6 +277,30 @@ namespace sysio { kv::index<"bytier"_n, const_mem_fun> >; + /** + * Canonical cross-contract accessor for the active ROA network generation. + * Sibling system contracts should use this instead of duplicating singleton reads. + */ + static uint8_t current_network_gen(name roa_account) { + roastate_t state(roa_account); + check(state.exists(), "roa state not initialized"); + return state.get().network_gen; + } + + /** + * Count the authoritative node-owner rows for `tier` in one ROA generation. + * Unlike sysio.system's optional emissions counter, this remains correct for owners + * registered before emissions configuration and across network generations. + */ + static uint32_t nodeowner_count(name roa_account, uint8_t network_gen, uint8_t tier) { + nodeowners_t owners(roa_account, network_gen); + auto by_tier = owners.get_index<"bytier"_n>(); + uint32_t count = 0; + for (auto it = by_tier.lower_bound(tier); it != by_tier.end() && it->tier == tier; ++it) + ++count; + return count; + } + private: /** diff --git a/contracts/sysio.roa/sysio.roa.wasm b/contracts/sysio.roa/sysio.roa.wasm index 834daced9e..ad1204bc76 100755 Binary files a/contracts/sysio.roa/sysio.roa.wasm and b/contracts/sysio.roa/sysio.roa.wasm differ diff --git a/contracts/tests/CMakeLists.txt b/contracts/tests/CMakeLists.txt index af225359ec..7e93a0f994 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..2f3f700b36 --- /dev/null +++ b/contracts/tests/council_math_tests.cpp @@ -0,0 +1,168 @@ +/** + * @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) { + // Empty tiers are skipped by the state machine; keep the math guard deterministic as well. + BOOST_CHECK(R({0, 0, 0}, {0, 0, 0}, 0, false, false).result == round_result::FAIL); + + // 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..9585812d08 --- /dev/null +++ b/contracts/tests/sysio.councl_tests.cpp @@ -0,0 +1,1310 @@ +/// 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 (emissions mirror), 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 "sysio.system_tester.hpp" + +#include +#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_GOVERNANCE = "GOVERNANCE"; +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; + + explicit sysio_councl_tester(bool configure_emissions = true) { + 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 so tests also exercise the optional emissions membership mirror. + 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"))); + if (configure_emissions) + 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) { + sysio_system::test_support::load_account_abi(*this, account, out_ser); + } + + void setup_emission_config() { + push(config::system_account_name, system_abi, config::system_account_name, "setemitcfg"_n, + mvo()("cfg", sysio_system::test_support::default_emission_config())); + produce_blocks(); + } + + /// Register `owner` at `tier` in authoritative sysio.roa::nodeowners. When emissions are + /// configured, regnodeowner also updates sysio.system's distribution mirror. + 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) { + return sysio_system::test_support::push_contract_action(*this, contract, ser, signer, action_name, data); + } + + // ── 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, + std::optional expected_round = std::nullopt) { + mvo data; + data("proposer", proposer.to_string())("c1", c1.to_string())("c2", c2.to_string())("c3", c3.to_string()); + if (expected_round.has_value()) + data("expected_round", *expected_round); + return push(COUNCL_ACCOUNT, councl_abi, proposer, "repcandidate"_n, data); + } + action_result vote(name voter, bool v1, bool v2, bool v3, + std::optional expected_round = std::nullopt) { + mvo data; + data("voter", voter.to_string())("v1", v1)("v2", v2)("v3", v3); + if (expected_round.has_value()) + data("expected_round", *expected_round); + return push(COUNCL_ACCOUNT, councl_abi, voter, "vote"_n, data); + } + 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)); + load_tier_fully(2); + load_tier_fully(3); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + } + + /// Complete a source-read-bounded tier scan across as many calls as necessary. + void load_tier_fully(uint8_t tier, uint32_t batch_size = 1000) { + const char* complete_field = tier == 2 ? "t2_scan_complete" : "t3_scan_complete"; + for (uint32_t calls = 0; !get_config()[complete_field].as_bool() && calls < 10000; ++calls) + BOOST_REQUIRE_EQUAL(success(), loadtier(tier, batch_size)); + BOOST_REQUIRE(get_config()[complete_field].as_bool()); + } + + // ── 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()); } + + /// ABI-decoded output row for a filled seat, or an empty variant when absent. + /// Per-election tables are scoped by the generation, so the scope name's value is election_gen. + fc::variant council_seat(uint64_t seat, uint64_t generation = GEN0) { + auto data = get_row_by_id(COUNCL_ACCOUNT, name(generation), "council"_n, seat); + if (data.empty()) + return fc::variant{}; + return councl_abi.binary_to_variant("council_row", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// Elected member for a filled seat, or the empty name if the seat row is absent. + name council_member(uint64_t seat, uint64_t generation = GEN0) { + const auto row = council_seat(seat, generation); + return row.is_null() ? name{} : name(row["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(); + } + + bool state_exists() { + return !get_row_by_account(COUNCL_ACCOUNT, COUNCL_ACCOUNT, "state"_n, "state"_n).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) { + // Must match GR_SCOPE_X_BITS in sysio.councl.cpp. + 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; + } +}; + +class sysio_councl_without_emissions_tester : public sysio_councl_tester { +public: + sysio_councl_without_emissions_tester() : sysio_councl_tester(false) {} +}; + +// =========================================================================== +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); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not a candidate"), rmcandidate(candidates_[0])); + } + 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_rejects_duplicate_roster_owner, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(); + std::vector duplicate = t1_owners; + duplicate.back() = duplicate.front(); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: duplicate owner in ordered_owners"), + startinit(TIME_SLOT, duplicate)); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(startinit_requires_exactly_21_roa_tier1_owners, sysio_councl_tester) { + try { + register_candidates(23); + // Genesis supplies NODE_DADDY; register only 19 of the remaining 20 owners. + for (size_t i = 1; i < t1_owners.size() - 1; ++i) + forcereg_owner(t1_owners[i], 1); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: roa tier-1 owner count does not match the council seat count"), + startinit(TIME_SLOT, t1_owners)); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(roa_enforces_authoritative_tier1_cap_when_emissions_counter_lags, + sysio_councl_tester) { + try { + register_tiers(); // ROA has 21; nodecount has 20 because NODE_DADDY predates setemitcfg. + const name extra{"extraowner"}; + mk(extra); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: node owner tier cap reached"), + push(ROA_ACCOUNT, roa_abi, ROA_ACCOUNT, "forcereg"_n, + mvo()("owner", extra.to_string())("tier", uint8_t{1}))); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(finalize_uses_roa_without_emissions_configuration, + sysio_councl_without_emissions_tester) { + try { + register_candidates(23); + register_tiers(/*n_t2=*/2, /*n_t3=*/1); // every registration predates setemitcfg. + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + load_tier_fully(2); + load_tier_fully(3); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(init_phase(), IP_READY); + BOOST_REQUIRE_EQUAL(get_config()["n2"].as(), 2u); + BOOST_REQUIRE_EQUAL(get_config()["n3"].as(), 1u); + } + 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], "reset"_n, mvo()) != success()); + 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)); + // max_rows bounds source rows inspected, including rows from other tiers. + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 3)); + BOOST_REQUIRE(!get_config()["t2_scan_complete"].as_bool()); + BOOST_REQUIRE_NE(get_config()["t2_cursor"].as(), 0u); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: tier-2 source scan incomplete"), finalizeinit()); + load_tier_fully(2, 3); + // finalize before tier-3 loaded -> incomplete + BOOST_REQUIRE_EQUAL(error("assertion failure with message: tier-3 source scan incomplete"), finalizeinit()); + load_tier_fully(3); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(get_config()["n2"].as(), 5u); + BOOST_REQUIRE_EQUAL(get_config()["n3"].as(), 9u); + BOOST_REQUIRE_EQUAL(phase(), PH_AWAIT_REP); + BOOST_REQUIRE_EQUAL(active_seat(), 0); + BOOST_REQUIRE_EQUAL(proposer().to_string(), t1_owners[0].to_string()); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(loadtier_validates_phase_tier_and_batch_size, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(/*n_t2=*/2, /*n_t3=*/1); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not in the loading phase"), loadtier(2, 1)); + for (const uint8_t invalid_tier : {uint8_t{0}, uint8_t{1}, uint8_t{4}}) + BOOST_REQUIRE_EQUAL(error("assertion failure with message: tier must be T2 or T3"), + loadtier(invalid_tier, 1)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: max_rows must be positive"), loadtier(2, 0)); + + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); + const uint32_t loaded = get_config()["t2_loaded"].as(); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); + BOOST_REQUIRE_EQUAL(get_config()["t2_loaded"].as(), loaded); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not in the loading phase"), loadtier(2, 1)); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(action_phase_guards_and_registration_closure, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(); + const name candidate = candidates_[0]; + const name unregistered = candidates_[23]; + + BOOST_REQUIRE_EQUAL(error("assertion failure with message: election is not running"), + repcandidate(t1_owners[0], candidates_[0], candidates_[1], candidates_[2])); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: election is not running"), + vote(t1_owners[1], true, false, false)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: election is not running"), settle()); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: election is not running"), stir(candidate)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: generation cleanup is not active"), purge(1)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: max_rows must be positive"), purge(0)); + + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate registration is closed"), + addcandidate(unregistered, "closed")); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate registration is closed"), + rmcandidate(candidate)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: election is not running"), + vote(t1_owners[1], true, false, false)); + + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1000)); + BOOST_REQUIRE_EQUAL(success(), loadtier(3, 1000)); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: voting is not open"), + vote(t1_owners[1], true, false, false)); + + const name active_proposer = proposer(); + BOOST_REQUIRE_EQUAL(success(), + repcandidate(active_proposer, candidates_[0], candidates_[1], candidates_[2])); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not accepting nominations right now"), + repcandidate(active_proposer, candidates_[3], candidates_[4], candidates_[5])); + BOOST_REQUIRE_EQUAL(success(), reset()); + BOOST_REQUIRE_EQUAL(init_phase(), IP_CLEANING); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: election is not running"), + forceassign(candidates_[3])); + } + 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)); + load_tier_fully(2); // snapshots twob/twoc and marks the first source pass complete + + // 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)); // a new pass absorbs "twoa" through identity dedup + load_tier_fully(3); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + + // 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() +} + +/// A failed or obsolete staged snapshot must be recoverable without redeploying the contract. +BOOST_FIXTURE_TEST_CASE(loading_generation_can_be_aborted_purged_and_restarted, sysio_councl_tester) { + try { + register_candidates(23); + register_tiers(/*n_t2=*/2, /*n_t3=*/1); + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + BOOST_REQUIRE_EQUAL(success(), loadtier(2, 1)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: tier-2 source scan incomplete"), finalizeinit()); + + BOOST_REQUIRE_EQUAL(success(), reset()); + BOOST_REQUIRE_EQUAL(init_phase(), IP_CLEANING); + 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(), GEN0); + BOOST_REQUIRE(candidate_exists(candidates_[0], GEN0)); + BOOST_REQUIRE_EQUAL(get_config()["cand_count"].as(), 23u); + BOOST_REQUIRE(!roster_exists(0, GEN0)); + BOOST_REQUIRE(tier2_owner(0, GEN0).to_string().empty()); + BOOST_REQUIRE(!state_exists()); + + BOOST_REQUIRE_EQUAL(success(), startinit(TIME_SLOT, t1_owners)); + load_tier_fully(2); + load_tier_fully(3); + BOOST_REQUIRE_EQUAL(success(), finalizeinit()); + BOOST_REQUIRE_EQUAL(init_phase(), IP_READY); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(active_election_can_be_aborted_without_retaining_partial_results, + sysio_councl_tester) { + try { + init_ready(); + const name p = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p, candidates_[0], candidates_[1], candidates_[2])); + const auto voters = tier1_voters_excluding(p); + for (size_t i = 0; i < 14; ++i) + BOOST_REQUIRE_EQUAL(success(), vote(voters[i], true, false, false)); + BOOST_REQUIRE(!council_member(0, GEN0).to_string().empty()); + + // Governance need not wait through a long configured slot or the remaining seats. + BOOST_REQUIRE_EQUAL(success(), reset()); + for (int calls = 0; init_phase() == IP_CLEANING && calls < 100; ++calls) + BOOST_REQUIRE_EQUAL(success(), purge(/*max_rows=*/5)); + + BOOST_REQUIRE_EQUAL(init_phase(), IP_REG); + BOOST_REQUIRE_EQUAL(election_gen(), 1u); + BOOST_REQUIRE(!state_exists()); + BOOST_REQUIRE(council_member(0, GEN0).to_string().empty()); + BOOST_REQUIRE(!candidate_exists(candidates_[0], GEN0)); + } + 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() +} + +BOOST_FIXTURE_TEST_CASE(council_rows_record_owner_tier_proposer_and_member, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/1, /*n_t3=*/1); + auto require_row = [&](uint64_t seat, name seat_owner, const char* filled_tier, name row_proposer, name member) { + const auto row = council_seat(seat); + BOOST_REQUIRE(!row.is_null()); + BOOST_REQUIRE_EQUAL(row["seat"].as(), seat); + BOOST_REQUIRE_EQUAL(row["seat_owner"].as_string(), seat_owner.to_string()); + BOOST_REQUIRE_EQUAL(row["filled_tier"].as_string(), filled_tier); + BOOST_REQUIRE_EQUAL(row["proposer"].as_string(), row_proposer.to_string()); + BOOST_REQUIRE_EQUAL(row["member"].as_string(), member.to_string()); + }; + + const name p1 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p1, candidates_[0], candidates_[4], candidates_[5])); + const auto voters1 = tier1_voters_excluding(p1); + for (size_t i = 0; i < 14; ++i) + BOOST_REQUIRE_EQUAL(success(), vote(voters1[i], true, false, false)); + require_row(0, t1_owners[0], TIER_T1, p1, candidates_[0]); + + elapse_and_settle(); // seat 1: T1 -> T2 + const name p2 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p2, candidates_[1], candidates_[4], candidates_[5])); + require_row(1, t1_owners[1], TIER_T2, p2, candidates_[1]); + + elapse_and_settle(); // seat 2: T1 -> T2 + elapse_and_settle(); // seat 2: T2 -> T3 + const name p3 = proposer(); + BOOST_REQUIRE_EQUAL(success(), repcandidate(p3, candidates_[2], candidates_[4], candidates_[5])); + require_row(2, t1_owners[2], TIER_T3, p3, candidates_[2]); + + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(success(), forceback()); + BOOST_REQUIRE_EQUAL(success(), forceassign(candidates_[3])); + require_row(3, t1_owners[3], TIER_GOVERNANCE, COUNCL_ACCOUNT, candidates_[3]); + } + FC_LOG_AND_RETHROW() +} + +BOOST_FIXTURE_TEST_CASE(tier2_failure_without_tier3_enters_backstop, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/2, /*n_t3=*/0); + 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(), 1u); + BOOST_REQUIRE_EQUAL(success(), vote(voters[0], false, false, false)); + BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); + } + 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(expected_round_makes_late_actions_fail_loud, sysio_councl_tester) { + try { + init_ready(/*n_candidates=*/23, /*n_t2=*/2, /*n_t3=*/0); + const name p1 = proposer(); + const uint64_t nomination_round = round_id(); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: round does not match expected_round"), + repcandidate(p1, candidates_[0], candidates_[1], candidates_[2], nomination_round + 1)); + + BOOST_REQUIRE_EQUAL(success(), + repcandidate(p1, candidates_[0], candidates_[1], candidates_[2], nomination_round)); + const auto voters = tier1_voters_excluding(p1); + produce_block(fc::seconds(TIME_SLOT + 1)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: round elapsed before vote could be applied"), + vote(voters[0], true, true, true, nomination_round)); + + // The fail-loud transaction rolled settlement back; an explicit crank advances the round. + BOOST_REQUIRE_EQUAL(round_id(), nomination_round); + BOOST_REQUIRE_EQUAL(phase(), PH_VOTING); + BOOST_REQUIRE_EQUAL(success(), settle()); + BOOST_REQUIRE_GT(round_id(), nomination_round); + BOOST_REQUIRE_EQUAL(tier(), TIER_T2); + } + 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)); + load_tier_fully(2); + load_tier_fully(3); + 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 + BOOST_REQUIRE_EQUAL(error("assertion failure with message: not awaiting a governance assignment"), + forceassign(candidates_[0])); + 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()); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: no active attempt is eligible for governance recovery"), forceback()); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: member is not a candidate"), + forceassign(candidates_[25])); + // 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); + + elapse_and_settle(); + BOOST_REQUIRE_EQUAL(phase(), PH_BACKSTOP); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: candidate already elected to a seat"), + forceassign(candidates_[0])); + } + 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); + BOOST_REQUIRE_EQUAL(active_seat(), 20); + }; + + 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_EQUAL(error("assertion failure with message: election is complete"), settle()); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: election is complete"), stir(candidates_[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_REQUIRE(!state_exists()); + 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() diff --git a/contracts/tests/sysio.dispute_tests.cpp b/contracts/tests/sysio.dispute_tests.cpp index 2591a9d126..43a7371936 100644 --- a/contracts/tests/sysio.dispute_tests.cpp +++ b/contracts/tests/sysio.dispute_tests.cpp @@ -39,6 +39,7 @@ // Canonical-encoding + header-derivation oracle: inbound envelopes must carry // spec-derived semantic headers or apply_consensus drops them before dispatch. #include "opp_envelope_oracle.hpp" +#include "sysio.system_tester.hpp" using namespace sysio::testing; using namespace sysio; @@ -161,11 +162,7 @@ class sysio_dispute_tester : public tester { // ── deploy / 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)); + sysio_system::test_support::load_account_abi(*this, account, out_ser); } void deploy(name account, std::vector wasm, std::vector abi, abi_serializer& out_ser) { @@ -181,23 +178,8 @@ class sysio_dispute_tester : public tester { } void setup_emission_config() { - auto cfg = mvo() - ("t1_allocation", int64_t(7500000000000000))("t2_allocation", int64_t(1000000000000000)) - ("t3_allocation", int64_t(100000000000000)) - ("t1_duration", uint32_t(12u*30u*24u*3600u))("t2_duration", uint32_t(24u*30u*24u*3600u)) - ("t3_duration", uint32_t(36u*30u*24u*3600u)) - ("min_claimable", int64_t(10000000000)) - ("t5_distributable", int64_t(375000000000000000LL))("t5_floor", int64_t(125000000000000000LL)) - ("target_annual_decay_bps", uint16_t(6940)) - ("annual_initial_emission", int64_t(563150000000000LL*365)) - ("annual_max_emission", int64_t(3000000000000000LL*365)) - ("annual_min_emission", int64_t(100000000000000LL*365)) - ("compute_bps", uint16_t(4000))("capex_bps", uint16_t(2000))("governance_bps", uint16_t(1000)) - ("producer_bps", uint16_t(7000))("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) - ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); push(config::system_account_name, system_abi, config::system_account_name, - "setemitcfg"_n, mvo()("cfg", cfg)); + "setemitcfg"_n, mvo()("cfg", sysio_system::test_support::default_emission_config())); produce_blocks(); } @@ -205,27 +187,7 @@ class sysio_dispute_tester : public tester { 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); - // Land each successful action in its own block so repeated identical actions (e.g. a - // second chkdispute / double-vote) get a distinct TaPoS ref and reach the contract guard - // rather than being dropped as a duplicate transaction. - produce_block(); - return success(); - } catch (const fc::exception& ex) { - return error(ex.top_message()); - } + return sysio_system::test_support::push_contract_action(*this, contract, ser, signer, action_name, data); } // ── OPP stack bootstrap: one batch op, one outpost, advance to epoch 1 ──── diff --git a/contracts/tests/sysio.system_tester.hpp b/contracts/tests/sysio.system_tester.hpp index 5e970410d9..1d2d9ba796 100644 --- a/contracts/tests/sysio.system_tester.hpp +++ b/contracts/tests/sysio.system_tester.hpp @@ -25,6 +25,67 @@ using mvo = fc::mutable_variant_object; namespace sysio_system { +namespace test_support { + +/// Load an account's deployed ABI into a serializer used by contract integration fixtures. +template +void load_account_abi(Tester& tester, name account, abi_serializer& out_ser) { + const auto* metadata = tester.control->find_account_metadata(account); + BOOST_REQUIRE(metadata != nullptr); + abi_def parsed; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(metadata->abi, parsed), true); + out_ser.set_abi(std::move(parsed), abi_serializer::create_yield_function(Tester::abi_serializer_max_time)); +} + +/// Push an ABI-encoded action and land it in its own block for stable TaPoS in repeated tests. +template +typename Tester::action_result push_contract_action(Tester& tester, name contract, abi_serializer& serializer, + name signer, name action_name, + const fc::variant_object& data) { + try { + action act; + act.account = contract; + act.name = action_name; + act.data = serializer.variant_to_binary( + serializer.get_action_type(action_name), data, + abi_serializer::create_yield_function(Tester::abi_serializer_max_time)); + act.authorization = std::vector{{signer, config::active_name}}; + + signed_transaction trx; + trx.actions.emplace_back(std::move(act)); + tester.set_transaction_headers(trx); + trx.sign(tester.get_private_key(signer, "active"), tester.control->get_chain_id()); + tester.push_transaction(trx); + tester.produce_block(); + return Tester::success(); + } catch (const fc::exception& ex) { + return Tester::error(ex.top_message()); + } +} + +/// Canonical valid emissions configuration shared by system-contract integration fixtures. +inline fc::mutable_variant_object default_emission_config() { + return 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)); +} + +} // namespace test_support + class sysio_system_tester : public TESTER { public: diff --git a/tests/sysio_util_snapshot_info_test.py b/tests/sysio_util_snapshot_info_test.py index ccb0b42067..aa581a3bcd 100755 --- a/tests/sysio_util_snapshot_info_test.py +++ b/tests/sysio_util_snapshot_info_test.py @@ -17,7 +17,7 @@ "result": { "version": 1, "chain_id": "087244f65e31c0106a58554b8f855e30ae657efb98c6c40348bb14db8bdb3f8e", - "head_block_id": "0000001dd8ac497760f09f90d77e2eb78afbdfe5ff7bae0bb19b10c5b717042f", + "head_block_id": "0000001dc03fb88c34c8236780275757bfc52fff27c327968a5a52cf497e3078", "head_block_num": 29, "head_block_time": "2025-01-01T00:00:14.000" } diff --git a/unittests/snapshots/blocks.index b/unittests/snapshots/blocks.index index 4c40d0e7ce..0652cc222b 100644 Binary files a/unittests/snapshots/blocks.index and b/unittests/snapshots/blocks.index differ diff --git a/unittests/snapshots/blocks.log b/unittests/snapshots/blocks.log index 7b7d86d3ac..553eee440f 100644 Binary files a/unittests/snapshots/blocks.log and b/unittests/snapshots/blocks.log differ diff --git a/unittests/snapshots/snap_v1.bin.gz b/unittests/snapshots/snap_v1.bin.gz index 80d7528dd1..4fd8c3e90a 100644 Binary files a/unittests/snapshots/snap_v1.bin.gz and b/unittests/snapshots/snap_v1.bin.gz differ diff --git a/unittests/snapshots/snap_v1.bin.json.gz b/unittests/snapshots/snap_v1.bin.json.gz index 1d6f274994..d1e215c805 100644 Binary files a/unittests/snapshots/snap_v1.bin.json.gz and b/unittests/snapshots/snap_v1.bin.json.gz differ diff --git a/unittests/snapshots/snap_v1.json.gz b/unittests/snapshots/snap_v1.json.gz index 9cc29836a0..8bd2c26406 100644 Binary files a/unittests/snapshots/snap_v1.json.gz and b/unittests/snapshots/snap_v1.json.gz differ