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
220 changes: 190 additions & 30 deletions contracts/reputation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ pub enum DataKey {
pub enum ReputationError {
NotInitialized = 1,
Unauthorized = 2,
InvalidInput = 3,
JobNotCompleted = 4,
NotJobParticipant = 5,
AlreadyReviewed = 6,
ContractStateError = 7,
}

#[contracttype]
Expand All @@ -72,6 +77,32 @@ pub struct ContractUpgradedEvent {
pub upgraded_at: u64,
}

#[contracttype]
#[derive(Clone)]
pub struct ReputationUpdatedEvent {
pub job_id: u64,
pub caller: Address,
pub target: Address,
pub role: Role,
pub rating: u32,
pub new_score: i32,
pub total_jobs: u32,
pub total_points: i32,
pub reviews: u32,
pub updated_at: u64,
}

#[contracttype]
#[derive(Clone)]
pub struct ScoreAdjustedEvent {
pub address: Address,
pub role: Role,
pub delta: i32,
pub new_score: i32,
pub total_jobs: u32,
pub adjusted_at: u64,
}

#[contract]
pub struct ReputationContract;

Expand All @@ -88,6 +119,10 @@ impl ReputationContract {
.extend_ttl(Self::INSTANCE_TTL_THRESHOLD, Self::INSTANCE_TTL_EXTEND_TO);
}

fn score_from_rating(score: u32) -> i32 {
(score as i32).saturating_mul(2_000)
}

/// Upgrades the current contract WASM. Only callable by admin.
pub fn upgrade(
env: Env,
Expand Down Expand Up @@ -149,20 +184,17 @@ impl ReputationContract {
/// 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 must authorize
caller.require_auth();
if !(1u32..=5u32).contains(&score) {
soroban_sdk::panic_with_error!(&env, ReputationError::InvalidInput);
}

// validate score in 1..=5
assert!((1u32..=5u32).contains(&score), "score out of range");

// ensure job registry is configured
let registry_addr: Address = env
.storage()
.instance()
.get(&DataKey::JobRegistry)
.expect("job registry not set");

// call JobRegistry.get_job(job_id) and decode into local JobRecord
let get_sym = Symbol::new(&env, "get_job");
let args = soroban_sdk::vec![&env, job_id.into_val(&env)];
let job: JobRecord = env
Expand All @@ -173,37 +205,53 @@ impl ReputationContract {
)
.unwrap();

// verify job is completed (ratings only allowed after completion)
assert!(job.status == JobStatus::Completed, "job not completed");
if job.status != JobStatus::Completed {
soroban_sdk::panic_with_error!(&env, ReputationError::JobNotCompleted);
}

// verify caller is participant
let caller_addr = caller.clone();
let is_client = caller_addr == job.client;
let is_freelancer = match job.freelancer.clone() {
Some(f) => caller_addr == f,
None => false,
};
assert!(is_client || is_freelancer, "unauthorized to rate");
if !(is_client || is_freelancer) {
soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized);
}

// prevent double review
let reviewed_key = DataKey::Reviewed(job_id, caller.clone());
assert!(
!env.storage().persistent().has(&reviewed_key),
"already reviewed"
);
if env.storage().persistent().has(&reviewed_key) {
soroban_sdk::panic_with_error!(&env, ReputationError::AlreadyReviewed);
}

// update reputation aggregates for target
let mut profile = storage::read_profile_or_default(&env, &target);

// We assume target is a freelancer for now in submit_rating
// In a more complex system, we might need to know which role was rated.
profile.freelancer_points = profile.freelancer_points.saturating_add(score as i32);
profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1);

// compute new averaged score in basis points: avg = total_points / jobs, scaled
let avg = profile.freelancer_points / (profile.freelancer_jobs as i32);
let bps = avg.saturating_mul(2000); // 1->2000 ... 5->10000
profile.freelancer_score = Self::clamp_score(bps);
let (role, total_points, total_jobs, new_score) = if target == job.client {
profile.client_points = profile.client_points.saturating_add(score as i32);
profile.client_jobs = profile.client_jobs.saturating_add(1);
let avg = profile.client_points / (profile.client_jobs as i32);
let bps = Self::score_from_rating(avg as u32);
profile.client_score = Self::clamp_score(bps);
(
Role::Client,
profile.client_points,
profile.client_jobs,
profile.client_score,
)
} else if job.freelancer.as_ref() == Some(&target) {
profile.freelancer_points = profile.freelancer_points.saturating_add(score as i32);
profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1);
let avg = profile.freelancer_points / (profile.freelancer_jobs as i32);
let bps = Self::score_from_rating(avg as u32);
profile.freelancer_score = Self::clamp_score(bps);
(
Role::Freelancer,
profile.freelancer_points,
profile.freelancer_jobs,
profile.freelancer_score,
)
} else {
soroban_sdk::panic_with_error!(&env, ReputationError::NotJobParticipant);
};

storage::write_profile(&env, &target, &profile);
env.storage().persistent().set(&reviewed_key, &true);
Expand All @@ -212,6 +260,21 @@ impl ReputationContract {
Self::PERSISTENT_TTL_THRESHOLD,
Self::PERSISTENT_TTL_EXTEND_TO,
);
env.events().publish(
("reputation", "ReputationUpdated"),
ReputationUpdatedEvent {
job_id,
caller,
target,
role,
rating: score,
new_score,
total_jobs,
total_points,
reviews: total_jobs,
updated_at: env.ledger().timestamp(),
},
);
Self::bump_instance_ttl(&env);
}

