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
113 changes: 67 additions & 46 deletions contracts/geev-core/src/governance.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::types::{DataKey, Error, GiveawayStatus, HelpRequestStatus};
use crate::types::{ContentType, DataKey, Error, GiveawayStatus, HelpRequestStatus};
use soroban_sdk::{contract, contractevent, contractimpl, Address, Env};

/// Number of flags required to automatically suspend content.
Expand All @@ -9,6 +9,8 @@ pub struct GovernanceContract;

#[contractevent]
pub struct ContentFlagged {
#[topic]
content_type: ContentType,
#[topic]
target_id: u64,
user: Address,
Expand All @@ -17,6 +19,8 @@ pub struct ContentFlagged {

#[contractevent]
pub struct ContentAutoSuspended {
#[topic]
content_type: ContentType,
#[topic]
target_id: u64,
count: u32,
Expand All @@ -31,14 +35,19 @@ pub struct ContentAppealed {

#[contractimpl]
impl GovernanceContract {
/// Flag a piece of content (Giveaway or HelpRequest) by its ID.
/// Each user may only flag a given ID once.
pub fn flag_content(env: Env, user: Address, target_id: u64) -> Result<(), Error> {
/// Flag a specific Giveaway or HelpRequest by its type and ID.
/// Each user may only flag a given content item once.
pub fn flag_content(
env: Env,
user: Address,
content_type: ContentType,
target_id: u64,
) -> Result<(), Error> {
// 1. Verify caller signature
user.require_auth();

// 2. Prevent duplicate flags from the same user
let flag_key = DataKey::FlagRecord(target_id, user.clone());
let flag_key = DataKey::FlagRecord(content_type, target_id, user.clone());
if env.storage().persistent().has(&flag_key) {
return Err(Error::AlreadyFlagged);
}
Expand All @@ -47,13 +56,14 @@ impl GovernanceContract {
env.storage().persistent().set(&flag_key, &true);

// 4. Increment the total flag count for this ID
let count_key = DataKey::FlagCount(target_id);
let count_key = DataKey::FlagCount(content_type, target_id);
let current: u32 = env.storage().persistent().get(&count_key).unwrap_or(0);
let new_count = current.checked_add(1).ok_or(Error::ArithmeticOverflow)?;
env.storage().persistent().set(&count_key, &new_count);

// 5. Emit "ContentFlagged" event: topics = (name, target_id), data = (user, total_flags)
ContentFlagged {
content_type,
target_id,
user,
count: new_count,
Expand All @@ -62,7 +72,7 @@ impl GovernanceContract {

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

Ok(())
Expand Down Expand Up @@ -119,61 +129,72 @@ impl GovernanceContract {
Err(Error::GiveawayNotFound)
}

/// Returns the total number of flags for a given content ID.
pub fn get_flag_count(env: Env, target_id: u64) -> u32 {
/// Returns the total number of flags for a specific content item.
pub fn get_flag_count(env: Env, content_type: ContentType, target_id: u64) -> u32 {
env.storage()
.persistent()
.get(&DataKey::FlagCount(target_id))
.get(&DataKey::FlagCount(content_type, target_id))
.unwrap_or(0)
}

/// Returns whether a specific user has already flagged a given content ID.
pub fn has_flagged(env: Env, user: Address, target_id: u64) -> bool {
/// Returns whether a user has already flagged a specific content item.
pub fn has_flagged(env: Env, user: Address, content_type: ContentType, target_id: u64) -> bool {
env.storage()
.persistent()
.has(&DataKey::FlagRecord(target_id, user))
.has(&DataKey::FlagRecord(content_type, 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 to suspend the content item identified by both type and ID.
/// Silently skips if the intended item does not exist or is not active.
fn auto_suspend(env: &Env, content_type: ContentType, target_id: u64, count: u32) {
let suspended = match content_type {
ContentType::Giveaway => {
let key = DataKey::Giveaway(target_id);
if let Some(mut giveaway) = env
.storage()
.persistent()
.get::<DataKey, crate::types::Giveaway>(&key)
{
if giveaway.status == GiveawayStatus::Active {
giveaway.status = GiveawayStatus::Suspended;
env.storage().persistent().set(&key, &giveaway);
true
} else {
false
}
} else {
false
}
}
}

// 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;
ContentType::HelpRequest => {
let key = DataKey::HelpRequest(target_id);
if let Some(mut request) = env
.storage()
.persistent()
.get::<DataKey, crate::types::HelpRequest>(&key)
{
if request.status == HelpRequestStatus::Open {
request.status = HelpRequestStatus::Suspended;
env.storage().persistent().set(&key, &request);
true
} else {
false
}
} else {
false
}
}
}
};

if suspended {
ContentAutoSuspended { target_id, count }.publish(env);
ContentAutoSuspended {
content_type,
target_id,
count,
}
.publish(env);
}
}
}
106 changes: 87 additions & 19 deletions contracts/geev-core/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1518,11 +1518,11 @@ fn test_flag_content_increments_count() {
let user = Address::generate(&env);
let target_id: u64 = 42;

assert_eq!(client.get_flag_count(&target_id), 0);
assert_eq!(client.get_flag_count(&ContentType::Giveaway, &target_id), 0);

client.flag_content(&user, &target_id);
client.flag_content(&user, &ContentType::Giveaway, &target_id);

assert_eq!(client.get_flag_count(&target_id), 1);
assert_eq!(client.get_flag_count(&ContentType::Giveaway, &target_id), 1);
}

#[test]
Expand All @@ -1537,12 +1537,12 @@ fn test_flag_content_multiple_users() {
let user_b = Address::generate(&env);
let target_id: u64 = 7;

client.flag_content(&user_a, &target_id);
client.flag_content(&user_b, &target_id);
client.flag_content(&user_a, &ContentType::Giveaway, &target_id);
client.flag_content(&user_b, &ContentType::Giveaway, &target_id);

assert_eq!(client.get_flag_count(&target_id), 2);
assert!(client.has_flagged(&user_a, &target_id));
assert!(client.has_flagged(&user_b, &target_id));
assert_eq!(client.get_flag_count(&ContentType::Giveaway, &target_id), 2);
assert!(client.has_flagged(&user_a, &ContentType::Giveaway, &target_id));
assert!(client.has_flagged(&user_b, &ContentType::Giveaway, &target_id));
}

#[test]
Expand All @@ -1557,9 +1557,9 @@ fn test_flag_content_duplicate_panics() {
let user = Address::generate(&env);
let target_id: u64 = 1;

client.flag_content(&user, &target_id);
client.flag_content(&user, &ContentType::Giveaway, &target_id);
// Second flag from the same user must panic with AlreadyFlagged
client.flag_content(&user, &target_id);
client.flag_content(&user, &ContentType::Giveaway, &target_id);
}

#[test]
Expand All @@ -1571,7 +1571,7 @@ fn test_has_flagged_returns_false_before_flag() {
let client = GovernanceContractClient::new(&env, &contract_id);

let user = Address::generate(&env);
assert!(!client.has_flagged(&user, &99u64));
assert!(!client.has_flagged(&user, &ContentType::Giveaway, &99u64));
}

#[test]
Expand All @@ -1584,17 +1584,41 @@ fn test_flag_counts_are_independent_per_id() {

let user = Address::generate(&env);

client.flag_content(&user, &1u64);
client.flag_content(&user, &ContentType::Giveaway, &1u64);

// ID 2 should still be at 0
assert_eq!(client.get_flag_count(&2u64), 0);
assert_eq!(client.get_flag_count(&1u64), 1);
assert_eq!(client.get_flag_count(&ContentType::Giveaway, &2u64), 0);
assert_eq!(client.get_flag_count(&ContentType::Giveaway, &1u64), 1);
}

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

let contract_id = env.register(GovernanceContract, ());
let client = GovernanceContractClient::new(&env, &contract_id);
let user = Address::generate(&env);
let shared_id = 1u64;

client.flag_content(&user, &ContentType::HelpRequest, &shared_id);

assert_eq!(
client.get_flag_count(&ContentType::HelpRequest, &shared_id),
1
);
assert_eq!(client.get_flag_count(&ContentType::Giveaway, &shared_id), 0);
assert!(client.has_flagged(&user, &ContentType::HelpRequest, &shared_id));
assert!(!client.has_flagged(&user, &ContentType::Giveaway, &shared_id));

client.flag_content(&user, &ContentType::Giveaway, &shared_id);
assert_eq!(client.get_flag_count(&ContentType::Giveaway, &shared_id), 1);
}

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

use crate::governance::FLAG_THRESHOLD;
use crate::types::{GiveawayStatus, SelectionMethod};
use crate::types::{ContentType, GiveawayStatus, SelectionMethod};

/// Seed a minimal active Giveaway directly into contract storage.
fn seed_active_giveaway(env: &Env, contract_id: &Address, giveaway_id: u64, token: &Address) {
Expand Down Expand Up @@ -1662,7 +1686,7 @@ fn test_giveaway_suspended_at_threshold() {
// 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);
gov.flag_content(&flagger, &ContentType::Giveaway, &giveaway_id);
}
env.as_contract(&contract_id, || {
let g: Giveaway = env
Expand All @@ -1675,7 +1699,7 @@ fn test_giveaway_suspended_at_threshold() {

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

env.as_contract(&contract_id, || {
let g: Giveaway = env
Expand Down Expand Up @@ -1705,7 +1729,7 @@ fn test_help_request_suspended_at_threshold() {

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

env.as_contract(&contract_id, || {
Expand All @@ -1718,6 +1742,49 @@ fn test_help_request_suspended_at_threshold() {
});
}

#[test]
fn test_help_request_auto_suspension_does_not_affect_same_id_giveaway() {
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 shared_id = 1u64;

seed_active_giveaway(&env, &contract_id, shared_id, &token);
seed_open_request(&env, &contract_id, shared_id, &token);

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

env.as_contract(&contract_id, || {
let giveaway: Giveaway = env
.storage()
.persistent()
.get(&DataKey::Giveaway(shared_id))
.unwrap();
let request: HelpRequest = env
.storage()
.persistent()
.get(&DataKey::HelpRequest(shared_id))
.unwrap();

assert_eq!(giveaway.status, GiveawayStatus::Active);
assert_eq!(request.status, HelpRequestStatus::Suspended);
});
assert_eq!(gov.get_flag_count(&ContentType::Giveaway, &shared_id), 0);
assert_eq!(
gov.get_flag_count(&ContentType::HelpRequest, &shared_id),
FLAG_THRESHOLD
);
}

#[test]
fn test_content_auto_suspended_event_emitted() {
let env = Env::default();
Expand All @@ -1736,14 +1803,15 @@ fn test_content_auto_suspended_event_emitted() {

for _ in 0..FLAG_THRESHOLD {
let flagger = Address::generate(&env);
gov.flag_content(&flagger, &giveaway_id);
gov.flag_content(&flagger, &ContentType::Giveaway, &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),
ContentType::Giveaway.into_val(&env),
giveaway_id.into_val(&env),
];
assert!(events
Expand Down
Loading
Loading