Skip to content

Liquid Democracy

Vote delegation on the OhShii Launcher. Delegation is live on both the OHSHII ecosystem DAO (ONS) and every per-campaign DAO (SONS). This document covers the user model, the canister architecture, the public API, the security and rate-limit model, and the operational contract that ties the launcher and the locker together. It details the ONS implementation; the SONS implementation shares the same delegation engine, with the differences summarized under Scope.


Liquid Democracy lets a verified ONS voter (the follower) delegate their vote to another verified principal (the delegate / followee). When the delegate casts a vote on an ONS proposal, every follower’s vote is automatically applied with the follower’s own quadratic voting power, computed fresh from lock_storage at the moment the delegate voted.

The model is shallow by design: a follower’s delegation chain is exactly one hop. A principal cannot be both a delegate and a follower at the same time on ONS — the accepts_followers toggle enforces this bipartite-graph invariant. There are no transitive cascades, no cycle detection, no recursion budgets to tune.

Delegation is opt-in on both sides:

  • The delegate must explicitly enable accepts_followers (off by default).
  • The follower must explicitly call follow(delegate).

Either party can dissolve the relationship at any time:

  • The follower calls unfollow().
  • The delegate calls dismiss_follower(follower).
  • The delegate disables accepts_followers, which removes every follower atomically.

Vote delegation is live on both governance layers:

  • ONS — the OHSHII ecosystem-wide DAO that governs the launcher, the locker, and shared protocol parameters. This is the implementation the rest of this document details.
  • SONS — per-campaign mini-DAOs running on their own sons_governance canister instances. SONS runs the same delegation engine (the follow / unfollow / set_accepts_followers / dismiss_follower surface, the 500-follower cap, the 100-per-tick cascade on the 5-minute timer, and the one-hop / bipartite / snapshot / direct-vote-wins invariants), with the differences listed below.
AspectONS (ohshii_governance)SONS (sons_governance)
Cascade triggerOnly proposals whose required_token_canister_id == OHSHII_LEDGER_CANISTER_ID cascade — and since create_proposal coerces that field to OHSHII, that is every ONS proposal.Every round-1 direct vote cascades — each instance governs a single campaign token, so there is no per-proposal token gate.
Cascade dispatchcascade_delegated_votes(...).await inline in vote().ic_cdk::futures::spawn(...) so a cascade trap cannot roll back the committed direct vote.
Voting-power sourceFetched from lock_storage via a batched inter-canister call (async cascade).Computed canister-locally from the instance’s own locks (fully synchronous cascade, zero ICC).
Verification gateFires when world_id_cfg.enabled || decideid_cfg.enabled (both default off); checked against ONS’s own verified-user sets (sync).Per-DAO requires_verification flag, default on; checked by a bounded-wait is_verified ICC to ONS behind the V-4 circuit breaker, with a permanent “Forever Verified” local cache.
De-escalation (unfollow, dismiss_follower)Verified when a gate is on — they are not exempt.Skip verification by design (sync de-escalation paths). When the gate is off, the acquisition paths fall back to a local active-lock anti-Sybil check.
DAO-assigned delegationNone.BatchSetFollow — a critical governance proposal can assign a delegate to a list of followers without each follower’s signed consent (the proposal is the authorization), still enforcing accepts_followers (re-checked at execution), no self-follow, the bipartite invariant, the 500-follower cap, and a 300-per-batch limit.

ONS is single-token by construction: create_proposal coerces required_token_canister_id to the OHSHII ledger for every proposal (the field is kept on the wire for API/back-compat), so an ONS proposal is always an OHSHII proposal — a direct caller cannot create a non-OHSHII (campaign-token) proposal on ONS, and the launcher UI already sends OHSHII. The cascade therefore fans out to all ONS proposals; the == OHSHII_LEDGER_CANISTER_ID cascade gate and the vote() “campaign-token” branch are defensive and can no longer be reached on ONS.

Imported DAOs run the same sons_governance WASM, so they expose the identical delegation surface, including BatchSetFollow.

The remainder of this document describes the ONS implementation in detail — its Candid API, stable-memory layout, and the lock_storage cross-canister dependency. SONS shares the same user model and cascade architecture; only the points in the table above differ.

