diff --git a/Cargo.lock b/Cargo.lock index 0f416a0..4b600fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5000,8 +5000,8 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusx" -version = "0.6.0" -source = "git+https://github.com/Quantus-Network/rusx?tag=v0.6.0#1f5383af185509649fcbc117fc97e166a6cb47d3" +version = "0.6.1" +source = "git+https://github.com/Quantus-Network/rusx?tag=v0.6.1#cf7283b98c64c11ab2195421254393b829848abc" dependencies = [ "async-trait", "mockall 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index 7c4325d..a4e5f47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ path = "src/bin/create_raid.rs" qp-human-checkphrase = "0.1.2" qp-rusty-crystals-dilithium = "2.0.0" quantus-cli = "0.3.0" -rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0"} +rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.1"} # Async runtime tokio = {version = "1.46", features = ["full", "test-util"]} @@ -90,4 +90,4 @@ tiny-keccak = {version = "2.0.2", features = ["keccak"]} mockall = "0.13" wiremock = "0.5" # Enable the testing feature ONLY for tests -rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0", features = ["testing"]} +rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.1", features = ["testing"]} diff --git a/config/default.toml b/config/default.toml index 674981e..dbb2c92 100644 --- a/config/default.toml +++ b/config/default.toml @@ -54,6 +54,9 @@ callback_url = "http://localhost:3000/api/auth/x/callback" client_id = "WlVrcm4xSEpXQ2l3TURFM3lLZnE6MTpjaQ" client_secret = "lfXc45dZLqYTzP62Ms32EhXinGQzxcIP9TvjJml2B-h0T1nIJK" +[x_association] +bio_mention = "@QuantusNetwork" + [tweet_sync] api_key = "some-key" interval_in_hours = 24 diff --git a/config/example.toml b/config/example.toml index 9aa437c..98869a9 100644 --- a/config/example.toml +++ b/config/example.toml @@ -64,6 +64,9 @@ callback_url = "http://localhost:12345/example/callback" client_id = "example-id" client_secret = "example-secret" +[x_association] +bio_mention = "@QuantusNetwork" + [tweet_sync] api_key = "some-key" interval_in_hours = 24 diff --git a/config/test.toml b/config/test.toml index f190ac8..47327b2 100644 --- a/config/test.toml +++ b/config/test.toml @@ -54,6 +54,9 @@ callback_url = "http://localhost:12345/api/auth/x/callback" client_id = "test-id" client_secret = "test-secret" +[x_association] +bio_mention = "@QuantusNetwork" + [tweet_sync] api_key = "some-key" interval_in_hours = 24 diff --git a/src/config.rs b/src/config.rs index 5bbad1a..244a4fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,7 @@ pub struct Config { pub tg_bot: TelegramBotConfig, pub raid_leaderboard: RaidLeaderboardConfig, pub alert: AlertConfig, + pub x_association: XAssociationConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,6 +100,11 @@ pub struct AlertConfig { pub webhook_url: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XAssociationConfig { + pub bio_mention: String, +} + impl Config { pub fn load(config_path: &str) -> Result { let settings = config::Config::builder() @@ -169,6 +175,10 @@ impl Config { pub fn get_raid_leaderboard_tweets_req_interval(&self) -> time::Duration { time::Duration::from_secs(self.raid_leaderboard.tweets_req_interval_in_secs) } + + pub fn get_x_bio_mention(&self) -> &str { + &self.x_association.bio_mention + } } impl Default for Config { @@ -235,6 +245,9 @@ impl Default for Config { alert: AlertConfig { webhook_url: "https://your-webhook-url.com".to_string(), }, + x_association: XAssociationConfig { + bio_mention: "@QuantusNetwork".to_string(), + }, } } } diff --git a/src/handlers/address.rs b/src/handlers/address.rs index 7de4f2b..b3e58d2 100644 --- a/src/handlers/address.rs +++ b/src/handlers/address.rs @@ -21,10 +21,13 @@ use crate::{ eth_association::{ AssociateEthAddressRequest, AssociateEthAddressResponse, EthAssociation, EthAssociationInput, }, + x_association::{AssociateXHandleRequest, XAssociation, XAssociationInput}, }, AppError, }; +use rusx::resources::{user::UserParams, UserField}; + use super::SuccessResponse; #[derive(Debug, thiserror::Error)] @@ -222,6 +225,65 @@ pub async fn associate_eth_address( Ok(Json(response)) } +pub async fn associate_x_handle( + State(state): State, + Extension(user): Extension
, + Json(payload): Json, +) -> Result { + tracing::info!( + "Received X handle association request for quan_address: {} -> username: {}", + user.quan_address.0, + payload.username, + ); + + let mut params = UserParams::new(); + params.user_fields = Some(vec![UserField::Description, UserField::Username]); + + let user_resp = state + .twitter_gateway + .users() + .get_by_username(&payload.username, Some(params)) + .await + .map_err(|e| { + tracing::error!("Failed to fetch user by username {}: {:?}", payload.username, e); + AppError::Handler(HandlerError::Address(AddressHandlerError::InvalidQueryParams(format!( + "Failed to verify Twitter username: {}", + e + )))) + })?; + + let twitter_user = user_resp.data.ok_or_else(|| { + AppError::Handler(HandlerError::Address(AddressHandlerError::InvalidQueryParams( + "Twitter user not found".to_string(), + ))) + })?; + + let bio = twitter_user.description.unwrap_or_default(); + let x_bio_mention = state.config.get_x_bio_mention(); + if !bio.to_lowercase().contains(&x_bio_mention.to_lowercase()) { + return Err(AppError::Handler(HandlerError::Address( + AddressHandlerError::Unauthorized(format!( + "Twitter bio must contain '{}' to verify ownership", + x_bio_mention + )), + ))); + } + + let new_association = XAssociation::new(XAssociationInput { + quan_address: user.quan_address.0, + username: twitter_user.username, + })?; + + state.db.x_associations.create(&new_association).await?; + tracing::info!( + "Created association for quan_address {} with X username {}", + new_association.quan_address.0, + new_association.username + ); + + Ok(NoContent) +} + pub async fn update_eth_address( State(state): State, Extension(user): Extension
, @@ -377,6 +439,207 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; // Required for .oneshot() + use rusx::{ + resources::{ + user::{User, UserApi}, + TwitterApiResponse, + }, + MockTwitterGateway, MockUserApi, + }; + use std::sync::Arc; + + #[tokio::test] + async fn test_associate_x_handle_success() { + let mut state = create_test_app_state().await; + reset_database(&state.db.pool).await; + + // 1. Setup User & Token + let user = create_persisted_address(&state.db.addresses, "108").await; + let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); + + // 2. Mock Twitter Gateway + let mut mock_gateway = MockTwitterGateway::new(); + let mut mock_user_api = MockUserApi::new(); + + // Expect get_by_username + let bio_mention = state.config.get_x_bio_mention().to_string(); + mock_user_api.expect_get_by_username().returning(move |_, _| { + Ok(TwitterApiResponse { + data: Some(User { + id: "u1".to_string(), + name: "Test User".to_string(), + username: "test_user".to_string(), + description: Some(format!("I love {}", bio_mention)), // Contains keyword from config + public_metrics: None, + }), + includes: None, + meta: None, + }) + }); + + let user_api_arc: Arc = Arc::new(mock_user_api); + mock_gateway.expect_users().return_const(user_api_arc); + + state.twitter_gateway = Arc::new(mock_gateway); + + // 3. Setup Router + let router = Router::new() + .route("/associate-x", post(associate_x_handle)) + .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) + .with_state(state.clone()); + + // 4. Request + let payload = json!({ "username": "test_user" }); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/associate-x") + .header(http::header::CONTENT_TYPE, "application/json") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::from(serde_json::to_string(&payload).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + // 5. Assert + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // Check DB + let assoc = state + .db + .x_associations + .find_by_address(&user.quan_address) + .await + .unwrap(); + assert!(assoc.is_some()); + assert_eq!(assoc.unwrap().username, "test_user"); + } + + #[tokio::test] + async fn test_associate_x_handle_fails_bad_bio() { + let mut state = create_test_app_state().await; + reset_database(&state.db.pool).await; + + let user = create_persisted_address(&state.db.addresses, "109").await; + let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); + + let mut mock_gateway = MockTwitterGateway::new(); + let mut mock_user_api = MockUserApi::new(); + + mock_user_api.expect_get_by_username().returning(|_, _| { + Ok(TwitterApiResponse { + data: Some(User { + id: "u1".to_string(), + name: "Test User".to_string(), + username: "test_user".to_string(), + description: Some("No keyword here".to_string()), // Missing keyword + public_metrics: None, + }), + includes: None, + meta: None, + }) + }); + + let user_api_arc: Arc = Arc::new(mock_user_api); + mock_gateway.expect_users().return_const(user_api_arc); + state.twitter_gateway = Arc::new(mock_gateway); + + let router = Router::new() + .route("/associate-x", post(associate_x_handle)) + .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) + .with_state(state); + + let payload = json!({ "username": "test_user" }); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/associate-x") + .header(http::header::CONTENT_TYPE, "application/json") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::from(serde_json::to_string(&payload).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + // Should return 401 Unauthorized + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_associate_x_handle_case_insensitive_success() { + let mut state = create_test_app_state().await; + reset_database(&state.db.pool).await; + + // 1. Setup User & Token + let user = create_persisted_address(&state.db.addresses, "110").await; + let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0); + + // 2. Mock Twitter Gateway + let mut mock_gateway = MockTwitterGateway::new(); + let mut mock_user_api = MockUserApi::new(); + + // Expect get_by_username + let bio_mention = state.config.get_x_bio_mention().to_string(); + // Create a lowercase version of the mention for the bio + let lowercase_bio_mention = bio_mention.to_lowercase(); + + mock_user_api.expect_get_by_username().returning(move |_, _| { + Ok(TwitterApiResponse { + data: Some(User { + id: "u1".to_string(), + name: "Test User".to_string(), + username: "test_user".to_string(), + description: Some(format!("I love {}", lowercase_bio_mention)), // Contains lowercase keyword + public_metrics: None, + }), + includes: None, + meta: None, + }) + }); + + let user_api_arc: Arc = Arc::new(mock_user_api); + mock_gateway.expect_users().return_const(user_api_arc); + + state.twitter_gateway = Arc::new(mock_gateway); + + // 3. Setup Router + let router = Router::new() + .route("/associate-x", post(associate_x_handle)) + .layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) + .with_state(state.clone()); + + // 4. Request + let payload = json!({ "username": "test_user" }); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/associate-x") + .header(http::header::CONTENT_TYPE, "application/json") + .header(http::header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::from(serde_json::to_string(&payload).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + // 5. Assert - Should be successful even with different case + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // Check DB + let assoc = state + .db + .x_associations + .find_by_address(&user.quan_address) + .await + .unwrap(); + assert!(assoc.is_some()); + } + #[tokio::test] async fn test_update_eth_address_success() { let state = create_test_app_state().await; diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 0004b9b..8037c5f 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -443,6 +443,7 @@ mod tests { id: "101".to_string(), name: "Quantus Network".to_string(), username: expected_username.to_string(), + description: Some("Quantus Network".to_string()), public_metrics: Default::default(), }), includes: Default::default(), diff --git a/src/handlers/tweet_author.rs b/src/handlers/tweet_author.rs index a80807a..99d41ca 100644 --- a/src/handlers/tweet_author.rs +++ b/src/handlers/tweet_author.rs @@ -377,6 +377,7 @@ mod tests { id: "hello".to_string(), name: "hello".to_string(), username: "test_user".to_string(), + description: Some("Quantus Network".to_string()), public_metrics: Some(UserPublicMetrics { followers_count: 100, following_count: 50, diff --git a/src/models/tweet_pull_usage.rs b/src/models/tweet_pull_usage.rs index c02dbeb..6d247a7 100644 --- a/src/models/tweet_pull_usage.rs +++ b/src/models/tweet_pull_usage.rs @@ -1,6 +1,6 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; -use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct TweetPullUsage { @@ -8,4 +8,3 @@ pub struct TweetPullUsage { pub tweet_count: i32, pub updated_at: DateTime, } - diff --git a/src/models/x_association.rs b/src/models/x_association.rs index 8bd9bbb..ed30fe6 100644 --- a/src/models/x_association.rs +++ b/src/models/x_association.rs @@ -49,3 +49,8 @@ pub struct XAssociationInput { pub quan_address: String, pub username: String, } + +#[derive(Debug, Deserialize)] +pub struct AssociateXHandleRequest { + pub username: String, +} diff --git a/src/routes/address.rs b/src/routes/address.rs index 42024e0..a69b8f4 100644 --- a/src/routes/address.rs +++ b/src/routes/address.rs @@ -1,16 +1,16 @@ use axum::{ handler::Handler, middleware, - routing::{delete, get, post}, + routing::{get, post}, Router, }; use crate::{ handlers::address::{ - associate_eth_address, dissociate_eth_address, dissociate_x_account, handle_aggregate_address_stats, - handle_get_address_reward_status_by_id, handle_get_address_stats, handle_get_addresses, handle_get_leaderboard, - handle_get_opted_in_position, handle_get_opted_in_users, handle_update_reward_program_status, - retrieve_associated_accounts, sync_transfers, update_eth_address, + associate_eth_address, associate_x_handle, dissociate_eth_address, dissociate_x_account, + handle_aggregate_address_stats, handle_get_address_reward_status_by_id, handle_get_address_stats, + handle_get_addresses, handle_get_leaderboard, handle_get_opted_in_position, handle_get_opted_in_users, + handle_update_reward_program_status, retrieve_associated_accounts, sync_transfers, update_eth_address, }, http_server::AppState, middlewares::jwt_auth, @@ -59,8 +59,8 @@ pub fn address_routes(state: AppState) -> Router { ) .route( "/addresses/associations/x", - delete(dissociate_x_account - .layer(middleware::from_fn_with_state(state, jwt_auth::jwt_auth))), + post(associate_x_handle.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_auth))) + .delete(dissociate_x_account.layer(middleware::from_fn_with_state(state, jwt_auth::jwt_auth))), ) .route("/addresses/sync-transfers", post(sync_transfers)) } diff --git a/src/services/tweet_synchronizer_service.rs b/src/services/tweet_synchronizer_service.rs index 16b3d0e..416041c 100644 --- a/src/services/tweet_synchronizer_service.rs +++ b/src/services/tweet_synchronizer_service.rs @@ -318,6 +318,7 @@ mod tests { id: id.to_string(), name: "Test User".to_string(), username: username.to_string(), + description: Some("Quantus Network".to_string()), public_metrics: Some(UserPublicMetrics { followers_count: 1000, following_count: 100,