Skip to content
Open
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
5 changes: 5 additions & 0 deletions apps/web/lib/reputation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ReputationMetrics {
reviews: number;
starRating: number;
averageStars: number;
badgeLevel?: number;
}

export interface ReputationViewMetrics {
Expand All @@ -42,6 +43,7 @@ interface ContractReputationScore {
total_jobs: number | string | bigint;
total_points: number | string | bigint;
reviews: number | string | bigint;
badge_level?: number | string | bigint;
}

interface ContractReputationView {
Expand All @@ -66,6 +68,7 @@ function fallbackMetrics(): ReputationMetrics {
reviews: 0,
starRating: toStarRating(scoreBps),
averageStars: 2.5,
badgeLevel: 0,
};
}

Expand All @@ -82,6 +85,7 @@ function metricsFromScore(score: ContractReputationScore): ReputationMetrics {
const totalPoints = normalizeNumber(score.total_points);
const reviews = normalizeNumber(score.reviews);
const averageStars = reviews > 0 ? totalPoints / reviews : toStarRating(scoreBps);
const badgeLevel = normalizeNumber(score.badge_level);

return {
scoreBps,
Expand All @@ -90,6 +94,7 @@ function metricsFromScore(score: ContractReputationScore): ReputationMetrics {
reviews,
starRating: toStarRating(scoreBps),
averageStars,
badgeLevel,
};
}

Expand Down
205 changes: 196 additions & 9 deletions contracts/reputation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ pub struct ReputationScore {
pub total_jobs: u32,
pub total_points: i128,
pub reviews: u32,
/// Active badge level
pub badge_level: u32,
pub average_rating_bps: i32,
pub badge_level: u32,
pub blacklisted: bool,
Expand All @@ -66,6 +68,7 @@ pub enum DataKey {
JobRegistry,
AuthorizedUpdater,
Reviewed(u64, Address),
AuthorizedContract(Address),
}

#[contracterror]
Expand Down Expand Up @@ -272,11 +275,11 @@ impl ReputationContract {
fn badge_level(metrics: &RoleMetrics, is_blacklisted: bool) -> u32 {
if is_blacklisted {
0
} else if metrics.completed_jobs >= 5 && metrics.score >= 9_500 {
} else if metrics.completed_jobs >= 15 && metrics.score >= 9_000 {
3
} else if metrics.completed_jobs >= 3 && metrics.score >= 8_500 {
} else if metrics.completed_jobs >= 7 && metrics.score >= 8_000 {
2
} else if metrics.completed_jobs >= 1 && metrics.score >= 7_000 {
} else if metrics.completed_jobs >= 3 && metrics.score >= 6_000 {
1
} else {
0
Expand Down Expand Up @@ -365,6 +368,49 @@ impl ReputationContract {
Self::bump_instance_ttl(&env);
}

/// Authorize a contract address (admin only)
pub fn authorize_contract(env: Env, admin: Address, contract: Address) {
admin.require_auth();
let configured_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("not initialized");
assert!(admin == configured_admin, "only admin can authorize contracts");

env.storage()
.instance()
.set(&DataKey::AuthorizedContract(contract), &true);
Self::bump_instance_ttl(&env);
}

/// Deauthorize a contract address (admin only)
pub fn deauthorize_contract(env: Env, admin: Address, contract: Address) {
admin.require_auth();
let configured_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("not initialized");
assert!(admin == configured_admin, "only admin can deauthorize contracts");

env.storage()
.instance()
.remove(&DataKey::AuthorizedContract(contract));
Self::bump_instance_ttl(&env);
}

/// Check if a contract is authorized
pub fn is_contract_authorized(env: Env, contract: Address) -> bool {
Self::bump_instance_ttl(&env);
env.storage()
.instance()
.get(&DataKey::AuthorizedContract(contract))
.unwrap_or(false)
}

/// Submit a rating for a target address tied to a Job ID. Caller must be the client or freelancer
/// on the job, and the job must be Completed.
pub fn submit_rating(env: Env, caller: Address, job_id: u64, target: Address, score: u32) {
caller.require_auth();
if !(1u32..=5u32).contains(&score) {
Expand Down Expand Up @@ -464,6 +510,8 @@ impl ReputationContract {
Self::bump_instance_ttl(&env);
}

/// Update reputation after a completed job. `delta` in basis points.
/// Score is clamped to [0, 10000]. Only callable by admin or authorized contract address.
pub fn update_score(env: Env, caller_contract: Address, address: Address, role: Role, delta: i32) {
Self::require_authorized_contract(&env, &caller_contract);

Expand Down Expand Up @@ -496,6 +544,7 @@ impl ReputationContract {
Self::bump_instance_ttl(&env);
}

/// Slash address for fraud / abandonment — reduces score by 20%. Only callable by admin or authorized contract.
pub fn slash(env: Env, caller_contract: Address, address: Address, role: Role, _reason: Symbol) {
Self::require_authorized_contract(&env, &caller_contract);

Expand Down Expand Up @@ -573,6 +622,17 @@ impl ReputationContract {
Self::score_from_profile(&address, role, &profile)
}

/// Get active badge level
pub fn get_badge_level(env: Env, address: Address, role: Role) -> u32 {
Self::bump_instance_ttl(&env);
let profile = storage::read_profile_or_default(&env, &address);
match role {
Role::Client => profile.client.badge_level,
Role::Freelancer => profile.freelancer.badge_level,
}
}

/// Update profile metadata hash (IPFS CID)
pub fn update_profile_metadata(env: Env, address: Address, metadata_hash: Bytes) {
address.require_auth();
let mut profile = storage::read_profile_or_default(&env, &address);
Expand All @@ -586,6 +646,8 @@ impl ReputationContract {
storage::read_profile(&env, &address).and_then(|profile| profile.metadata_hash)
}

/// Frontend-friendly aggregate metrics for public profile pages.
/// Returns: [score_bps, total_jobs, total_points, reviews, badge_level]
pub fn get_public_metrics(env: Env, address: Address, role_name: Symbol) -> Vec<i128> {
let role = if role_name == Symbol::new(&env, "client") {
Role::Client
Expand Down Expand Up @@ -621,6 +683,8 @@ impl ReputationContract {
}
}



#[cfg(test)]
mod test {
use super::*;
Expand Down Expand Up @@ -770,7 +834,7 @@ mod test {

let score = client.get_score(&freelancer, &Role::Freelancer);
assert_eq!(score.score, 8_000);
assert_eq!(score.badge_level, 1);
assert_eq!(score.badge_level, 0);
}

#[test]
Expand All @@ -796,19 +860,19 @@ mod test {

client.submit_rating(&client_one, &11, &freelancer, &5);
let after_first = client.get_public_metrics(&freelancer, &Symbol::new(&env, "freelancer"));
assert_eq!(after_first.get(4), Some(1));
assert_eq!(after_first.get(4), Some(0));

client.submit_rating(&client_two, &12, &freelancer, &5);
let after_second = client.get_public_metrics(&freelancer, &Symbol::new(&env, "freelancer"));
assert_eq!(after_second.get(4), Some(1));
assert_eq!(after_second.get(4), Some(0));

client.submit_rating(&client_three, &13, &freelancer, &5);
let after_third = client.get_public_metrics(&freelancer, &Symbol::new(&env, "freelancer"));
assert_eq!(after_third.get(4), Some(2));
assert_eq!(after_third.get(4), Some(1));
assert_eq!(after_third.get(5), Some(10_000));

let score = client.get_score(&freelancer, &Role::Freelancer);
assert_eq!(score.badge_level, 2);
assert_eq!(score.badge_level, 1);
assert_eq!(score.total_jobs, 3);
}

Expand Down Expand Up @@ -851,6 +915,7 @@ mod test {
assert!(client.is_blacklisted(&freelancer));
}


#[test]
#[should_panic(expected = "Error(Contract, #3)")]
fn test_get_public_metrics_rejects_unknown_role() {
Expand Down Expand Up @@ -889,7 +954,7 @@ mod test {
assert_eq!(freelancer_score.total_points, 5);
assert_eq!(freelancer_score.reviews, 1);
assert_eq!(freelancer_score.average_rating_bps, 10_000);
assert_eq!(freelancer_score.badge_level, 1);
assert_eq!(freelancer_score.badge_level, 0);

client.submit_rating(&caller_two, &8, &target, &4);
let second_freelancer_score = client.get_score(&target, &Role::Freelancer);
Expand Down Expand Up @@ -970,4 +1035,126 @@ mod test {
let wasm_hash = BytesN::from_array(&env, &[0; 32]);
client.upgrade(&attacker, &wasm_hash);
}

#[test]
fn test_empty_account_load_save() {
let env = Env::default();
let address = Address::generate(&env);
let contract_id = env.register_contract(None, ReputationContract);
let client = ReputationContractClient::new(&env, &contract_id);

// Fetching score for empty account should not panic and return defaults
let score = client.get_score(&address, &Role::Freelancer);
assert_eq!(score.score, 5000);
assert_eq!(score.badge_level, 0);

let level = client.get_badge_level(&address, &Role::Freelancer);
assert_eq!(level, 0);
}

#[test]
fn test_badge_upgrades() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let address = Address::generate(&env);
let contract_id = env.register_contract(None, ReputationContract);
let client = ReputationContractClient::new(&env, &contract_id);

client.initialize(&admin);
client.set_authorized_contract(&admin, &admin);

// Initially level 0
assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 0);

// Level 1: score >= 6000 and completed_jobs >= 3
// First job: score 5500
client.update_score(&admin, &address, &Role::Freelancer, &500);
assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 0);

// Second job: score 6000, total_jobs = 2
client.update_score(&admin, &address, &Role::Freelancer, &500);
assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 0);

// Third job: score 6500, total_jobs = 3 -> Should upgrade to level 1!
client.update_score(&admin, &address, &Role::Freelancer, &500);
assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 1);

// Check public metrics
let metrics = client.get_public_metrics(&address, &soroban_sdk::Symbol::new(&env, "freelancer"));
assert_eq!(metrics.get(0).unwrap(), 6500);
assert_eq!(metrics.get(1).unwrap(), 3);
assert_eq!(metrics.get(4).unwrap(), 1);
}

#[test]
fn test_authorized_contract_score_adjustment() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let authorized_contract = Address::generate(&env);
let unauthorized_contract = Address::generate(&env);
let address = Address::generate(&env);

let contract_id = env.register_contract(None, ReputationContract);
let client = ReputationContractClient::new(&env, &contract_id);

client.initialize(&admin);

// Authorize the contract
client.authorize_contract(&admin, &authorized_contract);
assert!(client.is_contract_authorized(&authorized_contract));
assert!(!client.is_contract_authorized(&unauthorized_contract));

// Authorized contract adjusts score
client.update_score(&authorized_contract, &address, &Role::Freelancer, &100);
let score = client.get_score(&address, &Role::Freelancer);
assert_eq!(score.score, 5100);

// Unauthorized contract attempt to adjust score should panic
let res = client.try_update_score(&unauthorized_contract, &address, &Role::Freelancer, &100);
assert!(res.is_err());

// Deauthorize
client.deauthorize_contract(&admin, &authorized_contract);
assert!(!client.is_contract_authorized(&authorized_contract));

// Now it should fail
let res2 = client.try_update_score(&authorized_contract, &address, &Role::Freelancer, &100);
assert!(res2.is_err());
}

#[test]
fn test_arbitrary_direct_review_rejected() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let client_addr = Address::generate(&env);
let freelancer_addr = Address::generate(&env);
let attacker = Address::generate(&env);

let contract_id = env.register_contract(None, ReputationContract);
let client = ReputationContractClient::new(&env, &contract_id);
client.initialize(&admin);

let mock_id = env.register_contract(None, MockJobRegistry);
client.set_job_registry(&admin, &mock_id);

let job = JobRecord {
client: client_addr.clone(),
freelancer: Some(freelancer_addr.clone()),
metadata_hash: Bytes::from_slice(&env, b"QmJob"),
budget_stroops: 10,
status: JobStatus::Completed,
};
let mock_client = MockJobRegistryClient::new(&env, &mock_id);
mock_client.set_job(&7u64, &job);

// Attacker who is not part of the job tries to rate the freelancer
let res = client.try_submit_rating(&attacker, &7u64, &freelancer_addr, &5u32);
assert!(res.is_err()); // should reject with unauthorized
}
}
Loading