Skip to content

Commit bbc5f12

Browse files
authored
Merge pull request Stellar-IndigoPay#255 from wagmiiii/feat/badge-weighted-voting
feat: implement badge-weighted governance voting
2 parents 8ca589d + 4a1d99a commit bbc5f12

4 files changed

Lines changed: 309 additions & 19 deletions

File tree

contracts/indigopay-contract/src/fuzz_tests.rs

Lines changed: 218 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -832,10 +832,225 @@ mod fuzz {
832832

833833
set_project_co2_rate_direct(&env, &cid, &project_id, u32::MAX);
834834

835-
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
836-
client.donate_usdc(&usdc_token, &donor, &project_id, &usdc_amount, &MSG_HASH);
837-
}));
835+
let result = client.try_donate_usdc(&usdc_token, &donor, &project_id, &usdc_amount, &MSG_HASH);
838836
prop_assert!(result.is_err(), "donate_usdc should panic on CO2 overflow");
839837
}
838+
} // END of first proptest!
839+
840+
proptest! {
841+
#![proptest_config(ProptestConfig::with_cases(100))]
842+
843+
#[test]
844+
fn prop_fuzz_badge_weighted_voting(
845+
amount in 10i128 * STROOP..=10_000i128 * STROOP,
846+
) {
847+
let (env, admin, client, project_id) = setup_with_admin();
848+
client.create_proposal(&admin, &project_id, &720u32);
849+
850+
let token_admin = Address::generate(&env);
851+
let token = env
852+
.register_stellar_asset_contract_v2(token_admin)
853+
.address();
854+
855+
let donor = Address::generate(&env);
856+
mint_tokens(&env, &token, &donor, amount);
857+
client.donate(&token, &donor, &project_id, &amount, &42u32);
858+
859+
let stats = client.get_donor_stats(&donor);
860+
let expected_weight = match stats.badge {
861+
BadgeTier::Seedling => 1u32,
862+
BadgeTier::Tree => 3u32,
863+
BadgeTier::Forest => 10u32,
864+
BadgeTier::EarthGuardian => 25u32,
865+
BadgeTier::None => 0u32,
866+
};
867+
868+
client.vote_verify_project(&donor, &project_id, &true);
869+
870+
let proposal = client.get_proposal(&project_id);
871+
prop_assert_eq!(proposal.votes_for, expected_weight);
872+
}
873+
} // END of proptest!
874+
875+
#[test]
876+
fn test_zero_amount_donation_rejected() {
877+
let (env, _cid, client, project_id, token) = setup();
878+
let donor = Address::generate(&env);
879+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
880+
client.donate(&token, &donor, &project_id, &0i128, &42u32);
881+
}));
882+
assert!(result.is_err(), "donate with amount=0 should panic");
883+
}
884+
885+
#[test]
886+
fn test_deactivated_project_cannot_be_paused() {
887+
let (_env, admin, client, project_id) = setup_with_admin();
888+
889+
client.deactivate_project(&admin, &project_id);
890+
891+
// Pausing a deactivated project must panic
892+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
893+
client.pause_project(&admin, &project_id);
894+
}));
895+
assert!(
896+
result.is_err(),
897+
"pause_project should panic when project is deactivated"
898+
);
899+
900+
let project = client.get_project(&project_id);
901+
assert!(!project.active);
902+
}
903+
904+
#[test]
905+
fn test_zero_co2_rate_rejected() {
906+
let (_env, admin, client, project_id) = setup_with_admin();
907+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
908+
client.update_project_co2_rate(&admin, &project_id, &0u32);
909+
}));
910+
assert!(
911+
result.is_err(),
912+
"update_project_co2_rate with 0 should panic"
913+
);
914+
}
915+
916+
#[test]
917+
fn test_excessive_co2_rate_rejected() {
918+
let (_env, admin, client, project_id) = setup_with_admin();
919+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
920+
client.update_project_co2_rate(&admin, &project_id, &(MAX_CO2_PER_XLM + 1));
921+
}));
922+
assert!(
923+
result.is_err(),
924+
"update_project_co2_rate > MAX should panic"
925+
);
926+
}
927+
928+
#[test]
929+
fn test_admin_transfer_happy_path() {
930+
let (env, admin, client, _project_id) = setup_with_admin();
931+
let new_admin = Address::generate(&env);
932+
933+
client.transfer_admin(&admin, &new_admin);
934+
let pending = client.get_pending_admin();
935+
assert_eq!(pending, Some(new_admin.clone()));
936+
937+
client.accept_admin();
938+
let stored_admin = client.get_admin();
939+
assert_eq!(stored_admin, new_admin);
940+
assert_eq!(client.get_pending_admin(), None);
941+
}
942+
943+
#[test]
944+
fn test_admin_transfer_cancel() {
945+
let (env, admin, client, _project_id) = setup_with_admin();
946+
let new_admin = Address::generate(&env);
947+
client.transfer_admin(&admin, &new_admin);
948+
assert!(client.get_pending_admin().is_some());
949+
950+
client.cancel_admin_transfer(&admin);
951+
assert!(client.get_pending_admin().is_none());
952+
assert_eq!(client.get_admin(), admin);
953+
}
954+
955+
#[test]
956+
fn test_duplicate_project_id_rejected() {
957+
let (env, admin, client, project_id) = setup_with_admin();
958+
let wallet2 = Address::generate(&env);
959+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
960+
client.register_project(
961+
&admin,
962+
&project_id,
963+
&SorobanString::from_str(&env, "Duplicate"),
964+
&wallet2,
965+
&50u32,
966+
);
967+
}));
968+
assert!(
969+
result.is_err(),
970+
"register_project with duplicate ID should panic"
971+
);
972+
}
973+
974+
#[test]
975+
fn test_veto_before_resolution() {
976+
let (_env, admin, client, project_id) = setup_with_admin();
977+
client.create_proposal(&admin, &project_id, &720u32);
978+
let proposal_before = client.get_proposal(&project_id);
979+
assert!(!proposal_before.resolved);
980+
981+
client.veto_proposal(&admin, &project_id);
982+
let proposal_after = client.get_proposal(&project_id);
983+
assert!(proposal_after.resolved);
984+
}
985+
986+
#[test]
987+
fn test_proposal_default_duration() {
988+
let (_env, admin, client, project_id) = setup_with_admin();
989+
client.create_proposal(&admin, &project_id, &0u32);
990+
let proposal = client.get_proposal(&project_id);
991+
assert!(!proposal.resolved);
992+
assert_eq!(proposal.votes_for, 0u32);
993+
assert_eq!(proposal.votes_against, 0u32);
994+
}
995+
996+
#[test]
997+
fn test_deactivate_all_projects() {
998+
let (env, admin, client, project_id) = setup_with_admin();
999+
1000+
let wallet_b = Address::generate(&env);
1001+
let project_b = SorobanString::from_str(&env, "proj-bulk-b");
1002+
client.register_project(
1003+
&admin,
1004+
&project_b,
1005+
&SorobanString::from_str(&env, "Bulk B"),
1006+
&wallet_b,
1007+
&75u32,
1008+
);
1009+
1010+
assert!(client.get_project(&project_id).active);
1011+
assert!(client.get_project(&project_b).active);
1012+
1013+
client.deactivate_all_projects(&admin);
1014+
1015+
assert!(!client.get_project(&project_id).active);
1016+
assert!(!client.get_project(&project_b).active);
1017+
}
1018+
1019+
#[test]
1020+
fn test_badge_weighted_voting_seedling_and_earth_guardian() {
1021+
let (env, admin, client, project_id) = setup_with_admin();
1022+
client.create_proposal(&admin, &project_id, &720u32);
1023+
1024+
let token_admin = Address::generate(&env);
1025+
let token = env
1026+
.register_stellar_asset_contract_v2(token_admin)
1027+
.address();
1028+
1029+
let donor_seedling = Address::generate(&env);
1030+
let amt_seedling = 10i128 * STROOP;
1031+
mint_tokens(&env, &token, &donor_seedling, amt_seedling);
1032+
client.donate(&token, &donor_seedling, &project_id, &amt_seedling, &42u32);
1033+
1034+
client.vote_verify_project(&donor_seedling, &project_id, &true);
1035+
1036+
let donor_earth = Address::generate(&env);
1037+
let amt_earth = 2000i128 * STROOP;
1038+
mint_tokens(&env, &token, &donor_earth, amt_earth);
1039+
client.donate(&token, &donor_earth, &project_id, &amt_earth, &42u32);
1040+
1041+
client.vote_verify_project(&donor_earth, &project_id, &true);
1042+
1043+
let proposal = client.get_proposal(&project_id);
1044+
assert_eq!(proposal.votes_for, 26u32);
1045+
}
1046+
1047+
#[test]
1048+
fn test_badge_weighted_voting_none_tier_panics() {
1049+
let (env, admin, client, project_id) = setup_with_admin();
1050+
client.create_proposal(&admin, &project_id, &720u32);
1051+
1052+
let voter = Address::generate(&env);
1053+
let result = client.try_vote_verify_project(&voter, &project_id, &true);
1054+
assert!(result.is_err(), "None tier voter should panic");
8401055
}
8411056
}

