diff --git a/src/config.rs b/src/config.rs index 284a5bd4..763f2f9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -51,6 +51,11 @@ pub_struct!(Clone, Deserialize; Contract { contract: FieldElement, }); +pub_struct!(Clone, Deserialize; TokenAndContract { + token_address: FieldElement, + contract: FieldElement, +}); + pub_struct!(Clone, Deserialize; Braavos { api_key_user: String, api_key_claimed_mission: String, @@ -76,6 +81,7 @@ pub_struct!(Clone, Deserialize; Quests { element: Element, nostra: Nostra, carbonable: Contract, + hashstack: TokenAndContract }); pub_struct!(Clone, Deserialize; Twitter { diff --git a/src/endpoints/quests/hashstack/claimable.rs b/src/endpoints/quests/hashstack/claimable.rs new file mode 100644 index 00000000..80592420 --- /dev/null +++ b/src/endpoints/quests/hashstack/claimable.rs @@ -0,0 +1,109 @@ +use crate::models::{AppState, CompletedTaskDocument, Reward, RewardResponse}; +use crate::utils::{get_error, get_nft}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use axum_auto_routes::route; +use futures::StreamExt; +use mongodb::bson::doc; +use serde::Deserialize; +use starknet::{ + core::types::FieldElement, + signers::{LocalWallet, SigningKey}, +}; +use std::sync::Arc; + +const QUEST_ID: u32 = 28; +const TASK_IDS: &[u32] = &[135, 136, 137, 138, 139]; +const LAST_TASK: u32 = TASK_IDS[4]; +const NFT_LEVEL: u32 = 40; + +#[derive(Deserialize)] +pub struct ClaimableQuery { + addr: FieldElement, +} + +#[route( +get, +"/quests/hashstack/claimable", +crate::endpoints::quests::hashstack::claimable +)] +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let collection = state + .db + .collection::("completed_tasks"); + + let pipeline = vec![ + doc! { + "$match": { + "address": &query.addr.to_string(), + "task_id": { "$in": TASK_IDS }, + }, + }, + doc! { + "$lookup": { + "from": "tasks", + "localField": "task_id", + "foreignField": "id", + "as": "task", + }, + }, + doc! { + "$match": { + "task.quest_id": QUEST_ID, + }, + }, + doc! { + "$group": { + "_id": "$address", + "completed_tasks": { "$push": "$task_id" }, + }, + }, + doc! { + "$match": { + "completed_tasks": { "$all": TASK_IDS }, + }, + }, + ]; + + let completed_tasks = collection.aggregate(pipeline, None).await; + match completed_tasks { + Ok(mut tasks_cursor) => { + if tasks_cursor.next().await.is_none() { + return get_error("User hasn't completed all tasks".into()); + } + + let signer = LocalWallet::from(SigningKey::from_secret_scalar( + state.conf.nft_contract.private_key, + )); + + let mut rewards = vec![]; + + let Ok((token_id, sig)) = + get_nft(QUEST_ID, LAST_TASK, &query.addr, NFT_LEVEL, &signer).await + else { + return get_error("Signature failed".into()); + }; + + rewards.push(Reward { + task_id: LAST_TASK, + nft_contract: state.conf.nft_contract.address.clone(), + token_id: token_id.to_string(), + sig: (sig.r, sig.s), + }); + + if rewards.is_empty() { + get_error("No rewards found for this user".into()) + } else { + (StatusCode::OK, Json(RewardResponse { rewards })).into_response() + } + } + Err(_) => get_error("Error querying rewards".into()), + } +} diff --git a/src/endpoints/quests/hashstack/discord_fw_callback.rs b/src/endpoints/quests/hashstack/discord_fw_callback.rs new file mode 100644 index 00000000..e275994c --- /dev/null +++ b/src/endpoints/quests/hashstack/discord_fw_callback.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; + +use crate::utils::CompletedTasksTrait; +use crate::{ + models::AppState, + utils::{get_error_redirect, success_redirect}, +}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, +}; +use axum_auto_routes::route; +use mongodb::bson::doc; +use reqwest::header::AUTHORIZATION; +use serde::Deserialize; +use starknet::core::types::FieldElement; + +#[derive(Deserialize)] +pub struct DiscordOAuthCallbackQuery { + code: String, + state: FieldElement, +} + +#[derive(Deserialize, Debug)] +pub struct Guild { + id: String, + #[allow(dead_code)] + name: String, +} + +#[route( +get, +"/quests/hashstack/discord_fw_callback", +crate::endpoints::quests::hashstack::discord_fw_callback + +)] +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let quest_id = 28; + let task_id = 137; + let guild_id = "907151419650482217"; + let authorization_code = &query.code; + let error_redirect_uri = format!( + "{}/quest/{}?task_id={}&res=false", + state.conf.variables.app_link, quest_id, task_id + ); + + // Exchange the authorization code for an access token + let params = [ + ("client_id", &state.conf.discord.oauth2_clientid), + ("client_secret", &state.conf.discord.oauth2_secret), + ("code", &authorization_code.to_string()), + ( + "redirect_uri", + &format!( + "{}/quests/hashstack/discord_fw_callback", + state.conf.variables.api_link + ), + ), + ("grant_type", &"authorization_code".to_string()), + ]; + let access_token = match exchange_authorization_code(params).await { + Ok(token) => token, + Err(e) => { + return get_error_redirect( + error_redirect_uri, + format!("Failed to exchange authorization code: {}", e), + ); + } + }; + + // Get user guild information + let client = reqwest::Client::new(); + let response_result = client + .get("https://discord.com/api/users/@me/guilds") + .header(AUTHORIZATION, format!("Bearer {}", access_token)) + .send() + .await; + let response: Vec = match response_result { + Ok(response) => { + let json_result = response.json().await; + match json_result { + Ok(json) => json, + Err(e) => { + return get_error_redirect( + error_redirect_uri, + format!( + "Failed to get JSON response while fetching user info: {}", + e + ), + ); + } + } + } + Err(e) => { + return get_error_redirect( + error_redirect_uri, + format!("Failed to send request to get user info: {}", e), + ); + } + }; + + for guild in response { + if guild.id == guild_id { + match state.upsert_completed_task(query.state, task_id).await { + Ok(_) => { + let redirect_uri = format!( + "{}/quest/{}?task_id={}&res=true", + state.conf.variables.app_link, quest_id, task_id + ); + return success_redirect(redirect_uri); + } + Err(e) => return get_error_redirect(error_redirect_uri, format!("{}", e)), + } + } + } + + get_error_redirect( + error_redirect_uri, + "You're not part of Hashstack's Discord server".to_string(), + ) +} + +async fn exchange_authorization_code( + params: [(&str, &String); 5], +) -> Result> { + let client = reqwest::Client::new(); + let res = client + .post("https://discord.com/api/oauth2/token") + .form(¶ms) + .send() + .await?; + let json: serde_json::Value = res.json().await?; + match json["access_token"].as_str() { + Some(s) => Ok(s.to_string()), + None => { + println!( + "Failed to get 'access_token' from JSON response : {:?}", + json + ); + Err(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "Failed to get 'access_token' from JSON response : {:?}", + json + ), + ))) + } + } +} diff --git a/src/endpoints/quests/hashstack/mod.rs b/src/endpoints/quests/hashstack/mod.rs new file mode 100644 index 00000000..3eedf6d3 --- /dev/null +++ b/src/endpoints/quests/hashstack/mod.rs @@ -0,0 +1,6 @@ +pub mod claimable; +pub mod discord_fw_callback; +pub mod verify_has_domain; +pub mod verify_twitter_fw_hashstack; +pub mod verify_twitter_rw; +pub mod verify_deposit; \ No newline at end of file diff --git a/src/endpoints/quests/hashstack/verify_deposit.rs b/src/endpoints/quests/hashstack/verify_deposit.rs new file mode 100644 index 00000000..29b7e1c0 --- /dev/null +++ b/src/endpoints/quests/hashstack/verify_deposit.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use crate::{ + models::{AppState, VerifyQuery}, + utils::{get_error, CompletedTasksTrait}, +}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use axum_auto_routes::route; +use serde_json::json; +use starknet::{ + core::types::{BlockId, BlockTag, FieldElement, FunctionCall}, + macros::selector, + providers::Provider, +}; + +#[route( +get, +"/quests/hashstack/verify_deposit", +crate::endpoints::quests::hashstack::verify_deposit +)] +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let task_id = 138; + let addr = &query.addr; + let token_id = state.conf.quests.hashstack.token_address; + let calldata = vec![token_id, *addr]; + + let call_result = state + .provider + .call( + FunctionCall { + contract_address: state.conf.quests.hashstack.contract, + entry_point_selector: selector!("get_user_deposit_stats_info"), + calldata, + }, + BlockId::Tag(BlockTag::Latest), + ) + .await; + + + match call_result { + Ok(result) => { + if result[0] < FieldElement::from_dec_str("1000000000").unwrap() { + get_error("You didn't invest on hashstack.".to_string()) + } else { + match state.upsert_completed_task(query.addr, task_id).await { + Ok(_) => (StatusCode::OK, Json(json!({"res": true}))).into_response(), + Err(e) => get_error(format!("{}", e)), + } + } + } + Err(e) => get_error(format!("{}", e)), + } +} diff --git a/src/endpoints/quests/hashstack/verify_has_domain.rs b/src/endpoints/quests/hashstack/verify_has_domain.rs new file mode 100644 index 00000000..49717b4d --- /dev/null +++ b/src/endpoints/quests/hashstack/verify_has_domain.rs @@ -0,0 +1,22 @@ +use crate::{ + common::verify_has_root_or_braavos_domain::verify_has_root_or_braavos_domain, + models::{AppState, VerifyQuery}, +}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, +}; +use axum_auto_routes::route; +use std::sync::Arc; + +#[route( +get, +"/quests/hashstack/verify_has_domain", +crate::endpoints::quests::hashstack::verify_has_domain +)] +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + verify_has_root_or_braavos_domain(state, &query.addr, 135).await +} diff --git a/src/endpoints/quests/hashstack/verify_twitter_fw_hashstack.rs b/src/endpoints/quests/hashstack/verify_twitter_fw_hashstack.rs new file mode 100644 index 00000000..8876d4fe --- /dev/null +++ b/src/endpoints/quests/hashstack/verify_twitter_fw_hashstack.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use crate::{ + models::{AppState, VerifyQuery}, + utils::{get_error, CompletedTasksTrait}, +}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use axum_auto_routes::route; +use serde_json::json; + +#[route( +get, +"/quests/hashstack/verify_twitter_fw", +crate::endpoints::quests::hashstack::verify_twitter_fw_hashstack +)] +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let task_id = 136; + match state.upsert_completed_task(query.addr, task_id).await { + Ok(_) => (StatusCode::OK, Json(json!({"res": true}))).into_response(), + Err(e) => get_error(format!("{}", e)), + } +} diff --git a/src/endpoints/quests/hashstack/verify_twitter_rw.rs b/src/endpoints/quests/hashstack/verify_twitter_rw.rs new file mode 100644 index 00000000..d59a4ec3 --- /dev/null +++ b/src/endpoints/quests/hashstack/verify_twitter_rw.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use crate::{ + models::{AppState, VerifyQuery}, + utils::{get_error, CompletedTasksTrait}, +}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use axum_auto_routes::route; +use serde_json::json; + +#[route( +get, +"/quests/hashstack/verify_twitter_rt", +crate::endpoints::quests::hashstack::verify_twitter_rw +)] +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let task_id = 139; + match state.upsert_completed_task(query.addr, task_id).await { + Ok(_) => (StatusCode::OK, Json(json!({"res": true}))).into_response(), + Err(e) => get_error(format!("{}", e)), + } +} diff --git a/src/endpoints/quests/mod.rs b/src/endpoints/quests/mod.rs index 7288584d..f990109b 100644 --- a/src/endpoints/quests/mod.rs +++ b/src/endpoints/quests/mod.rs @@ -20,3 +20,4 @@ pub mod zklend; pub mod rhino; pub mod rango; pub mod nimbora; +pub mod hashstack; diff --git a/src/endpoints/quests/uri.rs b/src/endpoints/quests/uri.rs index d3000098..4f40577c 100644 --- a/src/endpoints/quests/uri.rs +++ b/src/endpoints/quests/uri.rs @@ -434,6 +434,16 @@ pub async fn handler( }), ).into_response(), + Some(40) => ( + StatusCode::OK, + Json(TokenURI { + name: "Hashstack Winquest NFT ".into(), + description: "A Hashstack Winquest NFT won for successfully finishing the Quest".into(), + image: format!("{}/hashstack/hashstackEmpire.webp", state.conf.variables.app_link), + attributes: None, + }), + ).into_response(), + _ => get_error("Error, this level is not correct".into()), }