Skip to content

Commit

Permalink
feat: add hashstack quest (#210)
Browse files Browse the repository at this point in the history
* feat: add hashstack base

* feat: add updated endpoints with ids

* feat: add endpoints

* feat: update NFT naming
  • Loading branch information
ayushtom authored Apr 26, 2024
1 parent 26cfbea commit e80d8a2
Show file tree
Hide file tree
Showing 10 changed files with 427 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -76,6 +81,7 @@ pub_struct!(Clone, Deserialize; Quests {
element: Element,
nostra: Nostra,
carbonable: Contract,
hashstack: TokenAndContract
});

pub_struct!(Clone, Deserialize; Twitter {
Expand Down
109 changes: 109 additions & 0 deletions src/endpoints/quests/hashstack/claimable.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AppState>>,
Query(query): Query<ClaimableQuery>,
) -> impl IntoResponse {
let collection = state
.db
.collection::<CompletedTaskDocument>("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()),
}
}
152 changes: 152 additions & 0 deletions src/endpoints/quests/hashstack/discord_fw_callback.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AppState>>,
Query(query): Query<DiscordOAuthCallbackQuery>,
) -> 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<Guild> = 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<String, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let res = client
.post("https://discord.com/api/oauth2/token")
.form(&params)
.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
),
)))
}
}
}
6 changes: 6 additions & 0 deletions src/endpoints/quests/hashstack/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
61 changes: 61 additions & 0 deletions src/endpoints/quests/hashstack/verify_deposit.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AppState>>,
Query(query): Query<VerifyQuery>,
) -> 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)),
}
}
22 changes: 22 additions & 0 deletions src/endpoints/quests/hashstack/verify_has_domain.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AppState>>,
Query(query): Query<VerifyQuery>,
) -> impl IntoResponse {
verify_has_root_or_braavos_domain(state, &query.addr, 135).await
}
Loading

0 comments on commit e80d8a2

Please sign in to comment.