A verified principal who has explicitly opted in to receive delegations by enabling the accepts_followers toggle. A delegate cannot themselves follow anyone — the toggle and the follow operation are mutually exclusive on the same DAO.

A delegate may have up to 500 followers (MAX_FOLLOWERS_PER_FOLLOWEE). This cap exists so the destructive bulk-removal path (toggle OFF) can complete inline within IC’s per-message instruction limit.

A verified principal with non-zero OHSHII voting power who has called follow(delegate). A follower has at most one active follow relationship — to switch delegate they must unfollow() first.

When the delegate votes, the canister fetches each follower’s quadratic voting power from lock_storage once, in a single batched ICC, and freezes the result inside a PendingDelegationEntry. The timer applies that snapshot synchronously, so a follower’s later lock expiry between enqueue and apply does not cancel the delegation.

If a follower’s voting power is zero at the moment the delegate votes, the resulting Vote row carries 0 VP and contributes nothing to the tally — but the row is still written, so the follower’s vote count reflects participation.

Verification gate (Universal Participation Gate)

Section titled “Verification gate (Universal Participation Gate)”

The verification gate has expanded from delegation-only to a Universal Participation Gate. On ONS DAOs the gate fires when EITHER world_id_cfg.enabled OR decideid_cfg.enabled is true; when on, the following ONS write methods all require the caller to be verified by at least one method (is_any_method_verified):

  • vote() — direct vote
  • create_proposal() — proposal creation
  • follow() — delegation acquisition (V-1 verify pair: caller AND followee)
  • set_accepts_followers(true) — delegate opt-in (V-1 verify caller)

Verification is the implicit economic gate that makes the entire DAO participation surface Sybil-resistant — anonymous principals cannot participate, and fresh principals must pay the verification cost (a small ICP anchor fee or a Proof-of-Personhood credential). The de-escalation methods (unfollow, dismiss_follower) are not exempt: whenever a verification gate is enabled they require the caller to be verified, exactly like the acquisition methods. They differ only when verification is OFF — because they merely REMOVE state they carry no additional anti-Sybil substitute, whereas the acquisition paths fall back to a lock-existence check in that mode.

The verification storage is per-method on ONS (WORLD_ID_VERIFIED_PRINCIPALS

  • DECIDEID_VERIFIED_PRINCIPALS). Verifications PERSIST across method-disable: a user verified via World ID keeps voting access if World ID’s enabled flag is later flipped to false. Re-enabling restores access without a re-verification round-trip.

Why ONS does NOT use Forever Verified (SONS-only): on this canister is_any_method_verified is already a sync stable-map lookup against the per-method verified sets — there is no ICC to save. Forever Verified (the permanent local cache that bypasses the verify ICC on cache hit) is structurally a SONS-family feature because SONS canisters reach across the subnet to query ONS for verification on every gated call. ONS itself is the source of truth, so caching makes no sense here.

The asynchronous fan-out of delegated votes from a single direct vote to N follower votes. Implemented as an enqueue step inside vote() (captures voting power snapshots) plus a periodic timer step (process_pending_delegations) that drains up to 100 entries per tick and applies each as a synchronous Vote row.

  1. Visit the OnsVotingPage and switch to the Following tab.
  2. In the “Follow someone” panel, paste the delegate’s principal id.
  3. The UI runs two pre-flight checks:
    • am_i_accepting_followers() — if true, the button is disabled because you are already a delegate; you’d need to disable accept-followers first.
    • accepts_followers(target) — if false, the button is disabled with a clear notice that the target is not accepting followers.
  4. Click Follow. The wallet (Internet Identity, NFID, or Plug) prompts to sign. The canister fetches your voting power from lock_storage and registers the relationship if your VP is > 0.
  5. From this moment on, every ONS vote the delegate casts triggers an automatic vote from your wallet within ~5 minutes.
  1. Visit the Following tab on the OnsVotingPage.
  2. In the “Become a delegate” section at the top, click Enable accept-followers.
  3. A confirmation modal explains the implications: other verified ONS voters may now delegate their voting power to you, and any existing follow you had on someone else will be auto-removed.
  4. Confirm. From this moment on, anyone can call follow(your_principal).
  1. From the Following tab, click the Disable accept-followers button.
  2. A destructive-confirmation modal appears showing the current follower count and a preview of follower principals. The action is irreversible — the delegate’s followers will need to follow them again from scratch if they ever re-enable the toggle.
  3. Confirm. The canister inline-removes every follower (up to 500), resets the follower count, and emits a single AcceptsFollowersDisabled summary event in the audit log.

