diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs
index 56cc93de..6732ffe1 100644
--- a/contracts/escrow/src/lib.rs
+++ b/contracts/escrow/src/lib.rs
@@ -101,6 +101,8 @@ pub enum DataKey {
Locked,
MultisigConfig(u64), // Per-job multisig configuration
UpgradeAdmin,
+ Treasury,
+ FeeBps,
}
#[contracttype]
@@ -150,11 +152,18 @@ pub enum EscrowError {
UpgradeAdminNotSet = 18,
ArithmeticOverflow = 19,
DisputeResolutionExpired = 20,
+ MaxMilestonesExceeded = 21,
+ FeeTooHigh = 22,
+ NothingToSweep = 23,
}
/// Maximum platform fee, in basis points (100% = 10_000 bps).
pub const MAX_FEE_BPS: u32 = 10_000;
+/// Maximum number of milestones allowed per escrow job.
+/// Prevents unbounded storage growth and keeps WASM execution within block limits.
+pub const MAX_MILESTONES: u32 = 12;
+
#[contracttype]
#[derive(Clone)]
pub struct DisputeRaisedEvent {
@@ -257,6 +266,41 @@ pub struct DisputeExpiredEvent {
pub expired_at: u64,
}
+#[contracttype]
+#[derive(Clone)]
+pub struct FeeConfigUpdatedEvent {
+ pub treasury: Option
,
+ pub fee_bps: u32,
+ pub updated_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct LockupUpdatedEvent {
+ pub job_id: u64,
+ pub expires_at: u64,
+ pub updated_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct EmergencySweepEvent {
+ pub job_id: u64,
+ pub admin: Address,
+ pub rescue_address: Address,
+ pub amount: i128,
+ pub swept_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct MilestonesAmendedEvent {
+ pub job_id: u64,
+ pub milestone_count: u32,
+ pub remaining_amount: i128,
+ pub amended_at: u64,
+}
+
fn enter_reentrancy_guard(env: &Env) {
if env.storage().instance().has(&DataKey::Locked) {
panic_with_error!(env, EscrowError::ReentrancyDetected);
@@ -606,6 +650,11 @@ impl EscrowContract {
return Err(EscrowError::InvalidInput);
}
+ // Enforce maximum milestone partition count
+ if job.milestones.len() >= MAX_MILESTONES {
+ return Err(EscrowError::MaxMilestonesExceeded);
+ }
+
job.milestones.push_back(Milestone {
amount,
status: MilestoneStatus::Pending,
@@ -759,6 +808,32 @@ impl EscrowContract {
Ok(())
}
+ fn payout_with_fee(env: &Env, _job_id: u64, job: &EscrowJob, amount: i128) {
+ let token_client = token::Client::new(env, &job.token);
+ let fee_bps = Self::get_fee_bps(env.clone());
+ let mut payout_amount = amount;
+
+ if fee_bps > 0 {
+ if let Some(treasury) = Self::get_treasury(env.clone()) {
+ let fee_amount = amount
+ .checked_mul(fee_bps as i128)
+ .unwrap_or(0)
+ .checked_div(MAX_FEE_BPS as i128)
+ .unwrap_or(0);
+
+ payout_amount = amount.checked_sub(fee_amount).unwrap_or(amount);
+
+ if fee_amount > 0 {
+ token_client.transfer(&env.current_contract_address(), &treasury, &fee_amount);
+ }
+ }
+ }
+
+ if payout_amount > 0 {
+ token_client.transfer(&env.current_contract_address(), &job.freelancer, &payout_amount);
+ }
+ }
+
/// Happy-path release for an explicit milestone index (0-based).
/// Only the client may call this to release the funds for a specific milestone.
pub fn release_funds(
@@ -851,7 +926,9 @@ impl EscrowContract {
let next_status = EscrowStatus::Disputed;
job.status.validate_transition(&next_status)?;
job.status = next_status;
- job.dispute_deadline = env.ledger().timestamp() + Self::DISPUTE_RESOLUTION_WINDOW;
+ job.dispute_deadline = env.ledger().timestamp()
+ .checked_add(Self::DISPUTE_RESOLUTION_WINDOW)
+ .ok_or(EscrowError::ArithmeticError)?;
log!(&env, "open_dispute: job {}", job_id);
env.storage().persistent().set(&key, &job);
Self::bump_job_ttl(&env, &key);
@@ -914,7 +991,9 @@ impl EscrowContract {
let next_status = EscrowStatus::Disputed;
job.status.validate_transition(&next_status)?;
job.status = next_status;
- job.dispute_deadline = now + Self::DISPUTE_RESOLUTION_WINDOW;
+ job.dispute_deadline = now
+ .checked_add(Self::DISPUTE_RESOLUTION_WINDOW)
+ .ok_or(EscrowError::ArithmeticError)?;
log!(&env, "raise_dispute: job {}", job_id);
env.storage().persistent().set(&key, &job);
Self::bump_job_ttl(&env, &key);
@@ -925,7 +1004,7 @@ impl EscrowContract {
let mut released_count = 0u32;
for m in job.milestones.iter() {
if m.status == MilestoneStatus::Released {
- released_count += 1;
+ released_count = released_count.checked_add(1).ok_or(EscrowError::ArithmeticError)?;
}
}
@@ -1127,6 +1206,54 @@ impl EscrowContract {
Ok(())
}
+ /// Recoups storage space by removing the job from persistent storage.
+ /// Can only be invoked by the client or freelancer, and only if the job
+ /// is in a terminal state (Completed, Refunded, or Resolved).
+ pub fn cleanup_job(env: Env, job_id: u64, caller: Address) -> Result<(), EscrowError> {
+ caller.require_auth();
+
+ let key = DataKey::Job(job_id);
+ let job: EscrowJob = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(EscrowError::JobNotFound)?;
+
+ if !(caller == job.client || caller == job.freelancer) {
+ return Err(EscrowError::Unauthorized);
+ }
+
+ if !(job.status == EscrowStatus::Completed
+ || job.status == EscrowStatus::Refunded
+ || job.status == EscrowStatus::Resolved)
+ {
+ return Err(EscrowError::InvalidState);
+ }
+
+ let remaining = job
+ .total_amount
+ .checked_sub(job.released_amount)
+ .ok_or(EscrowError::ArithmeticError)?;
+ if remaining > 0 {
+ return Err(EscrowError::InvalidState); // Should not happen in terminal state
+ }
+
+ // De-allocate
+ env.storage().persistent().remove(&key);
+
+ let ms_key = DataKey::MultisigConfig(job_id);
+ if env.storage().persistent().has(&ms_key) {
+ env.storage().persistent().remove(&ms_key);
+ }
+
+ env.events().publish(
+ ("escrow", "CleanupJob"),
+ (job_id, caller, env.ledger().timestamp()),
+ );
+
+ Ok(())
+ }
+
pub fn get_job(env: Env, job_id: u64) -> Result {
let key = DataKey::Job(job_id);
let job: EscrowJob = env
@@ -1450,11 +1577,12 @@ impl EscrowContract {
treasury: Address,
fee_bps: u32,
) -> Result<(), EscrowError> {
- let admin: Address = env
+ let config: ContractConfig = env
.storage()
.instance()
- .get(&DataKey::Admin)
+ .get(&DataKey::Config)
.ok_or(EscrowError::NotInitialized)?;
+ let admin = config.admin;
admin.require_auth();
if fee_bps > MAX_FEE_BPS {
@@ -1479,7 +1607,10 @@ impl EscrowContract {
/// Returns the active platform fee in basis points (0 when unset).
pub fn get_fee_bps(env: Env) -> u32 {
- Self::fee_bps(&env)
+ env.storage()
+ .instance()
+ .get(&DataKey::FeeBps)
+ .unwrap_or(0)
}
/// Returns the configured treasury address, if any.
@@ -1560,11 +1691,12 @@ impl EscrowContract {
job_id: u64,
rescue_address: Address,
) -> Result<(), EscrowError> {
- let admin: Address = env
+ let config: ContractConfig = env
.storage()
.instance()
- .get(&DataKey::Admin)
+ .get(&DataKey::Config)
.ok_or(EscrowError::NotInitialized)?;
+ let admin = config.admin;
admin.require_auth();
let key = DataKey::Job(job_id);
@@ -3145,4 +3277,641 @@ mod test {
assert_eq!(config.required_signatures, 2);
assert_eq!(config.signers.len(), 2);
}
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // SC-ESC-016: Enforce Limit Restrictions on Maximum Milestone Partition Counts
+ // ─────────────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_add_milestones_up_to_limit_succeeds() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+
+ // Adding exactly MAX_MILESTONES (12) should succeed
+ for _ in 0..MAX_MILESTONES {
+ cc.add_milestone(&1u64, &100i128);
+ }
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.milestones.len(), MAX_MILESTONES);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #21)")]
+ fn test_add_milestones_limit_exceeded() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+
+ // Fill up to the max
+ for _ in 0..MAX_MILESTONES {
+ cc.add_milestone(&1u64, &100i128);
+ }
+
+ // The 13th milestone must fail with MaxMilestonesExceeded (#21)
+ cc.add_milestone(&1u64, &100i128);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // SC-ESC-013: Verify State Machine Integrity across Multi-Milestone Gigs
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Full lifecycle: Setup → Funded → WIP → WIP → Completed.
+ /// Validates status, released_amount, remaining balance, and milestone
+ /// statuses at every intermediate step.
+ #[test]
+ fn test_multi_milestone_full_lifecycle_state_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+
+ // ── Setup phase ──
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Setup);
+ assert_eq!(job.milestones.len(), 0);
+ assert_eq!(job.total_amount, 0);
+ assert_eq!(job.released_amount, 0);
+
+ cc.add_milestone(&1u64, &1000i128);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &4000i128);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.milestones.len(), 4);
+ assert_eq!(job.status, EscrowStatus::Setup);
+
+ // ── Deposit → Funded ──
+ cc.deposit(&1u64, &10_000i128);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Funded);
+ assert_eq!(job.total_amount, 10_000);
+ assert_eq!(job.released_amount, 0);
+ assert_eq!(cc.get_escrow_balance(&1u64), 10_000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 10_000);
+ assert_eq!(tc.balance(&contract_id), 10_000);
+
+ // Verify all milestones are Pending
+ let statuses = cc.get_milestone_status(&1u64);
+ for i in 0..4u32 {
+ assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Pending);
+ }
+
+ // ── Release milestone 0 → WorkInProgress ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 1000);
+ assert_eq!(cc.get_escrow_balance(&1u64), 9000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 9000);
+ assert_eq!(tc.balance(&freelancer), 1000);
+
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(3).unwrap(), MilestoneStatus::Pending);
+
+ // ── Release milestone 1 → still WorkInProgress ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 3000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 7000);
+ assert_eq!(tc.balance(&freelancer), 3000);
+
+ // ── Release milestone 2 → still WorkInProgress ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 6000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 4000);
+ assert_eq!(tc.balance(&freelancer), 6000);
+
+ // ── Release milestone 3 → Completed ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 0);
+ assert_eq!(tc.balance(&freelancer), 10_000);
+ assert_eq!(tc.balance(&contract_id), 0);
+
+ // All milestones must be Released
+ let statuses = cc.get_milestone_status(&1u64);
+ for i in 0..4u32 {
+ assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Released);
+ }
+ }
+
+ /// Out-of-order release_funds (explicit index) with state checks at each step.
+ #[test]
+ fn test_out_of_order_release_funds_state_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &1500i128);
+ cc.add_milestone(&1u64, &2500i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release milestone index 3 first (out of order)
+ cc.release_funds(&1u64, &client, &3u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 3000);
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(3).unwrap(), MilestoneStatus::Released);
+
+ // Release milestone index 1
+ cc.release_funds(&1u64, &client, &1u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 5500);
+ assert_eq!(tc.balance(&freelancer), 5500);
+
+ // Release milestone index 0
+ cc.release_funds(&1u64, &client, &0u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 7000);
+
+ // Release milestone index 2 — final → Completed
+ cc.release_funds(&1u64, &client, &2u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(tc.balance(&freelancer), 10_000);
+ assert_eq!(tc.balance(&contract_id), 0);
+
+ // All Released
+ let statuses = cc.get_milestone_status(&1u64);
+ for i in 0..4u32 {
+ assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Released);
+ }
+ }
+
+ /// Dispute raised mid-WIP after partial milestone releases; verifies balance
+ /// accounting is correct through dispute resolution.
+ #[test]
+ fn test_dispute_mid_wip_partial_milestones_balance_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first two milestones
+ cc.release_milestone(&1u64, &client); // 2000
+ cc.release_milestone(&1u64, &client); // 3000
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 5000);
+ assert_eq!(tc.balance(&freelancer), 5000);
+
+ // Raise dispute with 5000 remaining
+ cc.raise_dispute(&1u64, &freelancer);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Disputed);
+ assert_eq!(cc.get_remaining_balance(&1u64), 5000);
+
+ // Milestone statuses: first two Released, third Pending
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending);
+
+ // Resolve: 60/40 split of remaining 5000
+ cc.resolve_dispute(&1u64, &3000i128, &2000i128);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Resolved);
+ // Total released: 5000 (milestones) + 5000 (resolution) = 10000
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(tc.balance(&freelancer), 8000); // 5000 + 3000
+ assert_eq!(tc.balance(&client), 92_000); // 100000 - 10000 + 2000
+ }
+
+ /// Cancel brief in WorkInProgress state refunds only the unreleased portion.
+ #[test]
+ fn test_cancel_brief_wip_refunds_remaining_only() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first milestone → WIP
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(tc.balance(&freelancer), 2000);
+ assert_eq!(tc.balance(&client), 90_000);
+
+ // Cancel brief — should refund remaining 8000 to client
+ cc.cancel_brief(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Refunded);
+ assert_eq!(job.released_amount, job.total_amount); // fully accounted
+ assert_eq!(tc.balance(&client), 98_000); // 90000 + 8000
+ assert_eq!(tc.balance(&freelancer), 2000); // unchanged
+ assert_eq!(tc.balance(&contract_id), 0); // fully drained
+ }
+
+ /// Amend milestones mid-WIP, then release amended milestones to Completed.
+ /// Validates the state machine remains coherent through amendment.
+ #[test]
+ fn test_amend_milestones_then_complete_state_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first milestone
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 2000);
+
+ // Amend remaining milestones: 3000+5000=8000 → 4000+4000=8000
+ let new_amounts = soroban_sdk::vec![&env, 4000i128, 4000i128];
+ cc.amend_milestones(&1u64, &new_amounts);
+
+ // Verify structure: 1 Released + 2 new Pending
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.milestones.len(), 3);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 2000);
+
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending);
+
+ // Release remaining amended milestones
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 6000);
+ assert_eq!(tc.balance(&freelancer), 6000);
+
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(tc.balance(&freelancer), 10_000);
+ assert_eq!(tc.balance(&contract_id), 0);
+ }
+
+ /// Getter consistency: get_escrow_balance and get_remaining_balance match
+ /// across every state transition in a multi-milestone lifecycle.
+ #[test]
+ fn test_getter_consistency_across_transitions() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &4000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Both getters must agree at every step
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 10_000);
+
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 7000);
+
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 4000);
+
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 0);
+
+ // Final state
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ }
+
+ /// Unauthorized callers are blocked from all state-mutating functions
+ /// on a multi-milestone job.
+ #[test]
+ fn test_unauthorized_state_mutations_blocked_multi_milestone() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let rando = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first milestone to enter WIP
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::WorkInProgress);
+
+ // Verify: release_milestone by rando → Unauthorized (#3)
+ let result = cc.try_release_milestone(&1u64, &rando);
+ assert!(result.is_err());
+
+ // Verify: release_funds by freelancer → Unauthorized (#3)
+ let result = cc.try_release_funds(&1u64, &freelancer, &1u32);
+ assert!(result.is_err());
+
+ // Verify: refund by freelancer → Unauthorized (#3)
+ let result = cc.try_refund(&1u64, &freelancer);
+ assert!(result.is_err());
+
+ // Verify: cancel_brief by rando → Unauthorized (#3)
+ let result = cc.try_cancel_brief(&1u64, &rando);
+ assert!(result.is_err());
+
+ // State is still WIP — no mutation occurred
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 5000);
+ }
+
+ /// Invalid state transitions are blocked on multi-milestone jobs:
+ /// cannot release after dispute, cannot dispute after completion.
+ #[test]
+ fn test_invalid_transitions_blocked_multi_milestone() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+
+ // ── Test 1: Cannot release after dispute ──
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &7000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ cc.release_milestone(&1u64, &client); // WIP
+ cc.raise_dispute(&1u64, &client); // Disputed
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Disputed);
+
+ // release_milestone must fail in Disputed state
+ let result = cc.try_release_milestone(&1u64, &client);
+ assert!(result.is_err());
+
+ // release_funds must fail in Disputed state
+ let result = cc.try_release_funds(&1u64, &client, &1u32);
+ assert!(result.is_err());
+
+ // ── Test 2: Cannot dispute after completion ──
+ cc.create_job(&2u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&2u64, &5000i128);
+ cc.add_milestone(&2u64, &5000i128);
+ cc.deposit(&2u64, &10_000i128);
+
+ cc.release_milestone(&2u64, &client);
+ cc.release_milestone(&2u64, &client);
+ assert_eq!(cc.get_job(&2u64).status, EscrowStatus::Completed);
+
+ // raise_dispute must fail in Completed state
+ let result = cc.try_raise_dispute(&2u64, &client);
+ assert!(result.is_err());
+
+ // open_dispute must fail in Completed state
+ let result = cc.try_open_dispute(&2u64, &client);
+ assert!(result.is_err());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // SC-ESC-019: Dynamic Storage Allocation Recouping (State De-allocation)
+ // ─────────────────────────────────────────────────────────────────────────
+ #[test]
+ fn test_cleanup_job_completed() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &5000i128);
+ cc.release_milestone(&1u64, &client);
+
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Completed);
+
+ // cleanup_job by client
+ cc.cleanup_job(&1u64, &client);
+
+ // verify it is deleted
+ let result = cc.try_get_job(&1u64);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_cleanup_job_invalid_state() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+
+ // Setup state
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Setup);
+ let res = cc.try_cleanup_job(&1u64, &client);
+ assert!(res.is_err());
+
+ cc.deposit(&1u64, &5000i128);
+ // Funded state
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Funded);
+ let res = cc.try_cleanup_job(&1u64, &client);
+ assert!(res.is_err());
+ }
+
+ #[test]
+ fn test_cleanup_job_unauthorized() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let random = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &5000i128);
+ cc.release_milestone(&1u64, &client);
+
+ // Random tries to cleanup
+ let res = cc.try_cleanup_job(&1u64, &random);
+ assert!(res.is_err());
+ }
}
diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs
index 1956aeee..4a5e781a 100644
--- a/contracts/job_registry/src/lib.rs
+++ b/contracts/job_registry/src/lib.rs
@@ -28,6 +28,7 @@ pub enum JobRegistryError {
InvalidExpiration = 15,
JobExpired = 16,
JobNotExpired = 17,
+ InvalidCollateral = 18,
}
#[contracttype]
@@ -39,6 +40,7 @@ pub enum JobStatus {
Completed,
Disputed,
Expired,
+ Defaulted,
}
#[contracttype]
@@ -60,6 +62,7 @@ pub struct JobRecord {
pub struct BidRecord {
pub freelancer: Address,
pub proposal_hash: Bytes,
+ pub collateral_stroops: i128,
}
#[contracttype]
@@ -176,9 +179,18 @@ impl JobRegistryContract {
}
/// Freelancer submits a bid.
- pub fn submit_bid(env: Env, job_id: u64, freelancer: Address, proposal_hash: Bytes) {
+ pub fn submit_bid(
+ env: Env,
+ job_id: u64,
+ freelancer: Address,
+ proposal_hash: Bytes,
+ collateral_stroops: i128,
+ ) {
ensure_initialized(&env);
validate_hash(&env, &proposal_hash);
+ if collateral_stroops < 0 {
+ panic_with_error!(&env, JobRegistryError::InvalidCollateral);
+ }
freelancer.require_auth();
let key = DataKey::Job(job_id);
@@ -215,10 +227,17 @@ impl JobRegistryContract {
bids.push_back(BidRecord {
freelancer: freelancer.clone(),
proposal_hash,
+ collateral_stroops,
});
env.storage().persistent().set(&bids_key, &bids);
- log!(&env, "submit_bid: id {} freelancer {}", job_id, freelancer);
+ log!(
+ &env,
+ "submit_bid: id {} freelancer {} collateral {}",
+ job_id,
+ freelancer,
+ collateral_stroops
+ );
env.events()
.publish((symbol_short!("bid"), job_id), freelancer);
}
@@ -376,6 +395,83 @@ impl JobRegistryContract {
env.events().publish((symbol_short!("dispute"), job_id), ());
}
+ pub fn enforce_default_slashing(env: Env, job_id: u64, client: Address) -> i128 {
+ ensure_initialized(&env);
+ client.require_auth();
+
+ let key = DataKey::Job(job_id);
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ // Strict ownership validation: only the job creator can enforce slashing
+ if client != job.client {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ // Must be in Assigned state to default/slash
+ if job.status != JobStatus::Assigned {
+ panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
+ }
+
+ // Must be expired
+ let now = env.ledger().timestamp();
+ if now < job.expires_at {
+ panic_with_error!(&env, JobRegistryError::JobNotExpired);
+ }
+
+ let freelancer = job.freelancer.clone().unwrap_or_else(|| {
+ panic_with_error!(&env, JobRegistryError::Unauthorized)
+ });
+
+ // Find the accepted freelancer's bid to get their collateral amount
+ let bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Bids(job_id))
+ .unwrap_or(Vec::new(&env));
+
+ let mut collateral_stroops: i128 = 0;
+ let mut found = false;
+ for bid in bids.iter() {
+ if bid.freelancer == freelancer {
+ collateral_stroops = bid.collateral_stroops;
+ found = true;
+ break;
+ }
+ }
+
+ if !found {
+ panic_with_error!(&env, JobRegistryError::BidNotFound);
+ }
+
+ // Checked math operations for penalty slashing (100% of collateral)
+ let penalty_bps: i128 = 10_000;
+ let slashed_amount = collateral_stroops
+ .checked_mul(penalty_bps)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow))
+ .checked_div(10_000)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow));
+
+ // Clean state transition to Defaulted
+ job.status = JobStatus::Defaulted;
+ env.storage().persistent().set(&key, &job);
+
+ log!(
+ &env,
+ "enforce_default_slashing: id {} freelancer {} slashed {}",
+ job_id,
+ freelancer,
+ slashed_amount
+ );
+ env.events()
+ .publish((symbol_short!("slash"), job_id), (freelancer, slashed_amount));
+
+ slashed_amount
+ }
+
pub fn get_job(env: Env, job_id: u64) -> JobRecord {
ensure_initialized(&env);
env.storage()
@@ -602,10 +698,11 @@ mod test {
assert_eq!(job.freelancer, None);
let proposal = Bytes::from_slice(&env, b"QmProposalHash");
- cc.submit_bid(&1u64, &freelancer, &proposal);
+ cc.submit_bid(&1u64, &freelancer, &proposal, &1000i128);
let bids = cc.get_bids(&1u64);
assert_eq!(bids.len(), 1);
+ assert_eq!(bids.get(0).unwrap().collateral_stroops, 1000i128);
cc.accept_bid(&1u64, &client, &freelancer);
let job = cc.get_job(&1u64);
@@ -633,8 +730,8 @@ mod test {
cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
let proposal = Bytes::from_slice(&env, b"QmProposal");
- cc.submit_bid(&1u64, &freelancer, &proposal);
- cc.submit_bid(&1u64, &freelancer, &proposal);
+ cc.submit_bid(&1u64, &freelancer, &proposal, &500i128);
+ cc.submit_bid(&1u64, &freelancer, &proposal, &500i128);
}
#[test]
@@ -660,7 +757,7 @@ mod test {
cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
let proposal = Bytes::from_slice(&env, b"QmProposal");
- cc.submit_bid(&1u64, &freelancer, &proposal);
+ cc.submit_bid(&1u64, &freelancer, &proposal, &0i128);
cc.accept_bid(&1u64, &client, &freelancer);
cc.mark_disputed(&1u64);
@@ -694,7 +791,7 @@ mod test {
env.ledger().set_timestamp(expires_at + 1);
let proposal = Bytes::from_slice(&env, b"QmProposal");
- cc.submit_bid(&1u64, &freelancer, &proposal);
+ cc.submit_bid(&1u64, &freelancer, &proposal, &100i128);
}
#[test]
@@ -708,7 +805,7 @@ mod test {
cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
let proposal = Bytes::from_slice(&env, b"QmProposal");
- cc.submit_bid(&1u64, &freelancer, &proposal);
+ cc.submit_bid(&1u64, &freelancer, &proposal, &200i128);
env.ledger().set_timestamp(expires_at + 1);
cc.accept_bid(&1u64, &client, &freelancer);
@@ -755,4 +852,104 @@ mod test {
cc.get_deliverable(&1u64);
}
+
+ #[test]
+ fn test_enforce_default_slashing_success() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &12345i128);
+
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, JobStatus::Assigned);
+
+ // Advance ledger timestamp to default threshold
+ env.ledger().set_timestamp(expires_at + 1);
+
+ // Client triggers default and gets 100% of collateral slashed
+ let slashed = cc.enforce_default_slashing(&1u64, &client);
+ assert_eq!(slashed, 12345i128);
+
+ let updated_job = cc.get_job(&1u64);
+ assert_eq!(updated_job.status, JobStatus::Defaulted);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_enforce_default_slashing_before_expiration_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &100i128);
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ // Calling enforce default slashing before job expires must fail
+ cc.enforce_default_slashing(&1u64, &client);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_enforce_default_slashing_unauthorized_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &200i128);
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ env.ledger().set_timestamp(expires_at + 1);
+
+ // A third-party address (represented by freelancer here) attempts to default
+ cc.enforce_default_slashing(&1u64, &freelancer);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_enforce_default_slashing_invalid_state_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &300i128);
+
+ env.ledger().set_timestamp(expires_at + 1);
+
+ // The job status is Open (not Assigned). Enforce default slashing should fail.
+ cc.enforce_default_slashing(&1u64, &client);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_submit_bid_negative_collateral_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ // Bid with negative collateral must panic
+ cc.submit_bid(&1u64, &freelancer, &proposal, &-100i128);
+ }
}
diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs
index ec22f312..844d25ac 100644
--- a/contracts/reputation/src/lib.rs
+++ b/contracts/reputation/src/lib.rs
@@ -482,7 +482,6 @@ impl ReputationContract {
let total_jobs = metrics.completed_jobs;
let badge_level = metrics.badge_level;
- profile.refresh_badges();
storage::write_profile(&env, &address, &profile);
env.events().publish(
("reputation", "ScoreAdjusted"),
@@ -515,7 +514,6 @@ impl ReputationContract {
let total_jobs = metrics.completed_jobs;
let badge_level = metrics.badge_level;
- profile.refresh_badges();
storage::write_profile(&env, &address, &profile);
env.events().publish(
("reputation", "ScoreAdjusted"),
@@ -623,6 +621,52 @@ impl ReputationContract {
is_blacklisted: profile.is_blacklisted,
}
}
+
+ pub fn get_badge(env: Env, address: Address, role: Role) -> BadgeLevel {
+ Self::bump_instance_ttl(&env);
+ let profile = storage::read_profile_or_default(&env, &address);
+ let score = Self::role_metrics(&profile, &role).score;
+ BadgeLevel::from_score(score)
+ }
+
+ pub fn set_badge_metadata(
+ env: Env,
+ admin: Address,
+ address: Address,
+ tier: BadgeTier,
+ uri: Bytes,
+ ) {
+ Self::require_admin(&env, &admin);
+ let mut profile = storage::read_profile_or_default(&env, &address);
+
+ let mut updated = false;
+ for i in 0..profile.badge_metadata.len() {
+ if let Some(mut entry) = profile.badge_metadata.get(i) {
+ if entry.tier == tier {
+ entry.uri = uri.clone();
+ profile.badge_metadata.set(i, entry);
+ updated = true;
+ break;
+ }
+ }
+ }
+ if !updated {
+ profile.badge_metadata.push_back(BadgeMetadataEntry { tier, uri });
+ }
+ storage::write_profile(&env, &address, &profile);
+ Self::bump_instance_ttl(&env);
+ }
+
+ pub fn get_badge_metadata(env: Env, address: Address, tier: BadgeTier) -> Option {
+ Self::bump_instance_ttl(&env);
+ let profile = storage::read_profile_or_default(&env, &address);
+ for entry in profile.badge_metadata.iter() {
+ if entry.tier == tier {
+ return Some(entry.uri);
+ }
+ }
+ None
+ }
}
#[cfg(test)]
@@ -985,9 +1029,10 @@ mod test {
let cid = env.register_contract(None, ReputationContract);
let client = ReputationContractClient::new(&env, &cid);
client.initialize(&admin);
+ client.set_authorized_contract(&admin, &admin);
// Raise score by 1000 → 5000+1000 = 6000 → Silver
- client.update_score(&addr, &Role::Freelancer, &1000);
+ client.update_score(&admin, &addr, &Role::Freelancer, &1000);
let badge = client.get_badge(&addr, &Role::Freelancer);
assert_eq!(badge, BadgeLevel::Silver);
}
@@ -1001,8 +1046,9 @@ mod test {
let cid = env.register_contract(None, ReputationContract);
let client = ReputationContractClient::new(&env, &cid);
client.initialize(&admin);
+ client.set_authorized_contract(&admin, &admin);
- client.update_score(&addr, &Role::Freelancer, &3000); // 5000+3000=8000
+ client.update_score(&admin, &addr, &Role::Freelancer, &3000); // 5000+3000=8000
assert_eq!(client.get_badge(&addr, &Role::Freelancer), BadgeLevel::Gold);
}
@@ -1015,13 +1061,14 @@ mod test {
let cid = env.register_contract(None, ReputationContract);
let client = ReputationContractClient::new(&env, &cid);
client.initialize(&admin);
+ client.set_authorized_contract(&admin, &admin);
// Bring to Gold first, then slash twice to drop back to Bronze
- client.update_score(&addr, &Role::Client, &3000); // 8000 → Gold
+ client.update_score(&admin, &addr, &Role::Client, &3000); // 8000 → Gold
assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Gold);
- client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 6000 → Silver
+ client.slash(&admin, &addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 6000 → Silver
assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Silver);
- client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 4000 → Bronze
+ client.slash(&admin, &addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 4000 → Bronze
assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Bronze);
}
diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs
index fc8a0afd..7599068b 100644
--- a/contracts/reputation/src/profile.rs
+++ b/contracts/reputation/src/profile.rs
@@ -99,14 +99,14 @@ pub struct Profile {
}
impl Profile {
- pub fn new(address: Address) -> Self {
+ pub fn new(env: &soroban_sdk::Env, address: Address) -> Self {
Self {
address,
client: RoleMetrics::new(),
freelancer: RoleMetrics::new(),
is_blacklisted: false,
metadata_hash: None,
- badge_metadata: soroban_sdk::Vec::new(_env),
+ badge_metadata: soroban_sdk::Vec::new(env),
}
}
}
diff --git a/contracts/reputation/src/storage.rs b/contracts/reputation/src/storage.rs
index f20489cd..1c1242dc 100644
--- a/contracts/reputation/src/storage.rs
+++ b/contracts/reputation/src/storage.rs
@@ -23,7 +23,7 @@ pub fn read_profile(env: &Env, address: &Address) -> Option {
}
pub fn read_profile_or_default(env: &Env, address: &Address) -> Profile {
- read_profile(env, address).unwrap_or_else(|| Profile::new(address.clone()))
+ read_profile(env, address).unwrap_or_else(|| Profile::new(env, address.clone()))
}
pub fn write_profile(env: &Env, address: &Address, profile: &Profile) {