Skip to content
Open
Show file tree
Hide file tree
Changes from all 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()
391 changes: 391 additions & 0 deletions contracts/sysio.councl/DESIGN.md

Large diffs are not rendered by default.

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

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

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

## Responsibility

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

## Election rules

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

## Actions

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

## Tables (KV)

| Table | Type | Scope | Contents |
|-------|------|-------|----------|
| `config` | global | — | init progress, `time_slot_sec`, generation, tier sizes, loaded-row counts/next snapshot indices |
| `state` | global | — | live cursor (seat/tier/phase/proposer), current slate + tallies, entropy accumulator |
| `candidates` | scoped | generation | `account`, `handle`, `elected` |
| `roster` / `tier2` / `tier3` | scoped | generation | frozen ordered node-owner snapshots (by-owner secondary index) |
| `state.voted_bitmap` | global field | — | bounded duplicate-vote bitmap (at most one bit per tier member) |
| `tier3remap` | scoped | (generation, seat) | lazy Fisher-Yates remap for O(1) no-repeat tier-3 selection |
| `council` | scoped | generation | the 21 filled seats (owner, tier, proposer, member) |

## Lifecycle

`addcandidate*` → `startinit` → `loadtier*` → `finalizeinit` → per seat
`repcandidate` + `vote*` (with authenticated cranks) escalating T1→T2→T3→`forceassign` as needed →
all 21 seats filled → `DONE` → `reset` → `purge*` → registration for the next generation.

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

## Build / status

Compiled with the Wire CDT toolchain; `sysio.councl.wasm` / `sysio.councl.abi` are committed
alongside the source (like every other system contract), so `BUILD_SYSTEM_CONTRACTS=OFF` builds
consume the prebuilt artifacts. Rebuild the artifacts with `BUILD_SYSTEM_CONTRACTS=ON` (targeting
the Wire CDT), then copy `.wasm`/`.abi` back to this directory and regenerate client types per the
root `CLAUDE.md`. Whenever the contract changes, regenerate the affected reference data as noted in
`CLAUDE.md` (system-contract WASM changes shift action merkle roots).
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