A delegate can selectively kick one follower without disabling the toggle (useful when a previously-trusted peer is no longer appropriate):

  1. Open the Voting Power expandable section on the OnsVotingPage.
  2. Click Show N Followers to expand the list.
  3. Click the red next to the principal you want to remove.
  4. A small confirmation modal asks for confirmation; click to proceed.
  5. The relationship is removed immediately; the kicked follower can follow(you) again later if they wish (subject to your toggle still being on).

From the Following tab, click the Unfollow button next to the delegate row. There is no confirmation modal — the operation is fully reversible (you can follow() the same delegate again right afterwards), so the friction is intentionally low.

The feature lives in the ohshii_governance canister (launcher repo). Five new stable maps + one secondary index hold the persistent state, all kept in lockstep by the helper API:

Stable mapMemory IDPurpose
FOLLOWS71follower → followee (forward index)
FOLLOWERS_BY_FOLLOWEE72(followee, follower) reverse index for range scans
PENDING_DELEGATIONS73FIFO cascade queue keyed (enqueued_at_ns, proposal_id, follower)
ACCEPTS_FOLLOWERS_FLAGS76Opt-in toggle set; presence = ON
FOLLOWERS_COUNT77Atomic capacity counter followee → u32
PENDING_DELEGATIONS_INDEX78Secondary index (proposal_id, follower) for O(log N) dedup

In addition, four heap-only rate-limit maps and two single-tuple global counters live in the canister’s heap. These wipe on every upgrade by design, providing a “nuclear-reset” lever for ops if a heap map ever fills up under attack.

Four state-mutating updates and eight read-only regular queries. No method takes a dao_id argument — the feature is single-DAO by construction. No method is a composite query — the frontend orchestrates cross-canister fanout itself.

// === Updates ===
"follow": (principal) -> (variant { Ok; Err: text });
"unfollow": () -> (variant { Ok; Err: text });
"set_accepts_followers": (bool) -> (variant { Ok; Err: text });
"dismiss_follower": (principal) -> (variant { Ok; Err: text });
// === Queries ===
"accepts_followers": (principal) -> (bool) query;
"am_i_accepting_followers": () -> (bool) query;
"get_my_followee": () -> (opt principal) query;
"get_followee_of": (principal) -> (opt principal) query;
"get_my_followers": (nat32) -> (FollowersPage) query;
"get_followers_of": (principal, nat32) -> (FollowersPage) query;
"get_my_followers_count": () -> (nat32) query;
"get_pending_delegation_queue_size": () -> (nat64) query;
type FollowersPage = record {
followers: vec principal; // up to `max` rows
total_count: nat32; // canonical follower count
truncated: bool; // true if total_count > followers.len()
};

The max argument on the paged-followers queries is clamped server-side to the cap of 500. The frontend uses 20 by default and offers a “Load more” affordance up to 100 (the lock_storage batch ceiling).

The cascade — how delegated votes propagate

Section titled “The cascade — how delegated votes propagate”
  1. Enqueue stage (synchronous, runs inside vote() after the direct vote commits):

    • Verify the proposal’s required token is the OHSHII ledger — the cascade fans out only OHSHII-token proposals (see Scope).
    • List the followers of the voter via a stable range scan (capped at 500 by the canister’s per-followee cap).
    • De-duplicate against any followers who already direct-voted on this proposal or already have an entry queued (via the secondary index).
    • Fetch every eligible follower’s voting power from lock_storage in a single batched ICC, parallelised across chunks of 100 if necessary. Bounded wait with a 15-second timeout; on timeout the affected chunk is logged and dropped.
    • Push one PendingDelegationEntry per follower, carrying the frozen voting-power snapshot.
  2. Apply stage (synchronous, runs inside the existing run_periodic_tasks() timer):

    • Drain up to 100 entries from the queue per tick (~5 minutes by default).
    • For each entry, re-verify the proposal is still open, the follower is still verified, the follow is still active, and the follower has not direct-voted in the interim. If any check fails, log a DelegatedVoteFailed event and drop the entry.
    • Apply the snapshot: write the Vote row, update the proposal tally, append to the follower’s voter history, log a DelegatedVoteCast event.