Expand All @@ -226,20 +289,33 @@ impl ReputationContract {
admin.require_auth();

let mut profile = storage::read_profile_or_default(&env, &address);
match role {
let (new_score, total_jobs) = match role {
Role::Client => {
profile.client_score =
Self::clamp_score(profile.client_score.saturating_add(delta));
profile.client_jobs = profile.client_jobs.saturating_add(1);
(profile.client_score, profile.client_jobs)
}
Role::Freelancer => {
profile.freelancer_score =
Self::clamp_score(profile.freelancer_score.saturating_add(delta));
profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1);
(profile.freelancer_score, profile.freelancer_jobs)
}
}
};

storage::write_profile(&env, &address, &profile);
env.events().publish(
("reputation", "ScoreAdjusted"),
ScoreAdjustedEvent {
address,
role,
delta,
new_score,
total_jobs,
adjusted_at: env.ledger().timestamp(),
},
);
Self::bump_instance_ttl(&env);
}

Expand All @@ -253,17 +329,30 @@ impl ReputationContract {
admin.require_auth();

let mut profile = storage::read_profile_or_default(&env, &address);
match role {
let (new_score, total_jobs) = match role {
Role::Client => {
profile.client_score = Self::clamp_score(profile.client_score.saturating_sub(2000));
(profile.client_score, profile.client_jobs)
}
Role::Freelancer => {
profile.freelancer_score =
Self::clamp_score(profile.freelancer_score.saturating_sub(2000));
(profile.freelancer_score, profile.freelancer_jobs)
}
}
};

storage::write_profile(&env, &address, &profile);
env.events().publish(
("reputation", "ScoreAdjusted"),
ScoreAdjustedEvent {
address,
role,
delta: -2_000,
new_score,
total_jobs,
adjusted_at: env.ledger().timestamp(),
},
);
Self::bump_instance_ttl(&env);
}

Expand Down Expand Up @@ -337,6 +426,28 @@ mod test {
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{Address, BytesN, Env};

#[contract]
pub struct MockJobRegistry;

#[contracttype]
enum MockKey {
Job(u64),
}

#[contractimpl]
impl MockJobRegistry {
pub fn set_job(env: Env, job_id: u64, job: JobRecord) {
env.storage().persistent().set(&MockKey::Job(job_id), &job);
}

pub fn get_job(env: Env, _job_id: u64) -> Result<JobRecord, soroban_sdk::Error> {
env.storage()
.persistent()
.get(&MockKey::Job(_job_id))
.ok_or_else(|| soroban_sdk::Error::from_contract_error(1))
}
}

#[test]
fn test_initial_score() {
let env = Env::default();
Expand Down Expand Up @@ -428,6 +539,55 @@ mod test {
assert_eq!(c_score.score, 5500);
}

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

let admin = Address::generate(&env);
let caller = Address::generate(&env);
let target = Address::generate(&env);
let freelancer = Address::generate(&env);
let caller2 = 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: caller.clone(),
freelancer: Some(freelancer.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);
let other_job = JobRecord {
client: caller2.clone(),
freelancer: Some(target.clone()),
metadata_hash: Bytes::from_slice(&env, b"QmJob2"),
budget_stroops: 10,
status: JobStatus::Completed,
};
mock_client.set_job(&8u64, &other_job);

client.submit_rating(&caller, &7u64, &freelancer, &5u32);
let client_score = client.get_score(&freelancer, &Role::Freelancer);
assert_eq!(client_score.score, 10_000);
assert_eq!(client_score.total_jobs, 1);
assert_eq!(client_score.total_points, 5);

client.submit_rating(&caller2, &8u64, &target, &4u32);
let freelancer_score = client.get_score(&target, &Role::Freelancer);
assert_eq!(freelancer_score.score, 8_000);
assert_eq!(freelancer_score.total_jobs, 1);
assert_eq!(freelancer_score.total_points, 4);
assert_eq!(freelancer_score.reviews, 1);
}

#[test]
#[should_panic(expected = "Error(Contract, #2)")]
fn test_upgrade_requires_admin() {
Expand Down
38 changes: 38 additions & 0 deletions docs/contracts/reputation_score_update_logic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Reputation Score Update Logic

## Overview

This document describes the Lance reputation contract update flow for on-chain rating and administrative score adjustments.

## Behavior

- Ratings are only accepted after a job reaches `Completed`.
- Only job participants may rate a target address.
- A participant can rate each job only once.
- Ratings update the correct role bucket:
- `Client` when the target is the job client.
- `Freelancer` when the target is the assigned freelancer.
- Administrative score updates and slashing emit reputation events for off-chain auditability.

## Validation

- Rating input is restricted to the `1..=5` range.
- Non-participants are rejected with a dedicated Soroban error code.
- Duplicate reviews are rejected with a dedicated Soroban error code.
- Score values are clamped to the `0..=10000` basis-point range.

## Logging and Auditability

The contract emits state-changing events for:

- contract upgrades
- score adjustments
- rating submissions

These events are intended for backend indexing and debugging in Testnet and production environments.

## Files

- `contracts/reputation/src/lib.rs`
- `contracts/reputation/src/storage.rs`
- `contracts/reputation/src/profile.rs`
Loading