Skip to content

Commit 1b3cd52

Browse files
authored
Merge pull request #56 from Quantus-Network/fix/raid_leaderboard
Fix raid leaderboard submission validity check
2 parents 4bcffa8 + 7b37b82 commit 1b3cd52

4 files changed

Lines changed: 141 additions & 43 deletions

File tree

src/handlers/raid_quest.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,18 +186,13 @@ pub async fn handle_create_raid_submission(
186186
Extension(user): Extension<Address>,
187187
extract::Json(payload): Json<RaidSubmissionInput>,
188188
) -> Result<(StatusCode, Json<SuccessResponse<String>>), AppError> {
189-
let (current_active_raid, user_x) = get_active_raid_and_x_association(&state, &user).await?;
189+
let (current_active_raid, _user_x) = get_active_raid_and_x_association(&state, &user).await?;
190190

191-
let Some((reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else {
191+
let Some((_reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else {
192192
return Err(AppError::Handler(HandlerError::InvalidBody(
193193
"Couldn't parse tweet reply link".to_string(),
194194
)));
195195
};
196-
if user_x.username.to_lowercase() != reply_username.to_lowercase() {
197-
return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized(
198-
"Only tweet reply author is eligible to submit".to_string(),
199-
))));
200-
}
201196

202197
let new_raid_submission = CreateRaidSubmission {
203198
id: reply_id,

src/models/raid_submission.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,9 @@ pub struct RaiderSubmissions {
9191
pub current_raid: RaidQuest,
9292
pub submissions: Vec<String>,
9393
}
94+
95+
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
96+
pub struct ValidRaidSubmissionWithRaiderUsername {
97+
pub raid_submission_id: String,
98+
pub raider_username: String,
99+
}

src/repositories/raid_submission.rs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
22

33
use crate::{
44
db_persistence::DbError,
5-
models::raid_submission::{CreateRaidSubmission, RaidSubmission, UpdateRaidSubmissionStats},
5+
models::raid_submission::{
6+
CreateRaidSubmission, RaidSubmission, UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername,
7+
},
68
repositories::DbResult,
79
};
810

@@ -62,12 +64,12 @@ impl RaidSubmissionRepository {
6264
Ok(submission)
6365
}
6466

65-
pub async fn find_valid_only_by_raid(&self, raid_id: i32) -> DbResult<Vec<RaidSubmission>> {
66-
let mut qb = Self::create_select_base_query();
67-
qb.push(" WHERE raid_id = ");
67+
pub async fn find_valid_only_by_raid(&self, raid_id: i32) -> DbResult<Vec<ValidRaidSubmissionWithRaiderUsername>> {
68+
let mut qb = QueryBuilder::new("SELECT rs.id as raid_submission_id, x.username as raider_username FROM raid_submissions rs INNER JOIN x_associations x ON rs.raider_id = x.quan_address");
69+
qb.push(" WHERE rs.raid_id = ");
6870
qb.push_bind(raid_id);
69-
qb.push(" AND NOT is_invalid");
70-
qb.push(" ORDER BY created_at DESC");
71+
qb.push(" AND NOT rs.is_invalid");
72+
qb.push(" ORDER BY rs.created_at DESC");
7173

7274
let submissions = qb.build_query_as().fetch_all(&self.pool).await?;
7375

@@ -295,6 +297,15 @@ mod tests {
295297
let repo = setup_test_repository().await;
296298
let seed = seed_dependencies(&repo.pool).await;
297299

300+
// Seed x_association for the raider so the query can retrieve the username
301+
let x_username = "test_raider_username";
302+
sqlx::query("INSERT INTO x_associations (quan_address, username) VALUES ($1, $2)")
303+
.bind(&seed.raider_id)
304+
.bind(x_username)
305+
.execute(&repo.pool)
306+
.await
307+
.expect("Failed to seed x_association");
308+
298309
// Create 3 submissions with slight delays to ensure distinct created_at timestamps
299310
let sub1 = create_mock_submission_input(&seed);
300311
repo.create(&sub1).await.unwrap();
@@ -312,11 +323,22 @@ mod tests {
312323

313324
assert_eq!(results.len(), 3);
314325

315-
// Verify Sorting: Query uses "ORDER BY created_at DESC"
326+
// Verify Sorting: Query uses "ORDER BY rs.created_at DESC"
316327
// So sub3 (newest) should be first
317-
assert_eq!(results[0].id, sub3.id, "Newest submission should be first");
318-
assert_eq!(results[1].id, sub2.id);
319-
assert_eq!(results[2].id, sub1.id, "Oldest submission should be last");
328+
assert_eq!(
329+
results[0].raid_submission_id, sub3.id,
330+
"Newest submission should be first"
331+
);
332+
assert_eq!(results[1].raid_submission_id, sub2.id);
333+
assert_eq!(
334+
results[2].raid_submission_id, sub1.id,
335+
"Oldest submission should be last"
336+
);
337+
338+
// Verify that usernames are correctly retrieved
339+
assert_eq!(results[0].raider_username, x_username);
340+
assert_eq!(results[1].raider_username, x_username);
341+
assert_eq!(results[2].raider_username, x_username);
320342
}
321343

322344
#[tokio::test]

src/services/raid_leaderboard_service.rs

Lines changed: 101 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ use std::{
44
};
55

66
use rusx::{
7-
resources::{tweet::TweetParams, TweetField},
7+
resources::{tweet::TweetParams, TweetExpansion, TweetField},
88
TwitterGateway,
99
};
1010

1111
use crate::{
1212
db_persistence::DbPersistence,
1313
metrics::{track_tweets_pulled, track_twitter_api_call},
14-
models::raid_submission::{RaidSubmission, UpdateRaidSubmissionStats},
14+
models::raid_submission::{UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername},
1515
services::alert_service::AlertService,
1616
AppError, AppResult, Config,
1717
};
@@ -25,13 +25,13 @@ pub struct RaidLeaderboardService {
2525
}
2626

2727
impl RaidLeaderboardService {
28-
fn build_batched_tweet_queries(submissions: &[RaidSubmission]) -> Vec<Vec<String>> {
28+
fn build_batched_tweet_queries(submissions: &[ValidRaidSubmissionWithRaiderUsername]) -> Vec<Vec<String>> {
2929
// Twitter's limit for the get ids result
3030
const TWEET_GET_MAX_IDS: usize = 100;
3131

3232
submissions
3333
.chunks(TWEET_GET_MAX_IDS)
34-
.map(|chunk| chunk.iter().map(|s| s.id.clone()).collect())
34+
.map(|chunk| chunk.iter().map(|s| s.raid_submission_id.clone()).collect())
3535
.collect()
3636
}
3737

@@ -88,7 +88,10 @@ impl RaidLeaderboardService {
8888
};
8989

9090
let queries = RaidLeaderboardService::build_batched_tweet_queries(&raid_submissions);
91-
let raider_map: HashMap<String, String> = raid_submissions.into_iter().map(|s| (s.id, s.raider_id)).collect();
91+
let submission_to_x_username_map: HashMap<String, String> = raid_submissions
92+
.into_iter()
93+
.map(|s| (s.raid_submission_id, s.raider_username))
94+
.collect();
9295

9396
let mut params = TweetParams::new();
9497
params.tweet_fields = Some(vec![
@@ -98,6 +101,7 @@ impl RaidLeaderboardService {
98101
TweetField::InReplyToUserId,
99102
TweetField::ReferencedTweets,
100103
]);
104+
params.expansions = Some(vec![TweetExpansion::AuthorId]);
101105

102106
// X Api Request Limit: 15 requests / 15 mins.
103107
// We set interval to 1 min (~1 req/min) to be safe.
@@ -119,6 +123,17 @@ impl RaidLeaderboardService {
119123
tracing::info!("No tweets found!.");
120124
continue;
121125
};
126+
let Some(includes) = &response.includes else {
127+
tracing::info!("No includes found!.");
128+
continue;
129+
};
130+
let Some(users) = &includes.users else {
131+
tracing::info!("No users found!.");
132+
continue;
133+
};
134+
135+
let user_id_to_username_map: HashMap<String, String> =
136+
users.iter().map(|u| (u.id.clone(), u.username.clone())).collect();
122137

123138
// Track Twitter API usage (for alerting)
124139
let tweets_pulled = tweets.len();
@@ -150,7 +165,13 @@ impl RaidLeaderboardService {
150165
// Check if ANY of the referenced IDs exist in our valid set
151166
refs.iter().any(|r| valid_raid_ids.contains(&r.id))
152167
});
153-
let is_eligible_owner = raider_map.get(&tweet.id) == tweet.author_id.as_ref();
168+
let is_eligible_owner = match (
169+
tweet.author_id.as_ref().and_then(|id| user_id_to_username_map.get(id)),
170+
submission_to_x_username_map.get(&tweet.id),
171+
) {
172+
(Some(author), Some(expected)) => author.eq_ignore_ascii_case(expected),
173+
_ => false,
174+
};
154175

155176
if is_valid_reply && is_eligible_owner {
156177
valid_tweets.push(tweet);
@@ -193,7 +214,8 @@ mod tests {
193214
use rusx::{
194215
resources::{
195216
tweet::{ReferenceType, ReferencedTweet, Tweet, TweetApi, TweetPublicMetrics},
196-
TwitterApiResponse,
217+
user::User,
218+
Includes, TwitterApiResponse,
197219
},
198220
MockTweetApi, MockTwitterGateway,
199221
};
@@ -242,6 +264,8 @@ mod tests {
242264
raid_id: i32,
243265
target_id: &str,
244266
submission_id: &str,
267+
x_username: &str,
268+
x_user_id: &str,
245269
) {
246270
// 1. Seed Raider (Address)
247271
// Handle constraint if address already exists from previous calls in same test
@@ -252,22 +276,34 @@ mod tests {
252276
.execute(&db.pool)
253277
.await;
254278

255-
// 2. Seed Tweet Author (Foreign Key for RelevantTweet)
279+
// 2. Seed X Association (Required for raider_id to X user ID mapping)
280+
let _ = sqlx::query(
281+
"INSERT INTO x_associations (quan_address, username) VALUES ($1, $2) ON CONFLICT (quan_address) DO UPDATE SET username = EXCLUDED.username",
282+
)
283+
.bind(raider_id)
284+
.bind(x_username)
285+
.execute(&db.pool)
286+
.await;
287+
288+
// 3. Seed Tweet Author (Foreign Key for RelevantTweet)
256289
let _ = sqlx::query(
257-
"INSERT INTO tweet_authors (id, name, username) VALUES ('auth_1', 'Auth', 'auth') ON CONFLICT DO NOTHING",
290+
"INSERT INTO tweet_authors (id, name, username) VALUES ($1, 'Auth', $2) ON CONFLICT DO NOTHING",
258291
)
292+
.bind(x_user_id)
293+
.bind(x_username)
259294
.execute(&db.pool)
260295
.await;
261296

262-
// 3. Seed Relevant Tweet (Target)
297+
// 4. Seed Relevant Tweet (Target)
263298
let _ = sqlx::query(
264-
"INSERT INTO relevant_tweets (id, author_id, text, created_at) VALUES ($1, 'auth_1', 'Target', NOW())",
299+
"INSERT INTO relevant_tweets (id, author_id, text, created_at) VALUES ($1, $2, 'Target', NOW())",
265300
)
266301
.bind(target_id)
302+
.bind(x_user_id)
267303
.execute(&db.pool)
268304
.await;
269305

270-
// 4. Create Submission
306+
// 5. Create Submission
271307
let _ = sqlx::query(
272308
"INSERT INTO raid_submissions (id, raid_id, raider_id, impression_count, like_count)
273309
VALUES ($1, $2, $3, 0, 0)",
@@ -337,28 +373,42 @@ mod tests {
337373
let raider_id = "0xRaider";
338374
let sub_id = "12345_submission";
339375
let target_id = "target_12345_submission";
340-
seed_submission(&db, raider_id, raid_id, target_id, sub_id).await;
376+
let x_username = "test_raider";
377+
let x_user_id = "1234567890"; // X user ID
378+
seed_submission(&db, raider_id, raid_id, target_id, sub_id, x_username, x_user_id).await;
341379

342380
// 3. Setup Mocks
343381
let mut mock_gateway = MockTwitterGateway::new();
344382
let mut mock_tweet_api = MockTweetApi::new();
345383

346384
// Expect get_many to be called with the submission ID
385+
let x_user_id_clone = x_user_id.to_string();
386+
let target_id_clone = target_id.to_string();
387+
let sub_id_clone = sub_id.to_string();
347388
mock_tweet_api
348389
.expect_get_many()
349390
.with(predicate::eq(vec![sub_id.to_string()]), predicate::always())
350391
.times(1)
351-
.returning(|_, _| {
392+
.returning(move |_, _| {
352393
Ok(TwitterApiResponse {
353394
// Return UPDATED stats (100 impressions, 50 likes)
354395
data: Some(vec![create_mock_tweet(
355-
sub_id,
356-
target_id.to_string(),
357-
raider_id.to_string(),
396+
&sub_id_clone,
397+
target_id_clone.clone(),
398+
x_user_id_clone.clone(), // Use X user ID, not raider_id
358399
100,
359400
50,
360401
)]),
361-
includes: None,
402+
includes: Some(Includes {
403+
users: Some(vec![User {
404+
id: x_user_id_clone.clone(),
405+
username: x_username.to_string(),
406+
name: "Test User".to_string(),
407+
description: None,
408+
public_metrics: None,
409+
}]),
410+
tweets: None,
411+
}),
362412
meta: None,
363413
})
364414
});
@@ -398,28 +448,41 @@ mod tests {
398448
let raider_id = "0xRaider";
399449
let sub_id = "12345_submission";
400450
let target_id = "target_12345_submission";
401-
seed_submission(&db, raider_id, raid_id, target_id, sub_id).await;
451+
let x_username = "test_raider";
452+
let x_user_id = "1234567890"; // X user ID
453+
seed_submission(&db, raider_id, raid_id, target_id, sub_id, x_username, x_user_id).await;
402454

403455
// 3. Setup Mocks
404456
let mut mock_gateway = MockTwitterGateway::new();
405457
let mut mock_tweet_api = MockTweetApi::new();
406458

407459
// Expect get_many to be called with the submission ID
460+
let x_user_id_clone = x_user_id.to_string();
461+
let sub_id_clone = sub_id.to_string();
408462
mock_tweet_api
409463
.expect_get_many()
410464
.with(predicate::eq(vec![sub_id.to_string()]), predicate::always())
411465
.times(1)
412-
.returning(|_, _| {
466+
.returning(move |_, _| {
413467
Ok(TwitterApiResponse {
414468
// Return UPDATED stats (100 impressions, 50 likes)
415469
data: Some(vec![create_mock_tweet(
416-
sub_id,
470+
&sub_id_clone,
417471
"invalid_id".to_string(),
418-
raider_id.to_string(),
472+
x_user_id_clone.clone(), // Use X user ID, not raider_id
419473
100,
420474
50,
421475
)]),
422-
includes: None,
476+
includes: Some(Includes {
477+
users: Some(vec![User {
478+
id: x_user_id_clone.clone(),
479+
username: "invalid_username".to_string(),
480+
name: "Test User".to_string(),
481+
description: None,
482+
public_metrics: None,
483+
}]),
484+
tweets: None,
485+
}),
423486
meta: None,
424487
})
425488
});
@@ -460,9 +523,20 @@ mod tests {
460523
// We just need unique IDs.
461524
let mut all_ids = Vec::new();
462525
let raider_id = "0xRaider";
526+
let x_username = "test_raider";
527+
let x_user_id = "1234567890"; // X user ID
463528
for i in 0..150 {
464529
let id = format!("sub_{}", i);
465-
seed_submission(&db, raider_id, raid_id, &format!("target_{}", id), &id).await;
530+
seed_submission(
531+
&db,
532+
raider_id,
533+
raid_id,
534+
&format!("target_{}", id),
535+
&id,
536+
x_username,
537+
x_user_id,
538+
)
539+
.await;
466540
all_ids.push(id);
467541
}
468542

@@ -473,11 +547,12 @@ mod tests {
473547
// We expect `get_many` to be called 2 times.
474548
// 1st time: 100 IDs
475549
// 2nd time: 50 IDs
476-
mock_tweet_api.expect_get_many().times(2).returning(|ids, _| {
550+
let x_user_id_clone = x_user_id.to_string();
551+
mock_tweet_api.expect_get_many().times(2).returning(move |ids, _| {
477552
// Return valid responses for whatever IDs were requested
478553
let tweets = ids
479554
.iter()
480-
.map(|id| create_mock_tweet(id, format!("target_{}", id), raider_id.to_string(), 10, 1))
555+
.map(|id| create_mock_tweet(id, format!("target_{}", id), x_user_id_clone.clone(), 10, 1))
481556
.collect();
482557
Ok(TwitterApiResponse {
483558
data: Some(tweets),

0 commit comments

Comments
 (0)