The apply stage performs no inter-canister calls. This is the property that makes the cascade cycle-safe — a popular delegate’s vote that fans out to hundreds of followers cannot stall the timer or drain the canister via repeated long-running ICCs.

Direct vote: visible on the proposal tally immediately.

Delegated vote: visible within one timer tick (~5 minutes by default). For proposals that close quickly, both the proposal voting period and the cascade tick rate are tuned to ensure delegated votes have time to settle.

If a follower casts their own direct vote on a proposal between the moment the cascade enqueues their entry and the moment the timer applies it, the direct vote always wins. The timer detects this via a proposal-vote lookup and drops the cascade entry with reason direct_vote_already_cast.

Verification gate (Universal Participation Gate)

Section titled “Verification gate (Universal Participation Gate)”

Every state-mutating delegation method — follow, set_accepts_followers, and the de-escalation methods unfollow and dismiss_follower (plus vote and create_proposal) — requires the caller to be verified via World ID or DecideID (is_any_method_verified) whenever a verification gate is enabled. The check is enforced both at the inspect_message boundary (cycle saver, rejects unverified callers pre-Candid-decode) and inside each method body (the authoritative check). The de-escalation methods differ only in that, when verification is OFF, they apply no lock-existence anti-Sybil substitute — they only remove state, never add it.

For SONS DAOs the same Universal Participation Gate is governed by the per-DAO requires_verification flag (SONS-side semantics: a bounded-wait ICC to ONS behind the V-4 circuit breaker). Each SONS DAO can flip its own gate via a CriticalGovernanceOperation proposal — verification is opt-in per DAO, not enforced ecosystem-wide.

The accepts_followers toggle enforces a strict role separation:

  • A principal with the toggle on cannot call follow(_).
  • A principal currently following someone cannot have their toggle on.

Enabling the toggle while currently following someone auto-removes the follow first. The auto-removal is logged as FollowRemoved with reason auto_on_toggle_accept.

Every batched VP fetch from the launcher to the locker uses Call::bounded_wait(...).change_timeout(15). If the locker becomes unresponsive, the timeout fires and the call returns an error rather than leaving a callback indefinitely in flight. This protects the launcher’s ability to be stopped and upgraded.

LayerDefence
L1 cycle floorEvery method aborts before doing expensive work if the canister cycle balance is below MIN_CYCLES_FOR_DELEGATION_CASCADE = 500B. Last line of defence; activates after attack starts.
L2 global capSingle-tuple counter (no per-key allocation). follow = 200 calls/min canister-wide; dismiss_follower = 60/min. Sybil-resistant by construction — a swarm of fresh identities cannot collectively exceed the cap.
L3 per-caller capHeap maps keyed by principal. follow = 5/hr; unfollow = 1/hr + 1-hr cooldown; set_accepts_followers = 3/hr + 5-min cooldown; dismiss_follower = 30/hr. Friction-only against a single misbehaving identity; bypassable by Sybil but caps growth via lazy cleanup + hard cap on the map itself.
L4 economic gateImplicit: every method requires verified status, which means the caller paid a verification cost. Bounds Sybil amplification to roughly the daily verification quota.

follow() is the only delegation update with an await (the VP fetch). It acquires a per-caller in-memory lock via FollowOpGuard, which releases on Drop — including in cleanup context after a trap. This prevents a single caller from having two follow() calls in flight at once, which could otherwise race on the capacity reservation.

The MAX_FOLLOWERS_PER_FOLLOWEE = 500 cap is enforced atomically inside register_follow (in the canister’s memory layer): a single with(borrow_mut) block reads the counter, checks the cap, increments, and inserts both index entries. Concurrent follow() calls on the same delegate race only at this point, and the race is resolved deterministically by IC’s serial message scheduler — no caller can slip past the cap, no counter drift can develop.

