Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
48 changes: 48 additions & 0 deletions contracts/sysio.councl/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/../../libraries/opp/generated-cdt>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../../libraries/libfc-lite/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opp.common/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.system/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.roa>
)

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()
401 changes: 401 additions & 0 deletions contracts/sysio.councl/DESIGN.md

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions contracts/sysio.councl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# sysio.councl

Council election contract. Fills 21 council seats — one per tier-1 node owner — by electing a
candidate from a shared pool. The right to *propose* a seat's slate of 3 candidates starts with
that seat's tier-1 owner and **escalates** through tier-2 and tier-3 node owners if the earlier
tier fails to elect anyone, ending in a governance backstop that can fill every seat. Final
completion requires governance to act at that backstop. See
[DESIGN.md](DESIGN.md) for the full model and rationale.

> Implemented against the modern KV table stack (`kv::table` / `kv::scoped_table` / `kv::global`)
> and the `sysio.roa` / `sysio.system` cross-contract read pattern used by `sysio.chalg`. The pure
> election arithmetic lives in a dependency-free header
> ([`council_math.hpp`](include/sysio.councl/council_math.hpp)) and is unit-tested host-side in
> [`contracts/tests/council_math_tests.cpp`](../tests/council_math_tests.cpp).

## Responsibility

- Registers council **candidates** (a sysio account + a short handle).
- Snapshots the tiered **node-owner** sets from `sysio.roa::nodeowners` (the tier-1 roster in a
governance-chosen order; the full tier-2 and tier-3 sets for escalation).
- Runs, per seat, a **strict-priority slate vote**: candidates are considered in submission order;
candidate *i+1* is only considered once candidate *i* is mathematically eliminated.
- **Escalates** a failed seat T1 → T2 → T3, choosing tier-2/3 proposers pseudo-randomly from an
in-contract entropy accumulator, and falls back to a governance assignment if tier-3 is exhausted.

## Election rules

| Quantity | Rule |
|----------|------|
| Win threshold | `floor(N·2/3) + 1` YES, where `N` is the tier's electorate size |
| Elimination | a candidate is out at `ceil(N/3)` NO (can no longer reach the threshold) |
| Tier-1 | `N = 20` (the other tier-1 owners); the seat owner does not vote, no auto-yes |
| Tier-2 / Tier-3 | `N =` full tier size; the proposer auto-yes counts for all 3 candidates; all other tier members vote |
| Timing | one inclusive `time_slot_sec` window per nomination and vote; bounded to 30 days |
| Randomness | SHA-256 accumulator over authenticated election activity (block number and timestamp excluded); folds in seat + round for retries |

## Actions

| Action | Auth | Purpose |
|--------|------|---------|
| `addcandidate(account, handle)` | `account` | Self-register as a candidate (registration phase). |
| `rmcandidate(account)` | contract | Remove a candidate before init. |
| `startinit(time_slot_sec, ordered_owners[21])` | contract | Freeze the tier-1 roster; close registration. |
| `loadtier(tier, max_rows)` | contract | Batch-load the tier-2/3 snapshot from roa (resumable). |
| `finalizeinit()` | contract | Verify snapshots vs `sysio.system::nodecount`; open seat 0. |
| `reset()` | contract | Abort LOADING or, after DONE, begin staged cleanup of ephemeral generation state. |
| `purge(max_rows)` | contract | Delete bounded cleanup batches; advance generation and reopen registration when complete. |
| `repcandidate(proposer, c1, c2, c3)` | `proposer` | The active proposer nominates a 3-candidate slate. |
| `vote(voter, v1, v2, v3)` | `voter` | Independent yes/no on each slate candidate. |
| `settle(caller)` | `caller` | Push a timed-out attempt forward; mix the authenticated caller into entropy. |
| `forceback()` | contract | Recovery path: move an elapsed active attempt directly to BACKSTOP. |
| `forceassign(member)` | contract | Governance backstop when tier-3 is exhausted. |
| `stir(caller)` | `caller` | Advance entropy and lazily settle elapsed state. |

## Tables (KV)

| Table | Type | Scope | Contents |
|-------|------|-------|----------|
| `config` | global | — | init progress, `time_slot_sec`, generation, tier sizes, loaded-row counts/next snapshot indices |
| `state` | global | — | live cursor, 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 also take `LOADING` → `reset` → `purge*` →
registration instead of leaving the contract stuck in a partial generation.

Candidate rows are billed to the self-registering candidate and registrations are capped at 1,000
per generation. Council results are retained permanently; candidates, snapshots, and tier-3 remap
state are removed in bounded cleanup batches before the next generation opens.

## Build / status

Compiled with the Wire CDT toolchain; `sysio.councl.wasm` / `sysio.councl.abi` are committed
alongside the source (like every other system contract), so `BUILD_SYSTEM_CONTRACTS=OFF` builds
consume the prebuilt artifacts. Rebuild the artifacts with `BUILD_SYSTEM_CONTRACTS=ON` (targeting
the Wire CDT), then copy `.wasm`/`.abi` back to this directory and regenerate client types per the
root `CLAUDE.md`. Whenever the contract changes, regenerate the affected reference data as noted in
`CLAUDE.md` (system-contract WASM changes shift action merkle roots).
97 changes: 97 additions & 0 deletions contracts/sysio.councl/include/sysio.councl/council_math.hpp
Original file line number Diff line number Diff line change
@@ -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 <array>
#include <cstddef>
#include <cstdint>

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<uint64_t, 3>& yes, const std::array<uint64_t, 3>& 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<size_t>(active)] >= T)
return {round_result::WIN, static_cast<uint8_t>(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<uint8_t, 32>& h) {
uint64_t s = 0;
for (int i = 0; i < 8; ++i)
s = (s << 8) | static_cast<uint64_t>(h[static_cast<size_t>(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
Loading
Loading