diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 0abde54..d85f0a5 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -20,7 +20,13 @@ //! - `supply`: Number of keys in circulation after the trade (for buy/sell events) //! - `payment`: Total amount paid by the buyer (for buy events, ≥ key price) -use soroban_sdk::{contracttype, symbol_short, Address, String, Symbol}; +use crate::{ + constants, read_registered_creator_profile, CreatorKeysContract, CreatorKeysContractArgs, + CreatorKeysContractClient, +}; +use soroban_sdk::{ + contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, Symbol, Vec, +}; /// Event name for protocol pause. pub const PAUSE_EVENT_NAME: Symbol = symbol_short!("pause"); @@ -40,12 +46,22 @@ pub const SELL_EVENT_NAME: Symbol = symbol_short!("sell"); /// Event name for peer-to-peer key transfer. pub const TRANSFER_EVENT_NAME: Symbol = symbol_short!("transfer"); -/// Event name for creator buyback. +/// Event name for creator key buyback. pub const BUYBACK_EVENT_NAME: Symbol = symbol_short!("buyback"); -/// Common topic indexes for event tuple topics. +/// Event name for governance poll creation. +pub const POLL_CREATED_EVENT_NAME: Symbol = symbol_short!("poll_new"); + +/// Event name for governance poll votes. +pub const POLL_VOTE_EVENT_NAME: Symbol = symbol_short!("poll_vote"); + +/// Topic index for the event name in common event topic tuples. pub const TOPIC_EVENT_NAME_INDEX: u32 = 0; + +/// Topic index for the creator address in common event topic tuples. pub const TOPIC_CREATOR_INDEX: u32 = 1; + +/// Topic index for the buyer/seller/actor address in common event topic tuples. pub const TOPIC_BUYER_INDEX: u32 = 2; /// Stable field order for registration event payloads. @@ -58,27 +74,20 @@ pub const REGISTER_EVENT_DATA_FIELDS: [&str; 6] = [ "protocol_bps", ]; -/// Number of fields in the registration event data payload. -pub const REGISTER_EVENT_FIELD_COUNT: usize = REGISTER_EVENT_DATA_FIELDS.len(); - -/// Stable field order for buy event tuple payloads. +/// Stable field order for buy event payloads. pub const BUY_EVENT_DATA_FIELDS: [&str; 2] = ["supply", "payment"]; -/// Number of fields in the buy event data payload. -pub const BUY_EVENT_FIELD_COUNT: usize = BUY_EVENT_DATA_FIELDS.len(); - -/// Stable field order for sell event tuple payloads. +/// Stable field order for sell event payloads. pub const SELL_EVENT_DATA_FIELDS: [&str; 1] = ["supply"]; -/// Number of fields in the sell event data payload. -pub const SELL_EVENT_FIELD_COUNT: usize = SELL_EVENT_DATA_FIELDS.len(); - /// Stable field order for buyback event payloads. pub const BUYBACK_EVENT_DATA_FIELDS: [&str; 5] = ["creator", "amount", "price_paid", "new_supply", "ledger"]; -/// Number of fields in the buyback event data payload. -pub const BUYBACK_EVENT_FIELD_COUNT: usize = BUYBACK_EVENT_DATA_FIELDS.len(); +const MIN_POLL_OPTIONS: u32 = 2; +const MAX_POLL_OPTIONS: u32 = 4; +const MAX_QUESTION_CHARS: u32 = 280; +const MAX_OPTION_CHARS: u32 = 100; /// Stable registration event payload for downstream indexers. /// @@ -258,3 +267,245 @@ pub struct KeysTransferredEvent { pub amount: u32, pub ledger: u32, } + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum PollError { + NotRegistered = 20, + Overflow = 21, + InvalidOptionCount = 22, + QuestionTooLong = 23, + OptionTooLong = 24, + PollNotFound = 25, + PollExpired = 26, + NotAHolder = 27, + InvalidOption = 28, +} + +#[derive(Clone)] +#[contracttype] +enum PollDataKey { + NextPollId(Address), + Poll(Address, u32), + Vote(Address, u32, Address), +} + +#[derive(Clone)] +#[contracttype] +pub struct Poll { + pub question: String, + pub options: Vec, + pub vote_counts: Vec, + pub total_weight: u32, + pub expires_at: u32, +} + +#[derive(Clone)] +#[contracttype] +pub struct PollVote { + pub option_index: u32, + pub weight: u32, +} + +#[derive(Clone)] +#[contracttype] +pub struct PollResult { + pub question: String, + pub options: Vec, + pub vote_counts: Vec, + pub total_weight: u32, + pub expired: bool, +} + +fn poll_storage_key(creator_id: &Address, poll_id: u32) -> PollDataKey { + PollDataKey::Poll(creator_id.clone(), poll_id) +} + +fn vote_storage_key(creator_id: &Address, poll_id: u32, voter: &Address) -> PollDataKey { + PollDataKey::Vote(creator_id.clone(), poll_id, voter.clone()) +} + +fn read_poll(env: &Env, creator_id: &Address, poll_id: u32) -> Result { + env.storage() + .persistent() + .get(&poll_storage_key(creator_id, poll_id)) + .ok_or(PollError::PollNotFound) +} + +fn is_poll_expired(env: &Env, poll: &Poll) -> bool { + env.ledger().sequence() >= poll.expires_at +} + +fn validate_poll_options(options: &Vec) -> Result<(), PollError> { + let option_count = options.len(); + if !(MIN_POLL_OPTIONS..=MAX_POLL_OPTIONS).contains(&option_count) { + return Err(PollError::InvalidOptionCount); + } + + let mut index = 0; + while index < option_count { + let option = options.get(index).ok_or(PollError::InvalidOption)?; + if option.len() > MAX_OPTION_CHARS { + return Err(PollError::OptionTooLong); + } + index += 1; + } + + Ok(()) +} + +#[contractimpl] +impl CreatorKeysContract { + /// Creates a creator-owned governance poll with two to four options. + /// + /// The creator address must authorize the call. Polls expire at the current ledger + /// sequence plus `duration_ledgers`, and the returned `poll_id` is scoped to the creator. + pub fn create_poll( + env: Env, + creator_id: Address, + question: String, + options: Vec, + duration_ledgers: u32, + ) -> Result { + creator_id.require_auth(); + read_registered_creator_profile(&env, &creator_id).map_err(|_| PollError::NotRegistered)?; + + if question.len() > MAX_QUESTION_CHARS { + return Err(PollError::QuestionTooLong); + } + validate_poll_options(&options)?; + + let mut vote_counts = Vec::new(&env); + let mut index = 0; + while index < options.len() { + vote_counts.push_back(0); + index += 1; + } + + let next_key = PollDataKey::NextPollId(creator_id.clone()); + let poll_id: u32 = env.storage().persistent().get(&next_key).unwrap_or(1); + let next_poll_id = poll_id.checked_add(1).ok_or(PollError::Overflow)?; + let expires_at = env + .ledger() + .sequence() + .checked_add(duration_ledgers) + .ok_or(PollError::Overflow)?; + + let poll = Poll { + question, + options, + vote_counts, + total_weight: 0, + expires_at, + }; + + env.storage() + .persistent() + .set(&poll_storage_key(&creator_id, poll_id), &poll); + env.storage().persistent().set(&next_key, &next_poll_id); + env.events().publish( + (POLL_CREATED_EVENT_NAME, creator_id.clone(), poll_id), + poll.expires_at, + ); + + Ok(poll_id) + } + + /// Casts or updates a weighted vote for a creator poll. + /// + /// The voter must authorize the call and must currently hold at least one liquid key for + /// the creator. Re-voting before expiry removes the previous weight and adds the voter's + /// current liquid key balance to the selected option. + pub fn cast_vote( + env: Env, + creator_id: Address, + voter: Address, + poll_id: u32, + option_index: u32, + ) -> Result<(), PollError> { + voter.require_auth(); + let mut poll = read_poll(&env, &creator_id, poll_id)?; + + if is_poll_expired(&env, &poll) { + return Err(PollError::PollExpired); + } + if option_index >= poll.options.len() { + return Err(PollError::InvalidOption); + } + + let balance_key = constants::storage::key_balance(&creator_id, &voter); + let weight: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); + if weight == 0 { + return Err(PollError::NotAHolder); + } + + let vote_key = vote_storage_key(&creator_id, poll_id, &voter); + if let Some(previous_vote) = env + .storage() + .persistent() + .get::(&vote_key) + { + let previous_count = poll + .vote_counts + .get(previous_vote.option_index) + .ok_or(PollError::InvalidOption)?; + let updated_previous_count = previous_count + .checked_sub(previous_vote.weight) + .ok_or(PollError::Overflow)?; + poll.vote_counts + .set(previous_vote.option_index, updated_previous_count); + poll.total_weight = poll + .total_weight + .checked_sub(previous_vote.weight) + .ok_or(PollError::Overflow)?; + } + + let selected_count = poll + .vote_counts + .get(option_index) + .ok_or(PollError::InvalidOption)?; + let updated_selected_count = selected_count + .checked_add(weight) + .ok_or(PollError::Overflow)?; + poll.vote_counts.set(option_index, updated_selected_count); + poll.total_weight = poll + .total_weight + .checked_add(weight) + .ok_or(PollError::Overflow)?; + + env.storage() + .persistent() + .set(&poll_storage_key(&creator_id, poll_id), &poll); + env.storage().persistent().set( + &vote_key, + &PollVote { + option_index, + weight, + }, + ); + env.events().publish( + (POLL_VOTE_EVENT_NAME, creator_id, poll_id, voter), + (option_index, weight), + ); + + Ok(()) + } + + /// Returns the current weighted result for a creator poll. + pub fn get_poll_result( + env: Env, + creator_id: Address, + poll_id: u32, + ) -> Result { + let poll = read_poll(&env, &creator_id, poll_id)?; + let expired = is_poll_expired(&env, &poll); + Ok(PollResult { + question: poll.question, + options: poll.options, + vote_counts: poll.vote_counts, + total_weight: poll.total_weight, + expired, + }) + } +} diff --git a/creator-keys/tests/emergency_pause.rs b/creator-keys/tests/emergency_pause.rs index 46d124f..230580b 100644 --- a/creator-keys/tests/emergency_pause.rs +++ b/creator-keys/tests/emergency_pause.rs @@ -203,6 +203,7 @@ fn test_pause_blocks_registration_not_reads() { &soroban_sdk::String::from_str(&env, "creatorb"), &None, &None, + &None, ); assert_eq!(result, Err(Ok(ContractError::ProtocolPaused))); @@ -220,6 +221,7 @@ fn test_pause_blocks_registration_not_reads() { &soroban_sdk::String::from_str(&env, "creatorb"), &None, &None, + &None, ) .unwrap(); } diff --git a/creator-keys/tests/governance_polls.rs b/creator-keys/tests/governance_polls.rs new file mode 100644 index 0000000..cf5675f --- /dev/null +++ b/creator-keys/tests/governance_polls.rs @@ -0,0 +1,221 @@ +use creator_keys::{events::PollError, CreatorKeysContract, CreatorKeysContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + vec, Address, Env, String, +}; + +fn poll_options(env: &Env) -> soroban_sdk::Vec { + vec![ + env, + String::from_str(env, "Yes"), + String::from_str(env, "No"), + ] +} + +#[test] +fn creator_can_create_poll_and_view_empty_result() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + client.register_creator( + &creator, + &String::from_str(&env, "alice"), + &None, + &None, + &None, + ); + + let question = String::from_str(&env, "Should we launch premium content?"); + let options = poll_options(&env); + let poll_id = client.create_poll(&creator, &question, &options, &10); + + assert_eq!(poll_id, 1); + let result = client.get_poll_result(&creator, &poll_id); + assert_eq!(result.question, question); + assert_eq!(result.options.len(), 2); + assert_eq!(result.vote_counts.get(0).unwrap(), 0); + assert_eq!(result.vote_counts.get(1).unwrap(), 0); + assert_eq!(result.total_weight, 0); + assert!(!result.expired); +} + +#[test] +fn holder_vote_uses_liquid_key_balance_as_weight() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_key_price(&admin, &100); + + let creator = Address::generate(&env); + client.register_creator( + &creator, + &String::from_str(&env, "alice"), + &None, + &None, + &None, + ); + + let holder = Address::generate(&env); + client.buy_key(&creator, &holder, &100, &None); + client.buy_key(&creator, &holder, &100, &None); + + let poll_id = client.create_poll( + &creator, + &String::from_str(&env, "Pick one"), + &poll_options(&env), + &10, + ); + client.cast_vote(&creator, &holder, &poll_id, &0); + + let result = client.get_poll_result(&creator, &poll_id); + assert_eq!(result.vote_counts.get(0).unwrap(), 2); + assert_eq!(result.vote_counts.get(1).unwrap(), 0); + assert_eq!(result.total_weight, 2); +} + +#[test] +fn changing_vote_before_expiry_updates_tally() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_key_price(&admin, &100); + + let creator = Address::generate(&env); + client.register_creator( + &creator, + &String::from_str(&env, "alice"), + &None, + &None, + &None, + ); + + let holder = Address::generate(&env); + client.buy_key(&creator, &holder, &100, &None); + client.buy_key(&creator, &holder, &100, &None); + client.buy_key(&creator, &holder, &100, &None); + + let poll_id = client.create_poll( + &creator, + &String::from_str(&env, "Pick one"), + &poll_options(&env), + &10, + ); + + client.cast_vote(&creator, &holder, &poll_id, &0); + client.cast_vote(&creator, &holder, &poll_id, &1); + + let result = client.get_poll_result(&creator, &poll_id); + assert_eq!(result.vote_counts.get(0).unwrap(), 0); + assert_eq!(result.vote_counts.get(1).unwrap(), 3); + assert_eq!(result.total_weight, 3); +} + +#[test] +fn vote_after_expiry_reverts_with_poll_expired() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_key_price(&admin, &100); + + let creator = Address::generate(&env); + client.register_creator( + &creator, + &String::from_str(&env, "alice"), + &None, + &None, + &None, + ); + + let holder = Address::generate(&env); + client.buy_key(&creator, &holder, &100, &None); + + let poll_id = client.create_poll( + &creator, + &String::from_str(&env, "Pick one"), + &poll_options(&env), + &1, + ); + + env.ledger().with_mut(|ledger| { + ledger.sequence_number += 1; + }); + + let result = client.try_cast_vote(&creator, &holder, &poll_id, &0); + assert_eq!(result, Err(Ok(PollError::PollExpired))); + + let result = client.get_poll_result(&creator, &poll_id); + assert!(result.expired); +} + +#[test] +fn non_holder_vote_reverts_with_not_a_holder() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + client.register_creator( + &creator, + &String::from_str(&env, "alice"), + &None, + &None, + &None, + ); + + let non_holder = Address::generate(&env); + let poll_id = client.create_poll( + &creator, + &String::from_str(&env, "Pick one"), + &poll_options(&env), + &10, + ); + + let result = client.try_cast_vote(&creator, &non_holder, &poll_id, &0); + assert_eq!(result, Err(Ok(PollError::NotAHolder))); +} + +#[test] +fn invalid_vote_option_reverts() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_key_price(&admin, &100); + + let creator = Address::generate(&env); + client.register_creator( + &creator, + &String::from_str(&env, "alice"), + &None, + &None, + &None, + ); + + let holder = Address::generate(&env); + client.buy_key(&creator, &holder, &100, &None); + + let poll_id = client.create_poll( + &creator, + &String::from_str(&env, "Pick one"), + &poll_options(&env), + &10, + ); + + let result = client.try_cast_vote(&creator, &holder, &poll_id, &2); + assert_eq!(result, Err(Ok(PollError::InvalidOption))); +}