The Following tab on the OnsVotingPage is implemented in src/frontend/src/components/DelegationPanel.jsx. It enforces three hard rules:

  1. No update calls on render. All four delegation mutators are invoked exclusively from user-action handlers (button click on the toggle, the Follow button, the Unfollow button, the dismiss “X”). No useEffect data-load may invoke them.
  2. Two-query fanout for VP display. The Voting Power panel computes Total / Own / Delegated voting power by calling ohshii_governance.get_my_followers(...) (regular query) and then lock_storage.calculate_quadratic_voting_power_batch(...) (regular query) directly from the browser. Two round-trips, both fast, neither composite.
  3. Destructive confirmation on toggle OFF. The “Disable accept- followers” path always opens a modal that lists the current follower count and a preview of follower principals before the call goes through. This protects delegates from accidentally nuking their entire follower base.

The cascade depends on a method exposed by lock_storage (in the OhShii Locker repo):

calculate_quadratic_voting_power_batch :
(vec principal, principal) -> (vec QuadraticVotingPowerResult) query;
  • Up to 100 principals per call (the launcher chunks larger inputs).
  • Returns one VP result per input principal, same index order.
  • Plain #[query] — not composite.

Lockstep deployment. If the launcher upgrade lands first while the locker still does not expose this method, every follower-cascade will trap on the missing endpoint after the direct vote has committed. The deployment runbook is:

  1. Deploy the locker upgrade with calculate_quadratic_voting_power_batch.
  2. Verify the method appears in lock_storage’s public Candid (e.g. via Candid UI or dfx canister call lock_storage __get_candid_interface_tmp_hack).
  3. Deploy the launcher upgrade with the cascade enabled.

Every delegation-related state change is recorded in the existing ohshii_governance event log, queryable through get_recent_events, get_events, and the System Status → Stable Logs tab in the UI.

  • FollowRegistered { follower, followee } — a new follow.
  • FollowRemoved { follower, followee, reason } — a follow ended; reason is one of unfollow, auto_on_toggle_accept, dismissed_by_followee.
  • AcceptsFollowersEnabled { who, auto_unfollowed_target } — toggle turned on; auto_unfollowed_target is Some(_) if the user was following someone at the moment they enabled the toggle.
  • AcceptsFollowersDisabled { who, followers_removed } — toggle turned off; the count of followers cleared inline.
  • DelegatedVoteCast { proposal_id, follower, followee, voting_power, decision } — the timer applied a delegated vote.
  • FollowFailed { caller, target, reason } — any failed follow / unfollow / dismiss attempt, with a closed-set reason code.
  • AcceptsFollowersToggleFailed { who, requested, reason } — toggle rejected (rate limit, unverified, cycle floor).
  • DelegatedVoteFailed { proposal_id, follower, followee, reason } — a queue entry was dropped before applying; reason explains why.
  • SystemLifecycle { event_type: "delegation_cascade_skipped_low_cycles" } — the canister was below the cycle floor when a cascade tried to fire.
  • SystemLifecycle { event_type: "followers_count_drift_detected" } — the periodic drift detector found a mismatch between the canonical follower counter and the index-based count. Should never fire under normal operation; investigate immediately if it does.

User-facing errors returned as Err(text) from the four updates. Each value corresponds to a stable closed-set reason code in the audit log.

ReasonWhen it appears
not_verifiedCaller has neither World ID nor DecideID verification.
self_followCaller principal equals the followee principal.
already_followingCaller already has a registered follow; must unfollow() first.
caller_accepts_followers_on_this_daoCaller has the delegate toggle on; cannot follow others.
target_does_not_accept_followers_on_this_daoFollowee has not enabled the toggle.
target_not_verifiedFollowee is not a verified principal.
followee_at_capacityFollowee already has the maximum 500 followers.
caller_has_no_voting_powerCaller has no eligible OHSHII locks (need ≥ 45-day duration to count).
vp_lookup_failedThe bounded-wait call to lock_storage timed out or returned an undecodable response. Retry.
not_followingunfollow() called by a principal with no active follow.
caller_not_a_delegatedismiss_follower() called by a principal with the toggle off.
not_follower_of_callerdismiss_follower() named a principal that does not currently follow the caller.
rate_limit_follow / rate_limit_unfollow / rate_limit_toggle / rate_limit_dismissPer-caller window or cooldown exceeded; OR the canister-wide global cap was reached (try again in a minute).
cycle_floorCanister is conserving cycles; try again in a few minutes.
reentrant_follow_in_flightSame caller has another follow() request still in flight.

Does delegating change my own ability to vote directly?

Section titled “Does delegating change my own ability to vote directly?”

