Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ next-env.d.ts
# rust
**/target/
wave-*.md

.vscode
59 changes: 58 additions & 1 deletion contracts/geev-core/src/governance.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::types::{DataKey, Error, GiveawayStatus, HelpRequestStatus};
use soroban_sdk::{contract, contractevent, contractimpl, Address, Env};

use crate::types::{DataKey, Error};
/// Number of flags required to automatically suspend content.
pub const FLAG_THRESHOLD: u32 = 10;

#[contract]
pub struct GovernanceContract;
Expand All @@ -13,6 +15,13 @@ pub struct ContentFlagged {
count: u32,
}

#[contractevent]
pub struct ContentAutoSuspended {
#[topic]
target_id: u64,
count: u32,
}

#[contractimpl]
impl GovernanceContract {
/// Flag a piece of content (Giveaway or HelpRequest) by its ID.
Expand Down Expand Up @@ -44,6 +53,11 @@ impl GovernanceContract {
}
.publish(&env);

// 5. Circuit breaker: suspend if threshold is reached.
if new_count >= FLAG_THRESHOLD {
Self::auto_suspend(&env, target_id, new_count);
}

Ok(())
}

Expand All @@ -61,4 +75,47 @@ impl GovernanceContract {
.persistent()
.has(&DataKey::FlagRecord(target_id, user))
}

// ── internal ──────────────────────────────────────────────────────────────

/// Try to suspend the Giveaway or HelpRequest with `target_id`.
/// Silently skips if neither exists (the ID may belong to a future content type).
fn auto_suspend(env: &Env, target_id: u64, count: u32) {
let giveaway_key = DataKey::Giveaway(target_id);
let request_key = DataKey::HelpRequest(target_id);

let mut suspended = false;

// Try Giveaway first.
if let Some(mut giveaway) = env
.storage()
.persistent()
.get::<DataKey, crate::types::Giveaway>(&giveaway_key)
{
if giveaway.status == GiveawayStatus::Active {
giveaway.status = GiveawayStatus::Suspended;
env.storage().persistent().set(&giveaway_key, &giveaway);
suspended = true;
}
}

// Try HelpRequest if giveaway wasn't found/suspended.
if !suspended {
if let Some(mut request) = env
.storage()
.persistent()
.get::<DataKey, crate::types::HelpRequest>(&request_key)
{
if request.status == HelpRequestStatus::Open {
request.status = HelpRequestStatus::Suspended;
env.storage().persistent().set(&request_key, &request);
suspended = true;
}
}
}

if suspended {
ContentAutoSuspended { target_id, count }.publish(env);
}
}
}
4 changes: 3 additions & 1 deletion contracts/geev-core/src/mutual_aid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ impl MutualAidContract {
panic_with_error!(&env, Error::HelpRequestAlreadyFullyFunded);
}

if request.status == HelpRequestStatus::Cancelled {
if request.status == HelpRequestStatus::Cancelled
|| request.status == HelpRequestStatus::Suspended
{
panic_with_error!(&env, Error::InvalidStatus);
}

Expand Down
236 changes: 235 additions & 1 deletion contracts/geev-core/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::types::{DataKey, HelpRequest, HelpRequestStatus};
use soroban_sdk::symbol_short;
use soroban_sdk::{
testutils::{Address as _, Events as _, Ledger},
token, vec, Address, Env, FromVal, IntoVal, String, Val,
token, vec, Address, Env, FromVal, IntoVal, String, Symbol, Val,
};

#[test]
Expand Down Expand Up @@ -1398,3 +1398,237 @@ fn test_flag_counts_are_independent_per_id() {
assert_eq!(client.get_flag_count(&2u64), 0);
assert_eq!(client.get_flag_count(&1u64), 1);
}

// ── auto-suspension tests ─────────────────────────────────────────────────────

use crate::governance::FLAG_THRESHOLD;
use crate::types::{Giveaway, GiveawayStatus};

/// Seed a minimal active Giveaway directly into contract storage.
fn seed_active_giveaway(env: &Env, contract_id: &Address, giveaway_id: u64, token: &Address) {
let creator = Address::generate(env);
let giveaway = Giveaway {
id: giveaway_id,
creator,
token: token.clone(),
amount: 500,
title: String::from_str(env, "Test"),
participant_count: 0,
end_time: env.ledger().timestamp() + 3600,
status: GiveawayStatus::Active,
winner: None,
};
env.as_contract(contract_id, || {
env.storage()
.persistent()
.set(&DataKey::Giveaway(giveaway_id), &giveaway);
});
}

/// Seed a minimal open HelpRequest directly into contract storage.
fn seed_open_request(env: &Env, contract_id: &Address, request_id: u64, token: &Address) {
let creator = Address::generate(env);
let request = HelpRequest {
id: request_id,
creator,
token: token.clone(),
goal: 1000,
raised_amount: 0,
status: HelpRequestStatus::Open,
is_verified: false,
};
env.as_contract(contract_id, || {
env.storage()
.persistent()
.set(&DataKey::HelpRequest(request_id), &request);
});
}

#[test]
fn test_giveaway_suspended_at_threshold() {
let env = Env::default();
env.mock_all_auths();

// Governance and Giveaway share the same contract so storage is shared.
let contract_id = env.register(GovernanceContract, ());
let gov = GovernanceContractClient::new(&env, &contract_id);

let token_admin = Address::generate(&env);
let token = env
.register_stellar_asset_contract_v2(token_admin)
.address();

let giveaway_id: u64 = 42;
seed_active_giveaway(&env, &contract_id, giveaway_id, &token);

// Flag FLAG_THRESHOLD - 1 times — should still be Active.
for _ in 0..FLAG_THRESHOLD - 1 {
let flagger = Address::generate(&env);
gov.flag_content(&flagger, &giveaway_id);
}
env.as_contract(&contract_id, || {
let g: Giveaway = env
.storage()
.persistent()
.get(&DataKey::Giveaway(giveaway_id))
.unwrap();
assert_eq!(g.status, GiveawayStatus::Active);
});

// The threshold flag suspends it.
let last_flagger = Address::generate(&env);
gov.flag_content(&last_flagger, &giveaway_id);

env.as_contract(&contract_id, || {
let g: Giveaway = env
.storage()
.persistent()
.get(&DataKey::Giveaway(giveaway_id))
.unwrap();
assert_eq!(g.status, GiveawayStatus::Suspended);
});
}

#[test]
fn test_help_request_suspended_at_threshold() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(GovernanceContract, ());
let gov = GovernanceContractClient::new(&env, &contract_id);

let token_admin = Address::generate(&env);
let token = env
.register_stellar_asset_contract_v2(token_admin)
.address();

let request_id: u64 = 7;
seed_open_request(&env, &contract_id, request_id, &token);

for _ in 0..FLAG_THRESHOLD {
let flagger = Address::generate(&env);
gov.flag_content(&flagger, &request_id);
}

env.as_contract(&contract_id, || {
let r: HelpRequest = env
.storage()
.persistent()
.get(&DataKey::HelpRequest(request_id))
.unwrap();
assert_eq!(r.status, HelpRequestStatus::Suspended);
});
}

