diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 1e81964..8a6262e 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -860,6 +860,24 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53aff6fdc1b181225acdcb5b14c47106726fd8e486707315b1b138baed68ee31" +[[package]] +name = "easy-poll" +version = "0.0.1" +dependencies = [ + "anyhow", + "cost", + "near-primitives 0.17.0", + "near-sdk", + "near-units", + "pretty_assertions", + "sbt", + "serde_json", + "tokio", + "tracing", + "uint", + "workspaces", +] + [[package]] name = "ed25519" version = "1.5.3" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index fa2f36e..61ab4d0 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -6,7 +6,7 @@ members = [ "oracle", "registry", "soulbound-class", - + "easy-poll", "human_checker", "ubi", "demo-issuer", diff --git a/contracts/easy-poll/Cargo.toml b/contracts/easy-poll/Cargo.toml new file mode 100644 index 0000000..ee3f46a --- /dev/null +++ b/contracts/easy-poll/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "easy-poll" +version = "0.0.1" +authors = [ + "NDC GWG (https://near.social/#/mob.near/widget/ProfilePage?accountId=govworkinggroup.near)", +] +edition = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + + +[dependencies] +uint.workspace = true +near-sdk.workspace = true +serde_json.workspace = true + +cost = { path = "../cost" } +sbt = { path = "../sbt" } + +[dev-dependencies] +pretty_assertions.workspace = true +anyhow.workspace = true +tokio.workspace = true +workspaces.workspace = true +near-primitives.workspace = true +near-units.workspace = true +tracing.workspace = true diff --git a/contracts/easy-poll/Makefile b/contracts/easy-poll/Makefile new file mode 100644 index 0000000..c2b26ea --- /dev/null +++ b/contracts/easy-poll/Makefile @@ -0,0 +1 @@ +include ../Makefile-common.mk diff --git a/contracts/easy-poll/README.md b/contracts/easy-poll/README.md new file mode 100644 index 0000000..57a2807 --- /dev/null +++ b/contracts/easy-poll/README.md @@ -0,0 +1,10 @@ +# Easy Poll + +Proof of concept + + +Based on https://www.notion.so/near-ndc/EasyPoll-v2-f991a29781ca452db154c64922717d19#35d9a363be34495bb13ad5fa4b73cafe + +## Usage + +## Deployed contracts diff --git a/contracts/easy-poll/src/errors.rs b/contracts/easy-poll/src/errors.rs new file mode 100644 index 0000000..a6efc96 --- /dev/null +++ b/contracts/easy-poll/src/errors.rs @@ -0,0 +1,38 @@ +use near_sdk::env::panic_str; +use near_sdk::FunctionError; + +use crate::MAX_TEXT_ANSWER_LEN; + +/// Contract errors +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +pub enum PollError { + RequiredAnswer(usize), + NotIAH, + NotFound, + NotActive, + OpinionRange, + WrongAnswer, + IncorrectAnswerVector, + AlredyAnswered, + AnswerTooLong(usize), +} + +impl FunctionError for PollError { + fn panic(&self) -> ! { + match self { + PollError::RequiredAnswer(index) => { + panic_str(&format!("Answer to a required question index={} was not provided",index)) + } + PollError::NotIAH => panic_str("voter is not a verified human"), + PollError::NotFound => panic_str("poll not found"), + PollError::NotActive => panic_str("poll is not active"), + PollError::OpinionRange => panic_str("opinion must be between 1 and 10"), + PollError::WrongAnswer => { + panic_str("answer provied does not match the expected question") + }, + PollError::IncorrectAnswerVector => panic_str("the answer vector provided is incorrect and does not match the questions in the poll"), + PollError::AlredyAnswered => panic_str("user has already answered"), + PollError::AnswerTooLong(len) => {panic_str(&format!("the answer too long, max_len:{}, got:{}", MAX_TEXT_ANSWER_LEN, len))} + } + } +} diff --git a/contracts/easy-poll/src/events.rs b/contracts/easy-poll/src/events.rs new file mode 100644 index 0000000..eac36b5 --- /dev/null +++ b/contracts/easy-poll/src/events.rs @@ -0,0 +1,50 @@ +use near_sdk::{serde::Serialize, AccountId}; +use serde_json::json; + +use sbt::{EventPayload, NearEvent}; + +use crate::PollId; + +fn emit_event(event: EventPayload) { + NearEvent { + standard: "ndc-easy-poll", + version: "1.0.0", + event, + } + .emit(); +} + +pub(crate) fn emit_create_poll(poll_id: PollId) { + emit_event(EventPayload { + event: "create_poll", + data: json!({ "poll_id": poll_id }), + }); +} + +pub(crate) fn emit_respond(poll_id: PollId, responder: AccountId) { + emit_event(EventPayload { + event: "respond", + data: json!({ "poll_id": poll_id, "responder": responder }), + }); +} + +#[cfg(test)] +mod unit_tests { + use near_sdk::{test_utils, AccountId}; + + use super::*; + + fn acc(idx: u8) -> AccountId { + AccountId::new_unchecked(format!("user-{}.near", idx)) + } + + #[test] + fn log_vote() { + let expected1 = r#"EVENT_JSON:{"standard":"ndc-easy-poll","version":"1.0.0","event":"create_poll","data":{"poll_id":21}}"#; + let expected2 = r#"EVENT_JSON:{"standard":"ndc-easy-poll","version":"1.0.0","event":"respond","data":{"poll_id":22,"responder":"user-1.near"}}"#; + emit_create_poll(21); + assert_eq!(vec![expected1], test_utils::get_logs()); + emit_respond(22, acc(1)); + assert_eq!(vec![expected1, expected2], test_utils::get_logs()); + } +} diff --git a/contracts/easy-poll/src/ext.rs b/contracts/easy-poll/src/ext.rs new file mode 100644 index 0000000..a8468d9 --- /dev/null +++ b/contracts/easy-poll/src/ext.rs @@ -0,0 +1,9 @@ +pub use crate::storage::*; +use near_sdk::{ext_contract, AccountId}; +use sbt::TokenId; + +#[ext_contract(ext_registry)] +trait ExtRegistry { + // queries + fn is_human(&self, account: AccountId) -> Vec<(AccountId, Vec)>; +} diff --git a/contracts/easy-poll/src/lib.rs b/contracts/easy-poll/src/lib.rs new file mode 100644 index 0000000..f9c27d1 --- /dev/null +++ b/contracts/easy-poll/src/lib.rs @@ -0,0 +1,879 @@ +pub use crate::errors::PollError; +use crate::events::emit_create_poll; +use crate::events::emit_respond; +pub use crate::ext::*; +pub use crate::storage::*; +use cost::MILI_NEAR; +use ext::ext_registry; +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::collections::LookupMap; +use near_sdk::collections::LookupSet; +use near_sdk::{env, near_bindgen, require, AccountId, PanicOnDefault}; +use near_sdk::{Balance, Gas}; + +mod errors; +mod events; +mod ext; +mod storage; + +pub const RESPOND_COST: Balance = MILI_NEAR; +pub const RESPOND_CALLBACK_GAS: Gas = Gas(2 * Gas::ONE_TERA.0); +pub const MAX_TEXT_ANSWER_LEN: usize = 500; // TODO: decide on the maximum length of the text answers to + +#[near_bindgen] +#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +pub struct Contract { + /// map of all polls + pub polls: LookupMap, + /// map of all results summarized + pub results: LookupMap, + /// lookup set of (poll_id, responder) + pub participants: LookupSet<(PollId, AccountId)>, + /// SBT registry. + pub sbt_registry: AccountId, + /// next poll id + pub next_poll_id: PollId, +} + +#[near_bindgen] +impl Contract { + #[init] + pub fn new(sbt_registry: AccountId) -> Self { + Self { + polls: LookupMap::new(StorageKey::Polls), + results: LookupMap::new(StorageKey::Results), + participants: LookupSet::new(StorageKey::Participants), + sbt_registry, + next_poll_id: 1, + } + } + + /********** + * QUERIES + **********/ + + /// Returns the poll details. If poll not found returns None. + pub fn poll(&self, poll_id: PollId) -> Option { + self.polls.get(&poll_id) + } + + /// Returns poll results (except for text answers), if poll not found returns None. + pub fn results(&self, poll_id: u64) -> Option { + self.results.get(&poll_id) + } + + /********** + * TRANSACTIONS + **********/ + + /// User can update the poll if starts_at > now + /// it panics if + /// - user tries to create an invalid poll + /// - if poll aready exists and starts_at < now + /// emits create_poll event + pub fn create_poll( + &mut self, + iah_only: bool, + questions: Vec, + starts_at: u64, + ends_at: u64, + title: String, + tags: Vec, + description: String, + link: String, + ) -> PollId { + let created_at = env::block_timestamp_ms(); + require!(created_at < starts_at, "poll start must be in the future"); + let poll_id = self.next_poll_id; + self.next_poll_id += 1; + self.initialize_results(poll_id, &questions); + self.polls.insert( + &poll_id, + &Poll { + iah_only, + questions, + starts_at, + ends_at, + title, + tags, + description, + link, + created_at, + }, + ); + emit_create_poll(poll_id); + poll_id + } + + /// Allows user to respond to a poll, once the answers are submited they cannot be changed. + /// it panics if + /// - poll not found + /// - poll not active + /// - user alredy answered + /// - poll.verified_humans_only is true, and user is not verified on IAH + /// - user tries to vote with an invalid answer to a question + /// emits repond event + #[payable] + #[handle_result] + pub fn respond( + &mut self, + poll_id: PollId, + answers: Vec>, + ) -> Result<(), PollError> { + require!( + env::attached_deposit() >= RESPOND_COST, + "attached_deposit not sufficient" + ); + let caller = env::predecessor_account_id(); + + self.assert_active(poll_id)?; + + self.assert_not_answered(poll_id, &caller)?; + let poll = match self.polls.get(&poll_id) { + None => return Err(PollError::NotFound), + Some(poll) => poll, + }; + // if iah calls the registry to verify the iah sbt + if poll.iah_only { + ext_registry::ext(self.sbt_registry.clone()) + .is_human(caller.clone()) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(RESPOND_CALLBACK_GAS) + .on_human_verifed(true, caller, poll_id, answers), + ); + } else { + self.on_human_verifed(vec![], false, caller, poll_id, answers)? + } + Ok(()) + } + + /********** + * PRIVATE + **********/ + + /// Callback for the respond method. + #[private] + #[handle_result] + pub fn on_human_verifed( + &mut self, + #[callback_unwrap] tokens: Vec<(AccountId, Vec)>, + iah_only: bool, + caller: AccountId, + poll_id: PollId, + answers: Vec>, + ) -> Result<(), PollError> { + // Check for IAH requirement if iah_only is set + if iah_only && tokens.is_empty() { + return Err(PollError::NotIAH); + } + + // Retrieve questions and poll results + let questions = match self.polls.get(&poll_id) { + Some(poll) => poll.questions, + None => return Err(PollError::NotFound), + }; + let mut poll_results = match self.results.get(&poll_id) { + Some(results) => results, + None => return Err(PollError::NotFound), + }; + + // Check if the number of answers matches the number of questions + if questions.len() != answers.len() { + return Err(PollError::IncorrectAnswerVector); + } + + for i in 0..questions.len() { + let q = &questions[i]; + let a = &answers[i]; + + match (a, &mut poll_results.results[i]) { + (Some(Answer::YesNo(response)), PollResult::YesNo((yes_count, no_count))) => { + if *response { + *yes_count += 1; + } else { + *no_count += 1; + } + } + (Some(Answer::TextChoices(choices)), PollResult::TextChoices(results)) + | (Some(Answer::PictureChoices(choices)), PollResult::PictureChoices(results)) => { + for choice in choices { + results[*choice as usize] += 1; + } + } + (Some(Answer::OpinionRange(opinion)), PollResult::OpinionRange(results)) => { + if *opinion < 1 || *opinion > 10 { + return Err(PollError::OpinionRange); + } + results.sum += *opinion as u64; + results.num += 1; + } + (Some(Answer::TextAnswer(answer)), PollResult::TextAnswer) => { + if answer.len() > MAX_TEXT_ANSWER_LEN { + return Err(PollError::AnswerTooLong(answer.len())); + } + } + // if the answer is not provided do nothing + (None, _) => { + if q.required { + return Err(PollError::RequiredAnswer(i)); + } + } + (_, _) => return Err(PollError::WrongAnswer), + } + } + + // Update the participants lookupset to ensure user cannot answer twice + self.participants.insert(&(poll_id, caller.clone())); + poll_results.participants_num += 1; + self.results.insert(&poll_id, &poll_results); + emit_respond(poll_id, caller); + + Ok(()) + } + + /********** + * INTERNAL + **********/ + + fn assert_active(&self, poll_id: PollId) -> Result<(), PollError> { + let poll = match self.polls.get(&poll_id) { + Some(poll) => poll, + None => return Err(PollError::NotFound), + }; + let current_timestamp = env::block_timestamp_ms(); + if poll.starts_at > current_timestamp || poll.ends_at < current_timestamp { + return Err(PollError::NotActive); + } + Ok(()) + } + + fn assert_not_answered(&self, poll_id: PollId, caller: &AccountId) -> Result<(), PollError> { + if self.participants.contains(&(poll_id, caller.clone())) { + return Err(PollError::AlredyAnswered); + } + Ok(()) + } + + fn initialize_results(&mut self, poll_id: PollId, questions: &[Question]) { + let mut index = 0; + let results: Vec = questions + .iter() + .map(|question| { + let result = match &question.question_type { + Answer::YesNo(_) => PollResult::YesNo((0, 0)), + Answer::TextChoices(choices) => PollResult::TextChoices(vec![0; choices.len()]), + Answer::PictureChoices(_) => PollResult::PictureChoices(Vec::new()), + Answer::OpinionRange(_) => { + PollResult::OpinionRange(OpinionRangeResult { sum: 0, num: 0 }) + } + Answer::TextAnswer(_) => PollResult::TextAnswer, + }; + index += 1; + result + }) + .collect(); + + self.results.insert( + &poll_id, + &Results { + status: Status::NotStarted, + participants_num: 0, + results, + }, + ); + } +} + +#[cfg(test)] +mod tests { + use near_sdk::{ + test_utils::{self, VMContextBuilder}, + testing_env, AccountId, VMContext, + }; + + use crate::{ + Answer, Contract, OpinionRangeResult, PollError, PollResult, Question, Results, Status, + RESPOND_COST, + }; + + const MILI_SECOND: u64 = 1000000; // nanoseconds + + fn alice() -> AccountId { + AccountId::new_unchecked("alice.near".to_string()) + } + + fn bob() -> AccountId { + AccountId::new_unchecked("bob.near".to_string()) + } + + fn charlie() -> AccountId { + AccountId::new_unchecked("charlie.near".to_string()) + } + + fn registry() -> AccountId { + AccountId::new_unchecked("registry.near".to_string()) + } + + fn tags() -> Vec { + vec![String::from("tag1"), String::from("tag2")] + } + + fn question_text_answers(required: bool) -> Question { + Question { + question_type: Answer::TextAnswer(String::from("")), + required, + title: String::from("Opinion test!"), + description: None, + image: None, + labels: None, + choices: None, + max_choices: None, + } + } + + fn question_yes_no(required: bool) -> Question { + Question { + question_type: Answer::YesNo(true), + required, + title: String::from("Yes and no test!"), + description: None, + image: None, + labels: None, + choices: None, + max_choices: None, + } + } + + fn question_text_choices(required: bool) -> Question { + Question { + question_type: Answer::TextChoices(vec![0, 1, 2]), + required, + title: String::from("Yes and no test!"), + description: None, + image: None, + labels: None, + choices: Some(vec![ + String::from("agree"), + String::from("disagree"), + String::from("no opinion"), + ]), + max_choices: Some(1), + } + } + + fn question_opinion_range(required: bool) -> Question { + Question { + question_type: Answer::OpinionRange(0), + required, + title: String::from("Opinion test!"), + description: None, + image: None, + labels: None, + choices: None, + max_choices: None, + } + } + + fn setup(predecessor: &AccountId) -> (VMContext, Contract) { + let mut ctx = VMContextBuilder::new() + .predecessor_account_id(alice()) + .block_timestamp(MILI_SECOND) + .is_view(false) + .build(); + testing_env!(ctx.clone()); + let ctr = Contract::new(registry()); + ctx.predecessor_account_id = predecessor.clone(); + testing_env!(ctx.clone()); + return (ctx, ctr); + } + + #[test] + #[should_panic(expected = "poll start must be in the future")] + fn create_poll_wrong_time() { + let (_, mut ctr) = setup(&alice()); + ctr.create_poll( + false, + vec![question_yes_no(true)], + 1, + 100, + String::from("Hello, world!"), + tags(), + String::from(""), + String::from(""), + ); + } + + #[test] + fn create_poll() { + let (_, mut ctr) = setup(&alice()); + ctr.create_poll( + false, + vec![question_yes_no(true)], + 2, + 100, + String::from("Hello, world!"), + tags(), + String::from(""), + String::from(""), + ); + let expected_event = r#"EVENT_JSON:{"standard":"ndc-easy-poll","version":"1.0.0","event":"create_poll","data":{"poll_id":1}}"#; + assert!(test_utils::get_logs().len() == 1); + assert_eq!(test_utils::get_logs()[0], expected_event); + } + + #[test] + fn results_poll_not_found() { + let (_, ctr) = setup(&alice()); + assert!(ctr.results(1).is_none()); + } + + #[test] + fn results() { + let (_, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_yes_no(true)], + 2, + 100, + String::from("Hello, world!"), + tags(), + String::from(""), + String::from(""), + ); + let res = ctr.results(poll_id); + let expected = Results { + status: Status::NotStarted, + participants_num: 0, + results: vec![PollResult::YesNo((0, 0))], + }; + assert_eq!(res.unwrap(), expected); + } + + #[test] + #[should_panic(expected = "attached_deposit not sufficient")] + fn respond_wrong_deposit() { + let (mut ctx, mut ctr) = setup(&alice()); + ctx.attached_deposit = RESPOND_COST - 1; + testing_env!(ctx); + let res = ctr.respond(0, vec![Some(Answer::YesNo(true))]); + assert!(res.is_err()); + } + + #[test] + fn respond_poll_not_active() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_yes_no(true)], + 2, + 100, + String::from("Hello, world!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.attached_deposit = RESPOND_COST; + testing_env!(ctx.clone()); + // too early + match ctr.respond(poll_id, vec![Some(Answer::YesNo(true))]) { + Err(err) => { + println!("Received error: {:?}", err); + match err { + PollError::NotActive => println!("Expected error: PollError::NotActive"), + _ => panic!("Unexpected error: {:?}", err), + } + } + Ok(_) => panic!("Received Ok result, but expected an error"), + } + ctx.block_timestamp = MILI_SECOND * 101; + testing_env!(ctx); + // too late + match ctr.respond(poll_id, vec![Some(Answer::YesNo(true))]) { + Err(err) => { + println!("Received error: {:?}", err); + match err { + PollError::NotActive => println!("Expected error: PollError::NotActive"), + _ => panic!("Unexpected error: {:?}", err), + } + } + Ok(_) => panic!("Received Ok result, but expected an error"), + } + } + + #[test] + fn yes_no_flow() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_yes_no(true)], + 2, + 100, + String::from("Hello, world!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx.clone()); + let mut res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::YesNo(true))], + ); + assert!(res.is_ok()); + + let expected_event = r#"EVENT_JSON:{"standard":"ndc-easy-poll","version":"1.0.0","event":"respond","data":{"poll_id":1,"responder":"alice.near"}}"#; + assert!(test_utils::get_logs().len() == 1); + assert_eq!(test_utils::get_logs()[0], expected_event); + + ctx.predecessor_account_id = bob(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::YesNo(true))], + ); + assert!(res.is_ok()); + + assert!(test_utils::get_logs().len() == 1); + + ctx.predecessor_account_id = charlie(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::YesNo(false))], + ); + assert!(res.is_ok()); + + assert!(test_utils::get_logs().len() == 1); + + let results = ctr.results(poll_id); + assert_eq!( + results.unwrap(), + Results { + status: Status::NotStarted, + participants_num: 3, + results: vec![PollResult::YesNo((2, 1)),] + } + ) + } + + #[test] + fn opinion_range_out_of_range() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_opinion_range(false)], + 2, + 100, + String::from("Multiple questions test!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx); + match ctr.on_human_verifed( + vec![], + false, + alice(), + poll_id, + vec![Some(Answer::OpinionRange(11))], + ) { + Err(err) => { + println!("Received error: {:?}", err); + match err { + PollError::OpinionRange => println!("Expected error: PollError::OpinionRange"), + _ => panic!("Unexpected error: {:?}", err), + } + } + Ok(_) => panic!("Received Ok result, but expected an error"), + } + } + + #[test] + fn respond_wrong_answer_vector() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_opinion_range(false)], + 2, + 100, + String::from("Multiple questions test!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx); + match ctr.on_human_verifed( + vec![], + false, + alice(), + poll_id, + vec![ + Some(Answer::OpinionRange(10)), + Some(Answer::OpinionRange(10)), + ], + ) { + Err(err) => { + println!("Received error: {:?}", err); + match err { + PollError::IncorrectAnswerVector => { + println!("Expected error: PollError::IncorrectAnswerVector") + } + _ => panic!("Unexpected error: {:?}", err), + } + } + Ok(_) => panic!("Received Ok result, but expected an error"), + } + } + + #[test] + fn opinion_range_flow() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_opinion_range(false)], + 2, + 100, + String::from("Multiple questions test!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.predecessor_account_id = alice(); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx.clone()); + let mut res = ctr.on_human_verifed( + vec![], + false, + alice(), + poll_id, + vec![Some(Answer::OpinionRange(5))], + ); + assert!(res.is_ok()); + ctx.predecessor_account_id = bob(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + bob(), + poll_id, + vec![Some(Answer::OpinionRange(10))], + ); + assert!(res.is_ok()); + ctx.predecessor_account_id = charlie(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + charlie(), + poll_id, + vec![Some(Answer::OpinionRange(2))], + ); + assert!(res.is_ok()); + let results = ctr.results(poll_id); + assert_eq!( + results.unwrap(), + Results { + status: Status::NotStarted, + participants_num: 3, + results: vec![PollResult::OpinionRange(OpinionRangeResult { + sum: 17, + num: 3 + }),] + } + ) + } + #[test] + fn text_chocies_flow() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_text_choices(true)], + 2, + 100, + String::from("Hello, world!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.predecessor_account_id = alice(); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx.clone()); + let mut res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::TextChoices(vec![0]))], + ); + assert!(res.is_ok()); + ctx.predecessor_account_id = bob(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::TextChoices(vec![0]))], + ); + assert!(res.is_ok()); + ctx.predecessor_account_id = charlie(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::TextChoices(vec![1]))], + ); + assert!(res.is_ok()); + let results = ctr.results(poll_id); + assert_eq!( + results.unwrap(), + Results { + status: Status::NotStarted, + participants_num: 3, + results: vec![PollResult::TextChoices(vec![2, 1, 0]),] + } + ) + } + + #[test] + fn text_answers_flow() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + false, + vec![question_text_answers(true)], + 2, + 100, + String::from("Hello, world!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.predecessor_account_id = alice(); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx.clone()); + let answer1: String = "Answer 1".to_string(); + let answer2: String = "Answer 2".to_string(); + let answer3: String = "Answer 3".to_string(); + let mut res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::TextAnswer(answer1.clone()))], + ); + assert!(res.is_ok()); + ctx.predecessor_account_id = bob(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::TextAnswer(answer2.clone()))], + ); + assert!(res.is_ok()); + ctx.predecessor_account_id = charlie(); + testing_env!(ctx.clone()); + res = ctr.on_human_verifed( + vec![], + false, + ctx.predecessor_account_id, + poll_id, + vec![Some(Answer::TextAnswer(answer3.clone()))], + ); + assert!(res.is_ok()); + let results = ctr.results(poll_id); + assert_eq!( + results.unwrap(), + Results { + status: Status::NotStarted, + participants_num: 3, + results: vec![PollResult::TextAnswer] + } + ); + } + + #[test] + fn respond_iah_only_not_human() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + true, + vec![question_opinion_range(false)], + 2, + 100, + String::from("Multiple questions test!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx); + match ctr.on_human_verifed( + vec![], + true, + alice(), + poll_id, + vec![Some(Answer::OpinionRange(10))], + ) { + Err(err) => { + println!("Received error: {:?}", err); + match err { + PollError::NotIAH => { + println!("Expected error: PollError::NotIAH") + } + _ => panic!("Unexpected error: {:?}", err), + } + } + Ok(_) => panic!("Received Ok result, but expected an error"), + } + } + + #[test] + fn respond_required_answer_not_provided() { + let (mut ctx, mut ctr) = setup(&alice()); + let poll_id = ctr.create_poll( + true, + vec![question_opinion_range(false), question_opinion_range(true)], + 2, + 100, + String::from("Multiple questions test!"), + tags(), + String::from(""), + String::from(""), + ); + ctx.block_timestamp = MILI_SECOND * 3; + testing_env!(ctx); + match ctr.on_human_verifed( + vec![], + false, + alice(), + poll_id, + vec![Some(Answer::OpinionRange(10)), None], + ) { + Err(err) => { + println!("Received error: {:?}", err); + match err { + PollError::RequiredAnswer(1) => { + println!("Expected error: PollError::RequiredAnswer") + } + _ => panic!("Unexpected error: {:?}", err), + } + } + Ok(_) => panic!("Received Ok result, but expected an error"), + } + } +} diff --git a/contracts/easy-poll/src/storage.rs b/contracts/easy-poll/src/storage.rs new file mode 100644 index 0000000..1ac8891 --- /dev/null +++ b/contracts/easy-poll/src/storage.rs @@ -0,0 +1,89 @@ +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::BorshStorageKey; + +pub type PollId = u64; + +/// Helper structure for keys of the persistent collections. +#[derive(BorshSerialize, BorshDeserialize, Deserialize, Serialize, Clone)] +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +#[serde(crate = "near_sdk::serde")] +pub enum Answer { + YesNo(bool), + TextChoices(Vec), // should respect the min_choices, max_choices + PictureChoices(Vec), // should respect the min_choices, max_choices + OpinionRange(u8), // should be a number between 0 and 10 + TextAnswer(String), +} + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +#[serde(crate = "near_sdk::serde")] +pub enum PollResult { + YesNo((u32, u32)), // yes, no + TextChoices(Vec), // should respect the min_choices, max_choices + PictureChoices(Vec), // should respect the min_choices, max_choices + OpinionRange(OpinionRangeResult), // mean value + TextAnswer, // indicates whether the question exist or not, the answers are stored in a different struct called `TextAnswers` +} + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +#[serde(crate = "near_sdk::serde")] +pub struct OpinionRangeResult { + pub sum: u64, + pub num: u64, +} + +/// Helper structure for keys of the persistent collections. +#[derive(BorshSerialize, BorshDeserialize, Deserialize, Serialize)] +#[serde(crate = "near_sdk::serde")] +pub struct Question { + pub question_type: Answer, // required + pub required: bool, // required, if true users can't vote without having an answer for this question + pub title: String, // required + pub description: Option, // optional + pub image: Option, // optional + pub labels: Option<(String, String, String)>, // if applicable, labels for the opinion scale question + pub choices: Option>, // if applicable, choices for the text and picture choices question TODO: make sure we dont need it + pub max_choices: Option, +} + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct Poll { + pub iah_only: bool, // required, if true only verified humans can vote, if false anyone can vote + pub questions: Vec, // required, a poll can have any number of questions + pub starts_at: u64, // required, time in milliseconds + pub ends_at: u64, // required, time in milliseconds + pub title: String, // required + pub tags: Vec, // can be an empty vector + pub description: String, // can be an empty string + pub link: String, // can be an empty string + pub created_at: u64, // time in milliseconds, should be assigned by the smart contract not a user. +} + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +#[serde(crate = "near_sdk::serde")] +pub struct Results { + pub status: Status, + pub participants_num: u64, // number of participants + pub results: Vec, // question_id, result (sum of yes etc.) +} + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone)] +#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Debug))] +#[serde(crate = "near_sdk::serde")] +pub enum Status { + NotStarted, + Active, + Finished, +} + +#[derive(BorshSerialize, BorshStorageKey)] +pub enum StorageKey { + Polls, + Results, + Participants, +}