diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 18440018..95e0573c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -70,6 +70,26 @@ model auth_challenges { @@index([expires_at], map: "idx_auth_challenges_expires_at") } +model refresh_tokens { + id Int @id @default(autoincrement()) + token_hash String @unique + address String + created_at DateTime @default(now()) @db.Timestamptz(6) + expires_at DateTime @db.Timestamptz(6) + revoked Boolean @default(false) @map("is_revoked") + revoked_at DateTime? @db.Timestamptz(6) + revoke_reason String? + jti String @unique + last_used_at DateTime? @db.Timestamptz(6) + ip_address String? + user_agent String? + + @@index([address], map: "idx_refresh_tokens_address") + @@index([expires_at], map: "idx_refresh_tokens_expires_at") + @@index([revoked], map: "idx_refresh_tokens_is_revoked") + @@index([jti], map: "idx_refresh_tokens_jti") +} + model bid_status_transitions { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid bid_id String @db.Uuid diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 67ba1879..bc4dbc48 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -470,13 +470,14 @@ async function issueRefreshToken( if (previousTokenId !== undefined) { await prisma.refresh_tokens.update({ where: { id: previousTokenId }, - data: { revoked: true }, + data: { revoked: true, revoked_at: new Date() }, }); } const rawToken = crypto .randomBytes(48) .toString("base64url"); + const jti = crypto.randomUUID(); const hashedToken = crypto .createHash("sha256") @@ -493,6 +494,7 @@ async function issueRefreshToken( address, expires_at: expiresAt, revoked: false, + jti, }, }); @@ -1204,11 +1206,12 @@ export function createAuthRouter(deps: { return res.status(401).json({ error: "Challenge expired" }); } - const isValid = verifyStellarSignature(address, challengeRecord.challenge, signature); + let isValid = verifyStellarSignature(address, challengeRecord.challenge, signature); if (!isValid && process.env.NODE_ENV !== "production") { if (signature === "mock-signature" || timingSafeEqualStrings(signature, challengeRecord.challenge)) { // Accept mock / self-signed challenges in dev/test + isValid = true; } } @@ -1241,6 +1244,7 @@ export function createAuthRouter(deps: { address, expires_at: refreshExpiresAt, revoked: false, + jti: crypto.randomUUID(), }, }); @@ -1301,7 +1305,7 @@ export function createAuthRouter(deps: { await prismaClient.refresh_tokens.update({ where: { id: record.id }, - data: { revoked: true }, + data: { revoked: true, revoked_at: new Date() }, }); const rawNewRefreshToken = crypto.randomBytes(48).toString("base64url"); @@ -1314,6 +1318,7 @@ export function createAuthRouter(deps: { address: record.address, expires_at: newRefreshExpiresAt, revoked: false, + jti: crypto.randomUUID(), }, }); @@ -1371,7 +1376,7 @@ export function createAuthRouter(deps: { const hash = crypto.createHash("sha256").update(refreshToken).digest("hex"); await prismaClient.refresh_tokens.updateMany({ where: { token_hash: hash, revoked: false }, - data: { revoked: true }, + data: { revoked: true, revoked_at: new Date() }, }).catch(() => {}); } @@ -1428,4 +1433,4 @@ export function createAuthRouter(deps: { return r; } -export default router; \ No newline at end of file +export default router; diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 6ed00306..c5089a79 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -30,6 +30,13 @@ pub enum AuthorizedCaller { DisputeResolution, } +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Client, + Freelancer, +} + #[contracterror] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ReputationError { @@ -80,6 +87,16 @@ pub struct DisputeVerdictProcessedEvent { pub processed_at: u64, } +#[contracttype] +#[derive(Clone)] +pub struct ScoreRecoveredEvent { + pub address: Address, + pub role: Role, + pub previous_score: i32, + pub new_score: i32, + pub recovered_at: u64, +} + #[contract] pub struct ReputationContract; @@ -87,6 +104,8 @@ pub struct ReputationContract; impl ReputationContract { const INSTANCE_TTL_THRESHOLD: u32 = 50_000; const INSTANCE_TTL_EXTEND_TO: u32 = 150_000; + const RECOVERY_INACTIVITY_SECONDS: u64 = 90u64 * 24 * 60 * 60; + const RECOVERY_STEP_BPS: i32 = 1_000; fn bump_instance_ttl(env: &Env) { env.storage() @@ -211,6 +230,16 @@ impl ReputationContract { } } + fn clamp_score_i128(score: i128) -> i32 { + if score < MIN_SCORE as i128 { + MIN_SCORE + } else if score > MAX_SCORE as i128 { + MAX_SCORE + } else { + score as i32 + } + } + /// Get profile for an address, creating default if doesn't exist pub fn get_profile(env: Env, address: Address) -> Profile { Self::bump_instance_ttl(&env); @@ -547,6 +576,92 @@ impl ReputationContract { Ok(()) } + fn compute_recovery_towards_default(env: &Env, score: i32) -> Result { + if score == DEFAULT_SCORE { + return Ok(score); + } + + let distance = DEFAULT_SCORE as i128 - score as i128; + let adjustment = distance + .checked_mul(Self::RECOVERY_STEP_BPS as i128) + .ok_or(ReputationError::ArithmeticOverflow)? + / BPS_SCALE as i128; + + Ok(Self::clamp_score_i128(score as i128 + adjustment)) + } + + /// Recover a single role score toward the default score after inactivity. + pub fn recover_score( + env: Env, + caller: Address, + target_address: Address, + role: Role, + ) -> Result { + Self::verify_authorized_caller(&env, &caller)?; + + let mut profile = storage::read_profile_or_default(&env, &target_address); + let now = env.ledger().timestamp(); + if now.saturating_sub(profile.last_activity) < Self::RECOVERY_INACTIVITY_SECONDS { + return Ok(match role { + Role::Client => profile.client.score, + Role::Freelancer => profile.freelancer.score, + }); + } + + let role_metrics = match role { + Role::Client => &mut profile.client, + Role::Freelancer => &mut profile.freelancer, + }; + let previous_score = role_metrics.score; + let new_score = Self::compute_recovery_towards_default(&env, previous_score)?; + role_metrics.score = new_score; + + let old_client_badge = profile.client_badge.clone(); + let old_freelancer_badge = profile.freelancer_badge.clone(); + profile.last_activity = now; + profile.refresh_badges(); + storage::write_profile(&env, &target_address, &profile); + + env.events().publish( + ("reputation", "ScoreRecovered"), + ScoreRecoveredEvent { + address: target_address.clone(), + role, + previous_score, + new_score, + recovered_at: now, + }, + ); + + if profile.client_badge != old_client_badge { + env.events().publish( + ("reputation", "BadgeUpgraded"), + BadgeUpgradedEvent { + address: target_address.clone(), + role: String::from_str(&env, "client"), + old_badge: old_client_badge, + new_badge: profile.client_badge, + upgraded_at: now, + }, + ); + } + + if profile.freelancer_badge != old_freelancer_badge { + env.events().publish( + ("reputation", "BadgeUpgraded"), + BadgeUpgradedEvent { + address: target_address, + role: String::from_str(&env, "freelancer"), + old_badge: old_freelancer_badge, + new_badge: profile.freelancer_badge, + upgraded_at: now, + }, + ); + } + + Ok(new_score) + } + /// Get the admin address pub fn get_admin(env: Env) -> Result { Self::bump_instance_ttl(&env); diff --git a/contracts/reputation/src/test.rs b/contracts/reputation/src/test.rs index 5ad68861..2aa5d49e 100644 --- a/contracts/reputation/src/test.rs +++ b/contracts/reputation/src/test.rs @@ -1,10 +1,10 @@ #![cfg(test)] use crate::{ - AuthorizedCaller, BadgeLevel, ReputationContract, ReputationError, + AuthorizedCaller, BadgeLevel, ReputationContract, ReputationError, Role, }; use soroban_sdk::{Address, Env, String}; -use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::{Address as _, Ledger as _}; #[test] fn test_fixed_point_zero_division_protection() { @@ -414,6 +414,80 @@ fn test_score_clamping() { }); } +#[test] +fn test_recover_score_after_inactivity() { + let env = Env::default(); + let contract_id = env.register_contract(None, ReputationContract); + let admin = Address::generate(&env); + let authorized_escrow = Address::generate(&env); + let target_address = Address::generate(&env); + + env.clone().as_contract(&contract_id, || { + env.mock_all_auths(); + ReputationContract::initialize(env.clone(), admin.clone()).unwrap(); + ReputationContract::set_authorized_caller( + env.clone(), + admin, + AuthorizedCaller::Escrow, + authorized_escrow.clone(), + ) + .unwrap(); + + ReputationContract::add_review( + env.clone(), + authorized_escrow.clone(), + target_address.clone(), + true, + 3000_i32, + ) + .unwrap(); + + env.ledger().set_timestamp(90u64 * 24 * 60 * 60); + let recovered = ReputationContract::recover_score( + env.clone(), + authorized_escrow, + target_address.clone(), + Role::Client, + ) + .unwrap(); + + assert_eq!(recovered, 3200); + let profile = ReputationContract::get_profile(env, target_address); + assert_eq!(profile.client.score, 3200); + }); +} + +#[test] +fn test_recover_score_requires_authorized_caller() { + let env = Env::default(); + let contract_id = env.register_contract(None, ReputationContract); + let admin = Address::generate(&env); + let authorized_escrow = Address::generate(&env); + let attacker = Address::generate(&env); + let target_address = Address::generate(&env); + + env.clone().as_contract(&contract_id, || { + env.mock_all_auths(); + ReputationContract::initialize(env.clone(), admin.clone()).unwrap(); + ReputationContract::set_authorized_caller( + env.clone(), + admin, + AuthorizedCaller::Escrow, + authorized_escrow, + ) + .unwrap(); + + let result = ReputationContract::recover_score( + env.clone(), + attacker, + target_address, + Role::Freelancer, + ); + + assert_eq!(result, Err(ReputationError::NotAuthorizedContract)); + }); +} + #[test] fn test_invalid_dispute_verdict() { let env = Env::default();