diff --git a/src/config.rs b/src/config.rs index c0b7b7a7..2e336787 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,6 +71,11 @@ pub_struct!(Clone, Deserialize; Twitter { oauth2_secret: String, }); +pub_struct!(Clone, Deserialize; QuestBoost{ + private_key: FieldElement, + update_interval: u64, +}); + pub_struct!(Clone, Deserialize; Discord { oauth2_clientid: String, oauth2_secret: String, @@ -85,8 +90,8 @@ pub enum QuizQuestionType { impl<'de> Deserialize<'de> for QuizQuestionType { fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, + where + D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; match s.to_lowercase().as_str() { @@ -144,6 +149,7 @@ pub_struct!(Clone, Deserialize; Config { quizzes: HashMap, starkscan: Starkscan, achievements: Achievements, + quest_boost: QuestBoost, }); pub fn load() -> Config { diff --git a/src/endpoints/has_completed_quest.rs b/src/endpoints/has_completed_quest.rs new file mode 100644 index 00000000..2c06113d --- /dev/null +++ b/src/endpoints/has_completed_quest.rs @@ -0,0 +1,106 @@ +use crate::{models::AppState, utils::get_error}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, + Json, +}; + +use futures::TryStreamExt; +use mongodb::bson::{doc, Document}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use starknet::core::types::FieldElement; +use std::sync::Arc; + +#[derive(Debug, Serialize, Deserialize)] +pub struct HasCompletedQuestsQuery { + addr: FieldElement, + quest_id: u32, +} + +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let address = query.addr.to_string(); + let quest_id = query.quest_id; + let pipeline = vec![ + doc! { + "$match": doc! { + "address": address, + } + }, + doc! { + "$lookup": doc! { + "from": "tasks", + "localField": "task_id", + "foreignField": "id", + "as": "associatedTask" + } + }, + doc! { + "$unwind": "$associatedTask" + }, + doc! { + "$project": doc! { + "_id": 0, + "address": 1, + "task_id": 1, + "quest_id": "$associatedTask.quest_id" + } + }, + doc! { + "$group": doc! { + "_id": "$quest_id", + "done": doc! { + "$sum": 1 + } + } + }, + doc! { + "$match": doc! { + "_id": quest_id, + } + }, + doc! { + "$lookup": doc! { + "from": "tasks", + "localField": "_id", + "foreignField": "quest_id", + "as": "tasks" + } + }, + doc! { + "$project": doc! { + "_id": 0, + "result": doc! { + "$cond": doc! { + "if": doc! { + "$eq": [ + doc! { + "$size": "$tasks" + }, + "$done" + ] + }, + "then": true, + "else": false + } + } + } + }, + ]; + let tasks_collection = state.db.collection::("completed_tasks"); + match tasks_collection.aggregate(pipeline, None).await { + Ok(cursor) => { + let mut cursor = cursor; + let mut result = false; + while let Some(doc) = cursor.try_next().await.unwrap() { + result = doc.get("result").unwrap().as_bool().unwrap(); + } + let response = serde_json::json!({ "completed": result }); + (StatusCode::OK, Json(response)).into_response() + } + Err(_) => get_error("Error querying status".to_string()), + } +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index 11e9eb48..dc271acb 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -9,3 +9,5 @@ pub mod get_tasks; pub mod get_trending_quests; pub mod quests; pub mod leaderboard; +pub mod quest_boost; +pub mod has_completed_quest; diff --git a/src/endpoints/quest_boost/get_claim_params.rs b/src/endpoints/quest_boost/get_claim_params.rs new file mode 100644 index 00000000..8803439c --- /dev/null +++ b/src/endpoints/quest_boost/get_claim_params.rs @@ -0,0 +1,62 @@ +use crate::{models::AppState, utils::get_error}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, + Json, +}; +use std::str::FromStr; + +use mongodb::bson::{doc, Document}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use starknet::core::crypto::ecdsa_sign; +use starknet::core::{crypto::pedersen_hash, types::FieldElement}; +use std::sync::Arc; + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetClaimBoostQuery { + boost_id: u32, +} + +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let boost_id = query.boost_id; + let collection = state.db.collection::("boosts"); + let res=collection.find_one(doc! {"id":boost_id},None).await.unwrap(); + + // if no boost found with the requested id + if res.is_none() { + return get_error(format!("Boost with id {} not found", boost_id)); + } + + let boost: Document = res.unwrap(); + let amount = boost.get("amount").unwrap().as_i32().unwrap() as u32; + let token = boost.get("token").unwrap().as_str().unwrap(); + let address = boost.get("winner").unwrap().as_str().unwrap(); + + let hashed = pedersen_hash( + &FieldElement::from(boost_id), + &pedersen_hash( + &FieldElement::from(amount), + &pedersen_hash( + &FieldElement::from(0 as u32), + &pedersen_hash( + &FieldElement::from_str(token).unwrap(), + &FieldElement::from_str(address).unwrap(), + ), + ), + ), + ); + + match ecdsa_sign(&state.conf.quest_boost.private_key, &hashed) { + Ok(signature) => ( + StatusCode::OK, + Json(json!({"address": address, "r": signature.r, "s": signature.s})), + ) + .into_response(), + Err(e) => get_error(format!("Error while generating signature: {}", e)), + } +} diff --git a/src/endpoints/quest_boost/mod.rs b/src/endpoints/quest_boost/mod.rs new file mode 100644 index 00000000..3fa8190c --- /dev/null +++ b/src/endpoints/quest_boost/mod.rs @@ -0,0 +1 @@ +pub mod get_claim_params; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 3839e116..9a7181f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use std::net::SocketAddr; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; -use crate::utils::{add_leaderboard_table}; +use crate::utils::{ add_leaderboard_table, run_boosts_raffle}; #[tokio::main] async fn main() { @@ -49,6 +49,8 @@ async fn main() { println!("database: connected"); } + let db_instance = shared_state.db.clone(); + run_boosts_raffle(&db_instance,conf.quest_boost.update_interval); add_leaderboard_table(&shared_state.db).await; let cors = CorsLayer::new().allow_headers(Any).allow_origin(Any); @@ -66,6 +68,10 @@ async fn main() { "/get_completed_quests", get(endpoints::get_completed_quests::handler), ) + .route( + "/has_completed_quests", + get(endpoints::has_completed_quest::handler), + ) .route( "/get_quest_participants", get(endpoints::get_quest_participants::handler), @@ -439,6 +445,10 @@ async fn main() { "/leaderboard/get_ranking", get(endpoints::leaderboard::get_ranking::handler), ) + .route( + "/boost/get_claim_params", + get(endpoints::quest_boost::get_claim_params::handler), + ) .with_state(shared_state) .layer(cors); diff --git a/src/models.rs b/src/models.rs index 2e5aa643..6626e1d9 100644 --- a/src/models.rs +++ b/src/models.rs @@ -153,6 +153,19 @@ pub_struct!(Debug, Serialize, Deserialize; LeaderboardTable { timestamp:f64, }); + +pub_struct!(Debug, Serialize, Deserialize; BoostTable { + amount: i32, + token: String, + expiry: i64, + quests: Vec, + claimed: bool, + winner: Option, + id: i32, + boost_description: String, + img_url: String, +}); + pub_struct!(Debug, Serialize, Deserialize; NftBalance { contract_address: String, token_id: String, diff --git a/src/utils.rs b/src/utils.rs index 492aef0e..000a66b6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,12 +1,19 @@ -use futures::TryStreamExt; -use crate::models::{AchievementDocument, AppState, CompletedTasks, LeaderboardTable, UserExperience}; +use crate::models::{ + AchievementDocument, AppState, BoostTable, CompletedTasks, LeaderboardTable, UserExperience, +}; use async_trait::async_trait; use axum::{ body::Body, http::{Response as HttpResponse, StatusCode, Uri}, response::{IntoResponse, Response}, }; -use mongodb::{bson::doc, options::UpdateOptions, results::UpdateResult, Collection, Database, Cursor, IndexModel}; +use chrono::Utc; +use futures::TryStreamExt; +use mongodb::{ + bson::doc, options::UpdateOptions, results::UpdateResult, Collection, Cursor, Database, + IndexModel, +}; +use rand::distributions::{Distribution, Uniform}; use starknet::signers::Signer; use starknet::{ core::{ @@ -18,7 +25,7 @@ use starknet::{ use std::fmt::Write; use std::result::Result; use std::str::FromStr; -use chrono::{Utc}; +use tokio::time::{sleep, Duration}; #[macro_export] macro_rules! pub_struct { ($($derive:path),*; $name:ident {$($field:ident: $t:ty),* $(,)?}) => { @@ -220,8 +227,15 @@ impl CompletedTasksTrait for AppState { let timestamp: f64 = Utc::now().timestamp_millis() as f64; let document = doc! { "address": addr.to_string(), "experience":experience, "timestamp":timestamp}; user_exp_collection.insert_one(document, None).await?; - let view_collection: Collection = self.db.collection("leaderboard_table"); - update_leaderboard(view_collection, addr.to_string(), experience.into(), timestamp).await; + let view_collection: Collection = + self.db.collection("leaderboard_table"); + update_leaderboard( + view_collection, + addr.to_string(), + experience.into(), + timestamp, + ) + .await; } Err(_e) => { get_error("Error querying quests".to_string()); @@ -276,11 +290,11 @@ impl AchievementsTrait for AppState { .update_one(filter, update, options) .await?; - match &result.upserted_id { Some(_id) => { // Check if the document was modified - let achievement_collection: Collection = self.db.collection("achievements"); + let achievement_collection: Collection = + self.db.collection("achievements"); // Define a query using the `doc!` macro. let query = doc! { "id": achievement_id }; let mut experience: i32 = 0; @@ -296,8 +310,15 @@ impl AchievementsTrait for AppState { let timestamp: f64 = Utc::now().timestamp_millis() as f64; let document = doc! { "address": addr.to_string(), "experience":experience, "timestamp":timestamp}; user_exp_collection.insert_one(document, None).await?; - let view_collection: Collection = self.db.collection("leaderboard_table"); - update_leaderboard(view_collection, addr.to_string(), experience.into(), timestamp).await; + let view_collection: Collection = + self.db.collection("leaderboard_table"); + update_leaderboard( + view_collection, + addr.to_string(), + experience.into(), + timestamp, + ) + .await; } None => {} } @@ -358,7 +379,13 @@ pub async fn fetch_json_from_url(url: String) -> Result, address: String, experience: i64, timestamp: f64) { +pub async fn update_leaderboard( + view_collection: Collection, + address: String, + experience: i64, + timestamp: f64, +) { + // get current experience and new experience to it let mut old_experience = 0; let filter = doc! { "_id": &*address }; @@ -367,15 +394,17 @@ pub async fn update_leaderboard(view_collection: Collection, a old_experience = doc.experience; } - // update the view collection let filter = doc! { "_id": &*address }; - let update = doc! { "$set": { "experience": old_experience + experience, "timestamp": timestamp } }; + let update = + doc! { "$set": { "experience": old_experience + experience, "timestamp": timestamp } }; let options = UpdateOptions::builder().upsert(true).build(); - view_collection.update_one(filter, update, options).await.unwrap(); + view_collection + .update_one(filter, update, options) + .await + .unwrap(); } - pub async fn add_leaderboard_table(db: &Database) { let view_collection_name = "leaderboard_table"; @@ -394,7 +423,8 @@ pub async fn add_leaderboard_table(db: &Database) { doc! { "$merge" : doc! { "into": view_collection_name , "on": "_id", "whenMatched": "replace", "whenNotMatched": "insert" } }, ]; - let view_collection: Collection = db.collection::(view_collection_name); + let view_collection: Collection = + db.collection::(view_collection_name); let source_collection = db.collection::("user_exp"); // create materialised view @@ -408,3 +438,183 @@ pub async fn add_leaderboard_table(db: &Database) { view_collection.create_index(index, None).await.unwrap(); } +pub async fn fetch_and_update_boosts_winner( + boost_collection: Collection, + completed_tasks_collection: Collection, + interval: u64, +) { + loop { + let pipeline = vec![doc! { + "$match": { + "expiry":{ + "$lt": Utc::now().timestamp_millis() + }, + "winner": { + "$eq": null, + }, + } + }]; + + match boost_collection.aggregate(pipeline, None).await { + Ok(mut cursor) => { + while let Some(doc) = cursor.try_next().await.unwrap() { + match doc.get("quests") { + Some(quests_res) => { + let quests = quests_res.as_array().unwrap(); + let mut address_list: Vec = Vec::new(); + for quest in quests { + let get_users_per_quest_pipeline = vec![ + doc! { + "$lookup": doc! { + "from": "tasks", + "localField": "task_id", + "foreignField": "id", + "as": "associated_tasks" + } + }, + doc! { + "$match": doc! { + "$expr": doc! { + "$eq": [ + doc! { + "$first": "$associated_tasks.quest_id" + }, + quest + ] + } + } + }, + doc! { + "$group": doc! { + "_id": "$address", + "tasks_list": doc! { + "$push": doc! { + "$arrayElemAt": [ + "$associated_tasks", + 0 + ] + } + } + } + }, + doc! { + "$unwind": "$tasks_list" + }, + doc! { + "$group": doc! { + "_id": doc! { + "address": "$_id", + "quest_id": "$tasks_list.quest_id" + }, + "tasks_array": doc! { + "$push": "$tasks_list" + } + } + }, + doc! { + "$project": doc! { + "_id": 0, + "address": "$_id.address", + "quest_id": "$_id.quest_id", + "tasks_array": 1 + } + }, + doc! { + "$lookup": doc! { + "from": "tasks", + "localField": "quest_id", + "foreignField": "quest_id", + "as": "associatedTasks" + } + }, + doc! { + "$match": doc! { + "$expr": doc! { + "$eq": [ + doc! { + "$size": "$tasks_array" + }, + doc! { + "$size": "$associatedTasks" + } + ] + } + } + }, + doc! { + "$project": doc! { + "address": "$address" + } + }, + doc! { + "$sample":{ + "size":1 + } + }, + ]; + match completed_tasks_collection + .aggregate(get_users_per_quest_pipeline, None) + .await + { + Ok(mut cursor) => { + while let Some(doc) = cursor.try_next().await.unwrap() { + let address = + doc.get("address").unwrap().as_str().unwrap(); + let formatted_address = + FieldElement::from_str(address).unwrap(); + address_list.push(formatted_address); + } + } + Err(_err) => {} + } + } + + // skip if no user has completed quests + if address_list.len() == 0 { + continue; + } + let random_index; + + // if length of address list is 1 then select the only user + if address_list.len() == 1 { + random_index = 0; + } + + // else select a random user + else { + let mut rng = rand::thread_rng(); + let die = Uniform::new(0, address_list.len()); + random_index = die.sample(&mut rng); + } + let winner = &address_list[random_index].to_string(); + + // save winner in database + let filter = doc! { "id": doc.get("id").unwrap().as_i32().unwrap() }; + let update = doc! { "$set": { "winner": winner } }; + let options = UpdateOptions::builder().upsert(true).build(); + boost_collection + .update_one(filter, update, options) + .await + .unwrap(); + } + None => {} + } + } + } + Err(_err) => println!("{}", _err), + }; + + sleep(Duration::from_secs(interval)).await; + } +} + +pub fn run_boosts_raffle(db: &Database, interval: u64) { + let boost_collection = db.collection::("boosts"); + let completed_tasks_collection = db.collection::("completed_tasks"); + tokio::spawn(fetch_and_update_boosts_winner( + boost_collection, + completed_tasks_collection, + interval, + )); +} +