No. You can always cast a direct vote on any open ONS proposal. If you do, the cascade detects it and drops the corresponding queue entry without applying the delegated vote — your direct vote always wins.

What happens if my voting power changes after I delegate?

Section titled “What happens if my voting power changes after I delegate?”

Delegation snapshots your VP at the moment the delegate votes, not at the moment you set up the delegation. Locks that mature, expire, or are added between two of the delegate’s votes will be reflected correctly the next time the cascade runs.

If your VP drops to zero between snapshots, your delegated Vote row is still written (so your participation count is recorded) but contributes 0 to the tally — exactly as if you had cast a direct zero-VP vote.

No. Each principal can have at most one active delegate at any time. To switch delegates you must unfollow() first, then follow(new_one).

Can a delegate be a follower of someone else?

Section titled “Can a delegate be a follower of someone else?”

No. The bipartite-graph invariant prevents this on ONS. If you want to switch from being a delegate to being a follower (or vice versa), toggle accepts_followers to off first (which removes your existing followers) before calling follow(...).

No. Either party can dissolve the relationship at any time:

  • The follower calls unfollow().
  • The delegate calls dismiss_follower(follower) or disables their accepts_followers toggle.

What happens if my delegate gets compromised?

Section titled “What happens if my delegate gets compromised?”

You can call unfollow() immediately to stop receiving any further delegated votes. Any votes the delegate has already cast cannot be retracted, but future ones will not propagate to your wallet.

How long does it take for a delegated vote to show up?

Section titled “How long does it take for a delegated vote to show up?”

Up to one timer tick (~5 minutes by default) after the delegate casts their vote. The OnsVotingPage shows your delegated votes in your vote history alongside direct votes once the timer has applied them.

No. A delegated vote uses the follower’s own quadratic voting power, applied with the same formula as a direct vote. The delegate’s voting power is unaffected — they vote with their own VP, and each follower votes with theirs.

Does my LGE tier (Fish / Shark / Whale) include delegated VP?

Section titled “Does my LGE tier (Fish / Shark / Whale) include delegated VP?”

No. The Voter Benefits Protocol tier ladder gates LGE purchase limits on each principal’s personal stake in OHSHII locks only. Liquid Democracy delegates votes, not tier privileges — so a Whale who accepts a hundred followers does NOT share their Whale-tier purchase limit with those followers, and a Guest who follows a Whale does NOT inherit any of the Whale’s tier benefits.

The get_lge_eligibility(principal) endpoint that powers the tier badge in the UI and the purchase-limit check in the launcher backend queries lock_storage.calculate_quadratic_voting_power(principal, OHSHII_LEDGER) for that single principal — never the cascade-aggregated total displayed in the Voting Power panel. The “Total VP / Own VP / Delegated to You” headline is purely informational and never feeds into a tier lookup.

Yes. Open the Voting Power expandable section on the OnsVotingPage — when your accepts_followers toggle is on, the “Show N Followers” button reveals the list, including each follower’s current voting power. From this list you can also dismiss any single follower.

Does the canister charge me cycles for delegating?

Section titled “Does the canister charge me cycles for delegating?”

No. Cycle costs are paid by the ohshii_governance canister, funded through the protocol treasury. The four-layer rate-limit model is the defence against cycle-drain attacks; it caps the total work the canister can be asked to do per minute.

How does this interact with the World ID / DecideID daily caps?

Section titled “How does this interact with the World ID / DecideID daily caps?”

Delegation respects the same verification gate as direct voting. If your verification has been cleared (for instance, after a governance-authorized verification reset for ops reasons), follow() will reject you with not_verified until you re-verify. Existing follows are not auto-revoked when verification is cleared; the cascade’s apply step will simply drop your queue entries with follower_unverified until you re-verify.


  • GOVERNANCE.md — overall governance model (ONS vs SONS, proposal categories, voting parameters).
  • WORKFLOWS.md — step-by-step LGE and proposal flows.
  • ARCHITECTURE.md — canister layout and stable-storage conventions.
  • WORLD_ID_VERIFICATION.md — the verification gate that delegation requires.
  • FRONTEND.md — frontend architecture: React + JSX + JSDoc @ts-check type-checking, the canister-types.js / utils/canister.js bridges, Candid wire conventions, and the TypeScript version policy (workspace TS is the source of truth).