feat(platform-governance): quadratic voting based on locked TREE tokens (#761) - #956
Merged
Idrhas merged 5 commits intoAug 4, 2026
Merged
Conversation
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
|
@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! 🚀 |
Contributor
|
fix your conflicts and dont forget to offramp with fundable on: https://stellar.fundable.finance/offramp |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
New public functions:
initialize() updated:
vote() updated:
Tests (12 new + all existing updated):