Skip to content

feat(platform-governance): quadratic voting based on locked TREE tokens (#761) - #956

Merged
Idrhas merged 5 commits into
Farm-credit:mainfrom
mandyslovestories-sudo:feat/761-quadratic-voting
Aug 4, 2026
Merged

feat(platform-governance): quadratic voting based on locked TREE tokens (#761)#956
Idrhas merged 5 commits into
Farm-credit:mainfrom
mandyslovestories-sudo:feat/761-quadratic-voting

Conversation

@mandyslovestories-sudo

Copy link
Copy Markdown
Contributor

Closes #761

Implements quadratic voting power calculation in platform-governance based on locked TREE token holdings. Voting power = isqrt(locked_amount), so the marginal influence of additional tokens diminishes as holdings grow — large token holders cannot dominate governance proportionally to their token count.

Previously, the stub get_voting_power() returned a fixed 1000 for every voter. Now voting power is derived entirely from real on-chain locked token balances.

New types:

  • TokenLock — stores voter, token, amount, voting_power (= isqrt(amount)), locked_until
  • GovernanceError contracterror enum: NoLockedTokens(200), LockAmountMustBePositive(201), InsufficientLockedBalance(202), LockNotYetExpired(203)

New public functions:

  • lock_tokens(voter, amount) — transfers TREE into contract, accumulates lock balance, recomputes isqrt power; respects optional minimum lock period
  • unlock_tokens(voter, amount) — returns tokens to voter, recomputes power on remainder
  • locked_balance(voter) — returns TokenLock record or None
  • set_min_lock_seconds(seconds) — admin-only; sets minimum lock period (0 = disabled)

initialize() updated:

  • New tree_token: Address parameter — TREE SAC contract address stored as TREE_TOK

vote() updated:

  • Reads pre-computed isqrt voting power from TokenLock record (O(1), no math at vote time)
  • Aggregates delegated power from all direct delegators (also isqrt-based)
  • Panics with "must lock TREE tokens to vote" if total power == 0
  • Quadratic applied uniformly to ALL proposal types (previously only SpeciesSelection)

Tests (12 new + all existing updated):

  • lock/unlock lifecycle, accumulation, power recomputation
  • All 3 error codes tested (200, 201, 202)
  • Different lock amounts produce sqrt-scaled power difference (4x tokens → 2x power)
  • Voting tally correctly reflects quadratic power per voter
  • Vote without locked tokens rejected
  • All prior tests updated: setup() now provisions tree_token, lock_for_voter() helper mints + locks before each vote test, expected totals updated (1000 → 100 for 10k token locks)

nupedev and others added 4 commits July 27, 2026 07:22
Introduces src/reentrancy.rs — a standalone RAII mutex that prevents
cross-contract reentrant calls — and applies it to every state-mutating
function in the contract.

src/reentrancy.rs:
- ReentrancyGuard struct with RAII acquire/drop semantics
- Stores a bool flag under 'REENTRANT' key in Instance storage
  (cheapest/fastest tier; scoped to the current transaction)
- acquire() panics with ReentrancyError::Reentrancy (code 200) if the
  lock is already held (reentrant call detected)
- Drop impl clears the flag, enabling sequential re-acquisition
- ReentrancyError::Reentrancy contracterror variant (u32 = 200)
- ReentrancyGuard::is_locked() helper for test inspection

src/lib.rs (clean rewrite of corrupted file):
- deposit()         — guarded: token transfer is a cross-contract call
- verify_planting() — guarded: token transfer + mint (two XCCs)
- verify_survival() — guarded: token transfer
- refund()          — guarded: token transfer
- All guarded functions follow CEI (Check-Effects-Interactions):
  state is committed to storage before any cross-contract call

Integration pattern applied to every state-mutating function:
  let _guard = ReentrancyGuard::acquire(&env);
  // ... auth checks, validation, state writes, then XCCs ...
  // _guard drops at end of scope → lock released

Tests (20):
Reentrancy guard unit tests:
  - guard acquires and releases lock (RAII)
  - second acquire panics with Error(Contract, Farm-credit#200)
  - lock cleared after explicit drop (simulate panic recovery)
  - sequential acquires succeed (no false positives)

deposit:
  - creates escrow record with correct fields
  - transfers tokens to contract
  - zero amount rejected (AmountMustBePositive Farm-credit#9)
  - zero tree count rejected (TreeCountMustBePositive Farm-credit#10)
  - duplicate deposit rejected (EscrowAlreadyExists Farm-credit#16)

verify_planting:
  - releases exactly 75% to farmer
  - mints TREE tokens to donor proportional to verified count
  - stores proof hash and planted_at timestamp
  - double planting rejected (Farm-credit#18)
  - verified count exceeds donation rejected (Farm-credit#12)
  - zero verified count rejected (Farm-credit#11)

verify_survival:
  - releases remaining 25% to farmer
  - stores proof hash and survival rate
  - too early rejected (Farm-credit#24)
  - below 70% rejected (Farm-credit#23)
  - rate > 100 rejected (Farm-credit#22)
  - without planting rejected (Farm-credit#19)

refund:
  - returns full amount to donor before planting
  - rejected after planting (Farm-credit#20)

full lifecycle: fund → plant → survive → 100% released

Fixes Farm-credit#749
Add a granular multi-issuer permission model to the NFT certificate
contract so multiple authorized addresses can mint certificates
independently, managed by a single admin.

Design:
- Admin initializes the contract; issuer set starts empty
- Admin calls add_issuer(addr) / remove_issuer(addr) to manage the set
- mint() now requires (issuer, to, token_id, metadata) — the issuer
  parameter must be in the authorized set and must sign the transaction
- Admin is NOT automatically an issuer; admin must add themselves via
  add_issuer if they need minting rights (explicit permission model)
- Each Token now stores the issuer address that minted it
- IssuerRecord stores address + added_at timestamp
- NftCertError enum with three new codes:
    NotAuthorizedIssuer(300), IssuerAlreadyExists(301),
    IssuerNotFound(302)

New public functions:
- add_issuer(issuer)          — admin only; adds to issuer set
- remove_issuer(issuer)       — admin only; revokes minting rights
- get_issuers() -> Vec        — returns all IssuerRecord entries
- is_issuer(addr) -> bool     — read-only issuer check

Updated storage:
- Tokens moved to Persistent storage (DataKey::Token(u64)) for better
  TTL management; were previously in Instance storage
- ISSUERS key in Instance holds Vec<IssuerRecord>
- Token struct gains issuer: Address field

Preserved from original:
- CertificateMetadata, Token, merge(), pause/unpause, total_supply,
  owner_of, get_token, all original validation error codes

Tests (28):
initialize: defaults, double-init rejected
add_issuer: grants permission, multiple issuers, duplicate rejected,
  timestamp stored
remove_issuer: revokes permission, one-of-many, non-existent rejected,
  re-add after remove
mint with issuer auth: authorized mints, multiple issuers independently,
  unauthorized rejected, removed issuer rejected, admin-without-role
  rejected, admin-added-as-issuer succeeds, duplicate token rejected,
  zero tree count rejected, zero co2 rejected
merge: two certs, wrong owner rejected, metadata mismatch rejected
pause/unpause: blocks mint, unpaused restores mint
queries: is_issuer false for unknown, get_issuers count, owner_of,
  owner_of none for unknown
total_supply: increments on mint, net decrease on merge

Fixes Farm-credit#754
…ampaigns (Farm-credit#755)

Add a campaign funding system with automatic refund trigger when the
funding target is not met within the deadline (default 30 days).

New types:
- CampaignStatus: Active | Claimed | Expired
- Campaign: id, organiser, token, target_amount, deadline, total_raised,
  status, created_at
- CampaignDonation: campaign_id, donor, amount, refunded

New error codes:
- CampaignNotFound(93)     — campaign ID does not exist
- CampaignExpired(94)      — deadline passed, no new donations allowed
- CampaignNotExpired(95)   — deadline not yet passed, cannot refund
- TargetAlreadyMet(96)     — campaign claimed, cannot refund
- CampaignAlreadyClosed(97) — already claimed or expired
- TargetNotMet(98)         — cannot claim, target not reached

New constant:
- CAMPAIGN_DEADLINE_SECS = 30 * 24 * 60 * 60 (30 days)

New public functions:
- create_campaign(organiser, token, target_amount, deadline_secs)
  Admin-only. deadline_secs=0 uses the 30-day default. Returns campaign ID.
- donate_to_campaign(donor, campaign_id, token, amount)
  Donor-signed. Escrows funds. Rejects after deadline. Returns donation seq.
- claim_campaign(campaign_id, destination)
  Admin-only. Transfers all raised funds to destination.
  Rejects if total_raised < target_amount.
- refund_campaign_donor(campaign_id, donation_seq)
  Permissionless. Refunds a single donor's contribution.
  Requires deadline passed AND target not met.
  Lazily transitions campaign to Expired on first call.
- auto_refund_expired(campaign_id, donation_seqs)
  Permissionless batch refund. Idempotent — skips already-refunded seqs.
  Lazily transitions campaign to Expired on first call.
- get_campaign(campaign_id) → Option<Campaign>
- get_campaign_donation(campaign_id, donation_seq) → Option<CampaignDonation>

Internals:
- campaign_key / campaign_donation_key helpers added
- Campaign counter initialised in initialize()

Tests added (15 new, all existing tests preserved):
- create_campaign returns id and stores record
- create_campaign uses 30-day default when deadline_secs=0
- donate_to_campaign escrows funds and updates total_raised
- claim_campaign succeeds when target met
- claim_campaign rejected when target not met (TargetNotMet)
- claim_campaign rejected when already claimed (CampaignAlreadyClosed)
- refund_campaign_donor after deadline — refunds correct amount
- refund_campaign_donor before deadline rejected (CampaignNotExpired)
- double refund rejected (AlreadyProcessed)
- auto_refund_expired refunds all listed donors
- auto_refund_expired is idempotent — second call skips already refunded
- auto_refund_expired rejected before deadline (CampaignNotExpired)
- auto_refund_expired rejected when campaign claimed (TargetAlreadyMet)
- donate_to_campaign rejected after deadline (CampaignExpired)
- multiple donors partial fill then deadline auto-refund (end-to-end)

Fixes Farm-credit#755
…ns (Farm-credit#761)

Implement quadratic voting power calculation in platform-governance
based on locked TREE token holdings. Voting power = isqrt(locked_amount)
so the marginal impact of additional tokens diminishes as holdings grow —
large token holders cannot dominate governance proportionally.

Changes:

GovernanceError enum (contracterror):
  NoLockedTokens(200), LockAmountMustBePositive(201),
  InsufficientLockedBalance(202), LockNotYetExpired(203)

TokenLock struct:
  voter, token, amount, voting_power (= isqrt(amount)), locked_until

Storage keys: TLOCK, TREE_TOK, MIN_LOCK

initialize(): new tree_token: Address parameter; stores TREE_TOK and
  MIN_LOCK (default 0 = no minimum lock period)

New public functions:
  lock_tokens(voter, amount)    — transfers TREE into contract, accumulates
    lock, recomputes isqrt power; respects optional min_lock period
  unlock_tokens(voter, amount)  — transfers back, recomputes power on remainder
  locked_balance(voter)         — returns TokenLock record or None
  set_min_lock_seconds(seconds) — admin-only; 0 disables minimum

vote() updated:
  - Reads staking_contract from instance storage
  - Calls get_voting_power (reads pre-computed isqrt from TokenLock)
  - Adds delegated power (also isqrt-based from each delegator's lock)
  - Panics 'must lock TREE tokens to vote' if total power == 0
  - Quadratic applied uniformly to ALL proposal types (not just SpeciesSelection)

get_voting_power() internal:
  Replaced fixed 1000 stub with real locked-balance lookup — O(1)
  read of TokenLock.voting_power (isqrt pre-computed at lock time)

Tests added (12 new, all existing tests updated for 6-tuple setup):
  lock_tokens_stores_correct_voting_power (10000 → power 100)
  lock_tokens_accumulates_on_successive_calls
  unlock_tokens_reduces_balance_and_recomputes_power
  unlock_more_than_locked_rejected (Error Farm-credit#202)
  unlock_with_no_lock_rejected (Error Farm-credit#200)
  lock_zero_amount_rejected (Error Farm-credit#201)
  different_lock_amounts_produce_different_voting_powers
  voting_power_used_in_vote (tally_1=10 vs tally_2=100)
  vote_without_locked_tokens_rejected
  locked_balance_returns_none_for_unlocked_voter
  quadratic_dampening_vs_linear (4x tokens → 2x power)

All existing tests updated:
  - setup() now returns 6-tuple including tree_token
  - lock_for_voter() helper mints + locks for a voter
  - All vote tests add lock_for_voter call before voting
  - test_vote_on_proposal: total_votes 1000 → 100 (isqrt(10000))
  - test_normal_voting_platform_fee: total_votes 1000 → 100
  - test_vote_aggregates_delegated_power: total 3000 → 300 (3 × 100)
  - test_retract_then_vote_directly: total_votes 1000 → 100
  - test_get_delegated_power_accumulates_multiple_delegators: 5000 → 50

Fixes Farm-credit#761
@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@mandyslovestories-sudo Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Idrhas

Idrhas commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

fix your conflicts and dont forget to offramp with fundable on: https://stellar.fundable.finance/offramp

@Idrhas
Idrhas merged commit e1ae047 into Farm-credit:main Aug 4, 2026
4 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Contract] Implement Quadratic Voting Mechanics in Governance

3 participants