#[test]
fn test_content_auto_suspended_event_emitted() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(GovernanceContract, ());
let gov = GovernanceContractClient::new(&env, &contract_id);

let token_admin = Address::generate(&env);
let token = env
.register_stellar_asset_contract_v2(token_admin)
.address();

let giveaway_id: u64 = 99;
seed_active_giveaway(&env, &contract_id, giveaway_id, &token);

for _ in 0..FLAG_THRESHOLD {
let flagger = Address::generate(&env);
gov.flag_content(&flagger, &giveaway_id);
}

// Verify ContentAutoSuspended event was emitted with the right topic.
let events = env.events().all();
let expected_topics: soroban_sdk::Vec<Val> = vec![
&env,
Symbol::new(&env, "content_auto_suspended").into_val(&env),
giveaway_id.into_val(&env),
];
assert!(events
.iter()
.any(|(ec, topics, _)| { ec == contract_id && topics == expected_topics.into_val(&env) }));
}

#[test]
#[should_panic]
fn test_enter_suspended_giveaway_fails() {
let env = Env::default();
env.mock_all_auths();

// Register both contracts; they share storage only when it's the same contract_id.
// Here we use GiveawayContract for entry and seed Suspended status directly.
let contract_id = env.register(GiveawayContract, ());
let giveaway_client = GiveawayContractClient::new(&env, &contract_id);

let token_admin = Address::generate(&env);
let token = env
.register_stellar_asset_contract_v2(token_admin)
.address();

let giveaway_id: u64 = 1;
// Seed a Suspended giveaway directly.
let creator = Address::generate(&env);
env.as_contract(&contract_id, || {
env.storage().persistent().set(
&DataKey::Giveaway(giveaway_id),
&Giveaway {
id: giveaway_id,
creator,
token,
amount: 500,
title: String::from_str(&env, "Suspended"),
participant_count: 0,
end_time: env.ledger().timestamp() + 3600,
status: GiveawayStatus::Suspended,
winner: None,
},
);
});

let participant = Address::generate(&env);
// Should panic with InvalidStatus because Suspended != Active.
giveaway_client.enter_giveaway(&participant, &giveaway_id);
}

#[test]
#[should_panic]
fn test_donate_to_suspended_request_fails() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(MutualAidContract, ());
let aid_client = MutualAidContractClient::new(&env, &contract_id);

let token_admin = Address::generate(&env);
let token = env
.register_stellar_asset_contract_v2(token_admin.clone())
.address();
let token_admin_client = token::StellarAssetClient::new(&env, &token);

let donor = Address::generate(&env);
token_admin_client.mint(&donor, &1000);

let request_id: u64 = 5;
let creator = Address::generate(&env);
env.as_contract(&contract_id, || {
env.storage().persistent().set(
&DataKey::HelpRequest(request_id),
&HelpRequest {
id: request_id,
creator,
token,
goal: 1000,
raised_amount: 0,
status: HelpRequestStatus::Suspended,
is_verified: false,
},
);
});

// Should panic with InvalidStatus.
aid_client.donate(&donor, &request_id, &100);
}
4 changes: 3 additions & 1 deletion contracts/geev-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ pub enum Error {
AlreadyFlagged = 19,
}

#[derive(Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq, Debug)]
#[contracttype]
pub enum GiveawayStatus {
Active = 0,
Claimable = 1,
Completed = 2,
Suspended = 3,
}

#[derive(Clone)]
Expand All @@ -54,6 +55,7 @@ pub enum HelpRequestStatus {
FullyFunded = 1,
Closed = 2,
Cancelled = 3,
Suspended = 4,
}

#[derive(Clone)]
Expand Down
Loading
Loading