Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -493,6 +494,7 @@ async function issueRefreshToken(
address,
expires_at: expiresAt,
revoked: false,
jti,
},
});

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -1241,6 +1244,7 @@ export function createAuthRouter(deps: {
address,
expires_at: refreshExpiresAt,
revoked: false,
jti: crypto.randomUUID(),
},
});

Expand Down Expand Up @@ -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");
Expand All @@ -1314,6 +1318,7 @@ export function createAuthRouter(deps: {
address: record.address,
expires_at: newRefreshExpiresAt,
revoked: false,
jti: crypto.randomUUID(),
},
});

Expand Down Expand Up @@ -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(() => {});
}

Expand Down Expand Up @@ -1428,4 +1433,4 @@ export function createAuthRouter(deps: {
return r;
}

export default router;
export default router;
115 changes: 115 additions & 0 deletions contracts/reputation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -80,13 +87,25 @@ 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;

#[contractimpl]
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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -547,6 +576,92 @@ impl ReputationContract {
Ok(())
}

fn compute_recovery_towards_default(env: &Env, score: i32) -> Result<i32, ReputationError> {
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<i32, ReputationError> {
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<Address, ReputationError> {
Self::bump_instance_ttl(&env);
Expand Down
78 changes: 76 additions & 2 deletions contracts/reputation/src/test.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
Loading