contracts/indigopay-contract/src/lib.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,16 @@ fn calculate_badge(total_stroops: i128) -> BadgeTier {
327327
}
328328
}
329329

330+
fn voting_weight_from_badge(badge: &BadgeTier) -> u32 {
331+
match badge {
332+
BadgeTier::None => 0,
333+
BadgeTier::Seedling => 1,
334+
BadgeTier::Tree => 3,
335+
BadgeTier::Forest => 10,
336+
BadgeTier::EarthGuardian => 25,
337+
}
338+
}
339+
330340
// ─── Contract ─────────────────────────────────────────────────────────────────
331341

332342
#[contract]
@@ -1269,6 +1279,20 @@ impl IndigoPayContract {
12691279
ensure_min_ttl(&env, VOTING_WINDOW_LEDGERS * 4);
12701280
}
12711281

1282+
pub fn get_voter_weight(env: Env, voter: Address) -> u32 {
1283+
let stats: DonorStats = env
1284+
.storage()
1285+
.instance()
1286+
.get(&DataKey::DonorStats(voter))
1287+
.unwrap_or(DonorStats {
1288+
total_donated: 0,
1289+
donation_count: 0,
1290+
badge: BadgeTier::None,
1291+
co2_offset_grams: 0,
1292+
});
1293+
voting_weight_from_badge(&stats.badge)
1294+
}
1295+
12721296
/// Badge holders (≥ Seedling) cast a vote. One vote per address per proposal.
12731297
pub fn vote_verify_project(env: Env, voter: Address, project_id: String, approve: bool) {
12741298
voter.require_auth();
@@ -1288,6 +1312,8 @@ impl IndigoPayContract {
12881312
panic!("Only badge holders (Seedling or above) can vote");
12891313
}
12901314

1315+
let weight = voting_weight_from_badge(&stats.badge);
1316+
12911317
let mut proposal: VoteProposal = env
12921318
.storage()
12931319
.instance()
@@ -1324,12 +1350,12 @@ impl IndigoPayContract {
13241350
if approve {
13251351
proposal.votes_for = proposal
13261352
.votes_for
1327-
.checked_add(1)
1353+
.checked_add(weight)
13281354
.expect("votes_for overflow");
13291355
} else {
13301356
proposal.votes_against = proposal
13311357
.votes_against
1332-
.checked_add(1)
1358+
.checked_add(weight)
13331359
.expect("votes_against overflow");
13341360
}
13351361
env.storage()

frontend/lib/stellar.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,31 @@ export async function getDonorStats(donorAddress: string) {
747747
}
748748
}
749749

750+
/**
751+
* Queries the contract for a voter's badge-weighted voting power.
752+
*
753+
* @param voterAddress - Voter Stellar public key.
754+
* @returns Voter weight (u32), or 0 when the contract is not configured or on errors.
755+
*/
756+
export async function getVoterWeight(voterAddress: string): Promise<number> {
757+
if (!CONTRACT_ID) {
758+
return 0;
759+
}
760+
761+
const contract = new Contract(CONTRACT_ID);
762+
763+
try {
764+
const voter = new Address(voterAddress);
765+
const weight = await simulateCall(contract, "get_voter_weight", [
766+
voter.toScVal(),
767+
]);
768+
return Number(weight);
769+
} catch (err) {
770+
console.error("Failed to fetch voter weight:", err);
771+
return 0;
772+
}
773+
}
774+
750775
/**
751776
* Simple djb2 hash function for donation messages.
752777
* Returns a 32-bit unsigned integer hash.

0 commit comments

Comments
 (0)