diff --git a/quid-contract/Cargo.lock b/quid-contract/Cargo.lock index cd035cc..f40f0b9 100644 --- a/quid-contract/Cargo.lock +++ b/quid-contract/Cargo.lock @@ -989,6 +989,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "quid-reputation" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "quid-store" version = "0.0.0" diff --git a/quid-contract/contracts/quid-milestone-escrow/src/error.rs b/quid-contract/contracts/quid-milestone-escrow/src/error.rs index f97161d..dc1041f 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/error.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/error.rs @@ -4,4 +4,8 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MilestoneEscrowError { InvalidState = 1, + InvalidAmount = 2, + ProgramNotFound = 3, + MilestoneNotFound = 4, + NotAuthorized = 5, } diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index 5b3c767..05e676c 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -1,42 +1,347 @@ #![no_std] - -use soroban_sdk::{contract, contractimpl, Env}; +use soroban_sdk::{contract, contractevent, contractimpl, token, Address, Env, String}; mod error; -pub mod types; +mod types; + +use error::MilestoneEscrowError; +use types::{DataKey, Milestone, MilestoneStatus, Program, ProgramStatus}; + +#[contractevent(topics = ["program", "created"])] +pub struct ProgramCreatedEvent { + pub program_id: u64, + pub sponsor: Address, + pub recipient: Address, +} + +#[contractevent(topics = ["program", "status"])] +pub struct ProgramStatusChangedEvent { + pub program_id: u64, + pub status: ProgramStatus, +} -use types::{DataKey, MilestoneStatus, ProgramStatus}; +#[contractevent(topics = ["milestone", "added"])] +pub struct MilestoneAddedEvent { + pub program_id: u64, + pub milestone_id: u64, + pub amount: i128, +} + +#[contractevent(topics = ["milestone", "paid"])] +pub struct MilestonePaidEvent { + pub program_id: u64, + pub milestone_id: u64, + pub amount: i128, + pub recipient: Address, +} + +#[contractevent(topics = ["program", "cancelled"])] +pub struct ProgramCancelledEvent { + pub program_id: u64, + pub sponsor: Address, + pub refund_amount: i128, +} #[contract] pub struct QuidMilestoneEscrowContract; #[contractimpl] impl QuidMilestoneEscrowContract { + // ── Status helpers ──────────────────────────────────────────────────── + pub fn get_program_status(env: Env) -> ProgramStatus { env.storage() - .persistent() + .instance() .get(&DataKey::ProgramStatus) .unwrap_or_default() } pub fn set_program_status(env: Env, status: ProgramStatus) { env.storage() - .persistent() + .instance() .set(&DataKey::ProgramStatus, &status); } pub fn get_milestone_status(env: Env) -> MilestoneStatus { env.storage() - .persistent() + .instance() .get(&DataKey::MilestoneStatus) .unwrap_or_default() } pub fn set_milestone_status(env: Env, status: MilestoneStatus) { env.storage() - .persistent() + .instance() .set(&DataKey::MilestoneStatus, &status); } + + pub fn get_program_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ProgramCount) + .unwrap_or(0_u64) + } + + // ── Core methods ────────────────────────────────────────────────────── + + pub fn create_program( + env: Env, + sponsor: Address, + recipient: Address, + token: Address, + total_amount: i128, + reviewer: Option
, + metadata_cid: Option, + ) -> Result { + sponsor.require_auth(); + + if total_amount <= 0 { + return Err(MilestoneEscrowError::InvalidAmount); + } + + token::Client::new(&env, &token).transfer( + &sponsor, + env.current_contract_address(), + &total_amount, + ); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ProgramCount) + .unwrap_or(0); + count += 1; + env.storage().instance().set(&DataKey::ProgramCount, &count); + + let program_id = count; + let created_at = env.ledger().timestamp(); + + let program = Program { + id: program_id, + sponsor: sponsor.clone(), + recipient: recipient.clone(), + reviewer, + token, + total_amount, + allocated_amount: 0, + released_amount: 0, + milestone_count: 0, + metadata_cid, + created_at, + status: ProgramStatus::Active, + }; + + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); + + ProgramCreatedEvent { + program_id, + sponsor, + recipient, + } + .publish(&env); + + ProgramStatusChangedEvent { + program_id, + status: ProgramStatus::Active, + } + .publish(&env); + + Ok(program_id) + } + + pub fn get_program(env: Env, program_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Program(program_id)) + .ok_or(MilestoneEscrowError::ProgramNotFound) + } + + pub fn add_milestone( + env: Env, + program_id: u64, + title: String, + amount: i128, + due_at: u64, + metadata_cid: String, + ) -> Result { + let mut program = Self::get_program(env.clone(), program_id)?; + program.sponsor.require_auth(); + + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::InvalidState); + } + + if amount <= 0 { + return Err(MilestoneEscrowError::InvalidAmount); + } + + let allocated_amount = program + .allocated_amount + .checked_add(amount) + .ok_or(MilestoneEscrowError::InvalidAmount)?; + if allocated_amount > program.total_amount { + return Err(MilestoneEscrowError::InvalidAmount); + } + + let milestone_id = program.milestone_count + 1; + let milestone = Milestone { + id: milestone_id, + program_id, + title, + amount, + due_at, + metadata_cid, + status: MilestoneStatus::Pending, + }; + + program.allocated_amount = allocated_amount; + program.milestone_count = milestone_id; + + env.storage() + .persistent() + .set(&DataKey::Milestone(program_id, milestone_id), &milestone); + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); + + MilestoneAddedEvent { + program_id, + milestone_id, + amount, + } + .publish(&env); + + Ok(milestone_id) + } + + pub fn get_milestone( + env: Env, + program_id: u64, + milestone_id: u64, + ) -> Result { + env.storage() + .persistent() + .get(&DataKey::Milestone(program_id, milestone_id)) + .ok_or(MilestoneEscrowError::MilestoneNotFound) + } + + pub fn approve_milestone( + env: Env, + program_id: u64, + milestone_id: u64, + approver: Address, + ) -> Result<(), MilestoneEscrowError> { + approver.require_auth(); + + let mut program = Self::get_program(env.clone(), program_id)?; + + let is_sponsor = approver == program.sponsor; + let is_reviewer = program.reviewer.as_ref() == Some(&approver); + if !is_sponsor && !is_reviewer { + return Err(MilestoneEscrowError::NotAuthorized); + } + + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::InvalidState); + } + + let mut milestone = Self::get_milestone(env.clone(), program_id, milestone_id)?; + if milestone.status != MilestoneStatus::Pending { + return Err(MilestoneEscrowError::InvalidState); + } + + let paid_amount = milestone.amount; + + token::Client::new(&env, &program.token).transfer( + &env.current_contract_address(), + &program.recipient, + &paid_amount, + ); + + milestone.status = MilestoneStatus::Paid; + env.storage() + .persistent() + .set(&DataKey::Milestone(program_id, milestone_id), &milestone); + + program.released_amount = program + .released_amount + .checked_add(paid_amount) + .ok_or(MilestoneEscrowError::InvalidAmount)?; + + let recipient = program.recipient.clone(); + + MilestonePaidEvent { + program_id, + milestone_id, + amount: paid_amount, + recipient, + } + .publish(&env); + + if program.released_amount >= program.total_amount { + program.status = ProgramStatus::Completed; + ProgramStatusChangedEvent { + program_id, + status: ProgramStatus::Completed, + } + .publish(&env); + } + + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); + + Ok(()) + } + + pub fn cancel_program( + env: Env, + program_id: u64, + sponsor: Address, + ) -> Result<(), MilestoneEscrowError> { + sponsor.require_auth(); + + let mut program = Self::get_program(env.clone(), program_id)?; + + if sponsor != program.sponsor { + return Err(MilestoneEscrowError::NotAuthorized); + } + + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::InvalidState); + } + + let refund_amount = program.total_amount - program.released_amount; + if refund_amount > 0 { + token::Client::new(&env, &program.token).transfer( + &env.current_contract_address(), + &program.sponsor, + &refund_amount, + ); + } + + program.status = ProgramStatus::Cancelled; + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); + + ProgramCancelledEvent { + program_id, + sponsor: program.sponsor.clone(), + refund_amount, + } + .publish(&env); + + ProgramStatusChangedEvent { + program_id, + status: ProgramStatus::Cancelled, + } + .publish(&env); + + Ok(()) + } } mod test; diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index 282249c..56a9a53 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -2,7 +2,27 @@ use super::*; use crate::types::{MilestoneStatus, ProgramStatus}; -use soroban_sdk::Env; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, String, +}; + +fn setup_test_env() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuidMilestoneEscrowContract, ()); + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let sponsor = Address::generate(&env); + let token_admin_client = StellarAssetClient::new(&env, &token_address); + token_admin_client.mint(&sponsor, &10_000_000); + + (env, contract_id, sponsor, token_address, token_admin) +} #[test] fn test_default_statuses() { @@ -26,3 +46,532 @@ fn test_status_storage_roundtrip() { assert_eq!(client.get_program_status(), ProgramStatus::Completed); assert_eq!(client.get_milestone_status(), MilestoneStatus::Paid); } + +#[test] +fn test_create_program_funds_and_stores_active_program() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token_address); + let recipient = Address::generate(&env); + let reviewer = Address::generate(&env); + let total_amount = 500; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &Some(reviewer.clone()), + &Some(String::from_str(&env, "QmProgram")), + ); + let contract_event_count = env + .events() + .all() + .iter() + .filter(|(id, _, _)| id == &contract_id) + .count(); + + assert_eq!(program_id, 1); + assert_eq!(contract_event_count, 2); + assert_eq!(token_client.balance(&contract_id), total_amount); + assert_eq!(client.get_program_count(), 1); + + let program = client.get_program(&program_id); + assert_eq!(program.id, program_id); + assert_eq!(program.sponsor, sponsor); + assert_eq!(program.recipient, recipient); + assert_eq!(program.reviewer, Some(reviewer)); + assert_eq!(program.token, token_address); + assert_eq!(program.total_amount, total_amount); + assert_eq!(program.allocated_amount, 0); + assert_eq!(program.released_amount, 0); + assert_eq!(program.milestone_count, 0); + assert_eq!(program.status, ProgramStatus::Active); +} + +#[test] +fn test_add_milestone_stores_and_updates_program_totals() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + let total_amount = 500; + let milestone_amount = 200; + let due_at = 1_750_000_000; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &None, + &None, + ); + let milestone_id = client.add_milestone( + &program_id, + &String::from_str(&env, "Design complete"), + &milestone_amount, + &due_at, + &String::from_str(&env, "QmMilestone"), + ); + + assert_eq!(milestone_id, 1); + + let program = client.get_program(&program_id); + assert_eq!(program.allocated_amount, milestone_amount); + assert_eq!(program.milestone_count, 1); + + let milestone = client.get_milestone(&program_id, &milestone_id); + assert_eq!(milestone.id, milestone_id); + assert_eq!(milestone.program_id, program_id); + assert_eq!(milestone.title, String::from_str(&env, "Design complete")); + assert_eq!(milestone.amount, milestone_amount); + assert_eq!(milestone.due_at, due_at); + assert_eq!( + milestone.metadata_cid, + String::from_str(&env, "QmMilestone") + ); + assert_eq!(milestone.status, MilestoneStatus::Pending); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_add_milestone_rejects_over_allocation() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + let total_amount = 500; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &None, + &None, + ); + let _ = client.add_milestone( + &program_id, + &String::from_str(&env, "Too large"), + &501, + &1_750_000_000, + &String::from_str(&env, "QmMilestone"), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_get_milestone_not_found() { + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + + let _ = client.get_milestone(&999, &1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_create_program_rejects_zero_amount() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + + let _ = client.create_program(&sponsor, &recipient, &token_address, &0, &None, &None); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_get_program_not_found() { + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + + let _ = client.get_program(&999); +} + +#[test] +fn test_approve_milestone_by_sponsor() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token_address); + let recipient = Address::generate(&env); + let total_amount = 1_000_i128; + let milestone_amount = 400_i128; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &None, + &None, + ); + let milestone_id = client.add_milestone( + &program_id, + &String::from_str(&env, "Phase 1"), + &milestone_amount, + &1_750_000_000, + &String::from_str(&env, "QmM1"), + ); + + client.approve_milestone(&program_id, &milestone_id, &sponsor); + + let milestone = client.get_milestone(&program_id, &milestone_id); + assert_eq!(milestone.status, MilestoneStatus::Paid); + + let program = client.get_program(&program_id); + assert_eq!(program.released_amount, milestone_amount); + assert_eq!(program.status, ProgramStatus::Active); + assert_eq!(token_client.balance(&recipient), milestone_amount); +} + +#[test] +fn test_cancel_program_refunds_unreleased_funds() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token_address); + let recipient = Address::generate(&env); + let total_amount = 1_000_i128; + let milestone_amount = 300_i128; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &None, + &None, + ); + let milestone_id = client.add_milestone( + &program_id, + &String::from_str(&env, "Phase 1"), + &milestone_amount, + &1_750_000_000, + &String::from_str(&env, "QmM1"), + ); + // Pay out one milestone so released_amount = 300 + client.approve_milestone(&program_id, &milestone_id, &sponsor); + + let sponsor_balance_before = token_client.balance(&sponsor); + client.cancel_program(&program_id, &sponsor); + + let program = client.get_program(&program_id); + assert_eq!(program.status, ProgramStatus::Cancelled); + + // Refund = total_amount - released_amount = 1000 - 300 = 700 + let expected_refund = total_amount - milestone_amount; + assert_eq!( + token_client.balance(&sponsor), + sponsor_balance_before + expected_refund + ); +} + +#[test] +fn test_approve_milestone_by_reviewer() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token_address); + let recipient = Address::generate(&env); + let reviewer = Address::generate(&env); + let total_amount = 1_000_i128; + let milestone_amount = 400_i128; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &Some(reviewer.clone()), + &None, + ); + let milestone_id = client.add_milestone( + &program_id, + &String::from_str(&env, "Phase 1"), + &milestone_amount, + &1_750_000_000, + &String::from_str(&env, "QmM1"), + ); + + // Reviewer (not sponsor) approves the milestone + client.approve_milestone(&program_id, &milestone_id, &reviewer); + + let milestone = client.get_milestone(&program_id, &milestone_id); + assert_eq!(milestone.status, MilestoneStatus::Paid); + assert_eq!(token_client.balance(&recipient), milestone_amount); + + // Reviewer is still stored (not consumed) + let program = client.get_program(&program_id); + assert_eq!(program.reviewer, Some(reviewer)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_approve_milestone_unauthorized_fails() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + let intruder = Address::generate(&env); + + let program_id = + client.create_program(&sponsor, &recipient, &token_address, &1_000, &None, &None); + let milestone_id = client.add_milestone( + &program_id, + &String::from_str(&env, "Phase 1"), + &400, + &1_750_000_000, + &String::from_str(&env, "QmM1"), + ); + + // Intruder is neither sponsor nor reviewer – must be rejected + client.approve_milestone(&program_id, &milestone_id, &intruder); +} + +#[test] +fn test_approve_milestone_completes_program() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + let total_amount = 500_i128; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &None, + &None, + ); + // Single milestone covering the full funded amount + let milestone_id = client.add_milestone( + &program_id, + &String::from_str(&env, "Full work"), + &total_amount, + &1_750_000_000, + &String::from_str(&env, "QmM1"), + ); + + client.approve_milestone(&program_id, &milestone_id, &sponsor); + + let program = client.get_program(&program_id); + assert_eq!(program.released_amount, total_amount); + assert_eq!(program.status, ProgramStatus::Completed); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_cancel_program_non_active_fails() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + + let program_id = + client.create_program(&sponsor, &recipient, &token_address, &500, &None, &None); + client.cancel_program(&program_id, &sponsor); + // Second cancel on a Cancelled program must fail with InvalidState + client.cancel_program(&program_id, &sponsor); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_cancel_program_unauthorized_fails() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + let intruder = Address::generate(&env); + + let program_id = + client.create_program(&sponsor, &recipient, &token_address, &500, &None, &None); + // Intruder is not the sponsor – must be rejected + client.cancel_program(&program_id, &intruder); +} + +// ── Issue #184: Milestone escrow happy path tests ────────────────────────── + +/// Full end-to-end happy path: +/// fund program → add two milestones → sponsor approves both → program completes. +#[test] +fn test_happy_path_two_milestones_full_release() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token_address); + let recipient = Address::generate(&env); + + let total_amount = 1_000_i128; + let amount_m1 = 600_i128; + let amount_m2 = 400_i128; + + // ── 1. Create funded program ────────────────────────────────────────── + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &None, + &Some(String::from_str(&env, "QmProgram")), + ); + + let program = client.get_program(&program_id); + assert_eq!(program.status, ProgramStatus::Active); + assert_eq!(program.total_amount, total_amount); + assert_eq!(program.released_amount, 0); + assert_eq!(token_client.balance(&contract_id), total_amount); + + // ── 2. Add two milestones ───────────────────────────────────────────── + let m1 = client.add_milestone( + &program_id, + &String::from_str(&env, "Milestone 1"), + &amount_m1, + &1_750_000_000, + &String::from_str(&env, "QmM1"), + ); + let m2 = client.add_milestone( + &program_id, + &String::from_str(&env, "Milestone 2"), + &amount_m2, + &1_750_086_400, + &String::from_str(&env, "QmM2"), + ); + + let program = client.get_program(&program_id); + assert_eq!(program.allocated_amount, total_amount); + assert_eq!(program.milestone_count, 2); + + // ── 3. Approve milestone 1 ──────────────────────────────────────────── + client.approve_milestone(&program_id, &m1, &sponsor); + + assert_eq!( + client.get_milestone(&program_id, &m1).status, + MilestoneStatus::Paid + ); + let program = client.get_program(&program_id); + assert_eq!(program.released_amount, amount_m1); + assert_eq!(program.status, ProgramStatus::Active); // still active + assert_eq!(token_client.balance(&recipient), amount_m1); + assert_eq!(token_client.balance(&contract_id), total_amount - amount_m1); + + // ── 4. Approve milestone 2 → program completes ──────────────────────── + client.approve_milestone(&program_id, &m2, &sponsor); + + assert_eq!( + client.get_milestone(&program_id, &m2).status, + MilestoneStatus::Paid + ); + let program = client.get_program(&program_id); + assert_eq!(program.released_amount, total_amount); + assert_eq!(program.status, ProgramStatus::Completed); + assert_eq!(token_client.balance(&recipient), total_amount); + assert_eq!(token_client.balance(&contract_id), 0); +} + +/// Happy path with a reviewer: reviewer approves each milestone in turn, +/// the reviewer field is preserved after each approval (not consumed). +#[test] +fn test_happy_path_reviewer_approves_multiple_milestones() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token_address); + let recipient = Address::generate(&env); + let reviewer = Address::generate(&env); + + let total_amount = 800_i128; + let amount_m1 = 500_i128; + let amount_m2 = 300_i128; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &Some(reviewer.clone()), + &None, + ); + + let m1 = client.add_milestone( + &program_id, + &String::from_str(&env, "Design"), + &amount_m1, + &1_750_000_000, + &String::from_str(&env, "QmDesign"), + ); + let m2 = client.add_milestone( + &program_id, + &String::from_str(&env, "Delivery"), + &amount_m2, + &1_750_086_400, + &String::from_str(&env, "QmDelivery"), + ); + + // Reviewer approves first milestone + client.approve_milestone(&program_id, &m1, &reviewer); + + assert_eq!( + client.get_milestone(&program_id, &m1).status, + MilestoneStatus::Paid + ); + // Reviewer option is still present after first approval + assert_eq!( + client.get_program(&program_id).reviewer, + Some(reviewer.clone()) + ); + assert_eq!(token_client.balance(&recipient), amount_m1); + + // Reviewer approves second milestone → program completes + client.approve_milestone(&program_id, &m2, &reviewer); + + assert_eq!( + client.get_milestone(&program_id, &m2).status, + MilestoneStatus::Paid + ); + let program = client.get_program(&program_id); + assert_eq!(program.released_amount, total_amount); + assert_eq!(program.status, ProgramStatus::Completed); + assert_eq!(program.reviewer, Some(reviewer)); + assert_eq!(token_client.balance(&recipient), total_amount); + assert_eq!(token_client.balance(&contract_id), 0); +} + +/// Happy path cancel: fund → add milestone → approve one → cancel → refund is exact. +#[test] +fn test_happy_path_partial_release_then_cancel() { + let (env, contract_id, sponsor, token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token_address); + let recipient = Address::generate(&env); + + let total_amount = 1_000_i128; + let amount_m1 = 250_i128; + + let program_id = client.create_program( + &sponsor, + &recipient, + &token_address, + &total_amount, + &None, + &None, + ); + let m1 = client.add_milestone( + &program_id, + &String::from_str(&env, "Phase 1"), + &amount_m1, + &1_750_000_000, + &String::from_str(&env, "QmM1"), + ); + + client.approve_milestone(&program_id, &m1, &sponsor); + + let sponsor_balance_before_cancel = token_client.balance(&sponsor); + + client.cancel_program(&program_id, &sponsor); + + let program = client.get_program(&program_id); + assert_eq!(program.status, ProgramStatus::Cancelled); + assert_eq!(program.released_amount, amount_m1); + + // Refund = total_amount - released_amount = 1000 - 250 = 750 + let expected_refund = total_amount - amount_m1; + assert_eq!( + token_client.balance(&sponsor), + sponsor_balance_before_cancel + expected_refund + ); + // Contract holds nothing after refund + assert_eq!(token_client.balance(&contract_id), 0); + // Recipient received only what was paid before cancellation + assert_eq!(token_client.balance(&recipient), amount_m1); +} diff --git a/quid-contract/contracts/quid-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..49365d7 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "quid-reputation" + +version = "0.1.0" + +edition = "2021" +publish = false + +[lib] + +crate-type = ["cdylib"] + + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true + diff --git a/quid-contract/contracts/quid-reputation/Makefile b/quid-contract/contracts/quid-reputation/Makefile new file mode 100644 index 0000000..b971934 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Makefile @@ -0,0 +1,16 @@ +default: build + +all: test + +test: build + cargo test + +build: + stellar contract build + @ls -l target/wasm32v1-none/release/*.wasm + +fmt: + cargo fmt --all + +clean: + cargo clean diff --git a/quid-contract/contracts/quid-reputation/README.md b/quid-contract/contracts/quid-reputation/README.md new file mode 100644 index 0000000..2c661de --- /dev/null +++ b/quid-contract/contracts/quid-reputation/README.md @@ -0,0 +1,141 @@ +# Quid Reputation Contract + + +The Quid Reputation contract implements an on-chain attestation system for tracking contributor activity and reputation. + +## Features + +- **Issue Attestations**: Create portable attestations for contributor activity with metadata and optional expiry +- **Revoke Attestations**: Issuers can revoke attestations they've issued +- **Admin Management**: Bootstrap and manage contract admin +- **Event Logging**: Publish events when attestations are issued or revoked + +## API + +### `bootstrap_admin(env, admin) -> Result<(), QuidError>` + +Initialize the contract admin. Can only be called once. + +**Parameters:** + +- `env`: Soroban environment +- `admin`: Address of the admin + +**Returns:** + +- `Ok(())` on success +- `Err(QuidError::NotAuthorized)` if admin is already set + +--- + +### `get_admin(env) -> Result` + +Get the current contract admin. + +**Parameters:** + +- `env`: Soroban environment + +**Returns:** + +- `Ok(Address)` - The admin address +- `Err(QuidError::AdminNotSet)` if no admin is set + +--- + +### `issue_attestation(env, issuer, subject, kind, label, metadata_cid, expires_at) -> Result` + +Issue an attestation for a subject. + +**Parameters:** + +- `env`: Soroban environment +- `issuer`: Address of the issuer (must authorize the transaction) +- `subject`: Address of the attestation subject +- `kind`: String describing the attestation type (e.g., "contributor", "expert") +- `label`: String label for the attestation (must not be empty) +- `metadata_cid`: Optional IPFS CID for additional metadata +- `expires_at`: Optional timestamp when the attestation expires + +**Returns:** + +- `Ok(u64)` - The attestation ID +- `Err(QuidError::NotAuthorized)` if issuer doesn't authorize +- `Err(QuidError::InvalidLabel)` if label is empty +- `Err(QuidError::InvalidExpiryTime)` if expiry is in the past + +**Events:** + +- Publishes `AttestationIssuedEvent` with attestation_id, issuer, and subject + +--- + +### `get_attestation(env, attestation_id) -> Result` + +Retrieve an attestation by ID. + +**Parameters:** + +- `env`: Soroban environment +- `attestation_id`: The attestation ID + +**Returns:** + +- `Ok(Attestation)` - The attestation record +- `Err(QuidError::AttestationNotFound)` if attestation doesn't exist + +--- + +### `revoke_attestation(env, attestation_id) -> Result<(), QuidError>` + +Revoke an attestation (issuer only). + +**Parameters:** + +- `env`: Soroban environment +- `attestation_id`: The attestation ID to revoke + +**Returns:** + +- `Ok(())` on success +- `Err(QuidError::NotAuthorized)` if not the issuer +- `Err(QuidError::AttestationNotFound)` if attestation doesn't exist +- `Err(QuidError::AlreadyRevoked)` if already revoked + +**Events:** + +- Publishes `AttestationRevokedEvent` with attestation_id + +## Data Structures + +### Attestation + +```rust +pub struct Attestation { + pub id: u64, + pub issuer: Address, + pub subject: Address, + pub kind: String, + pub label: String, + pub metadata_cid: Option, + pub issued_at: u64, + pub expires_at: Option, + pub revoked: bool, +} +``` + +## Testing + +Run tests with: + +```bash +cargo test +``` + +## Build + +Build the contract with: + +```bash +cargo build --target wasm32-unknown-unknown --release +``` diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs new file mode 100644 index 0000000..5d725c6 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -0,0 +1,12 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ReputationError { + NotAuthorized = 1, + ProfileNotFound = 2, + InvalidInput = 3, + AttestationNotFound = 4, + AlreadyRevoked = 5, + InvalidRewardAmount = 6, +} \ No newline at end of file diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs new file mode 100644 index 0000000..3f4b706 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,250 @@ +#![no_std] +use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; + +mod error; +mod types; + +use error::ReputationError; +use types::{Attestation, DataKey, Profile}; + +const PROFILE_TTL_LEDGERS: u32 = 5_184_000; + +#[contractevent(topics = ["attestation", "revoked"])] +pub struct AttestationRevokedEvent { + pub attestation_id: u64, + pub revoked_by: Address, +} + +#[contract] +pub struct QuidReputationContract; + +#[contractimpl] +impl QuidReputationContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), ReputationError> { + admin.require_auth(); + + if env.storage().instance().has(&DataKey::Admin) { + return Err(ReputationError::InvalidInput); + } + + env.storage().instance().set(&DataKey::Admin, &admin); + Ok(()) + } + + pub fn get_admin(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::NotAuthorized) + } + + pub fn issue_attestation( + env: Env, + issuer: Address, + subject: Address, + attestation_type: String, + data_cid: String, + ) -> Result { + issuer.require_auth(); + + let attestation_id = Self::get_next_attestation_id(&env); + let issued_at = env.ledger().timestamp(); + + let attestation = Attestation { + id: attestation_id, + issuer, + subject, + attestation_type, + data_cid, + issued_at, + revoked: false, + }; + + env.storage() + .persistent() + .set(&DataKey::Attestation(attestation_id), &attestation); + + env.storage().persistent().extend_ttl( + &DataKey::Attestation(attestation_id), + PROFILE_TTL_LEDGERS, + PROFILE_TTL_LEDGERS, + ); + + Ok(attestation_id) + } + + pub fn get_attestation(env: Env, attestation_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Attestation(attestation_id)) + .ok_or(ReputationError::AttestationNotFound) + } + + pub fn revoke_attestation( + env: Env, + caller: Address, + attestation_id: u64, + ) -> Result<(), ReputationError> { + caller.require_auth(); + + let mut attestation = Self::get_attestation(env.clone(), attestation_id)?; + + if attestation.revoked { + return Err(ReputationError::AlreadyRevoked); + } + + let admin = Self::get_admin(env.clone())?; + if caller != attestation.issuer && caller != admin { + return Err(ReputationError::NotAuthorized); + } + + attestation.revoked = true; + env.storage() + .persistent() + .set(&DataKey::Attestation(attestation_id), &attestation); + + env.storage().persistent().extend_ttl( + &DataKey::Attestation(attestation_id), + PROFILE_TTL_LEDGERS, + PROFILE_TTL_LEDGERS, + ); + + AttestationRevokedEvent { + attestation_id, + revoked_by: caller, + } + .publish(&env); + + Ok(()) + } + + pub fn get_attestation_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::AttestationCount) + .unwrap_or(0) + } + + pub fn attestation_exists(env: Env, attestation_id: u64) -> bool { + env.storage() + .persistent() + .has(&DataKey::Attestation(attestation_id)) + } + + pub fn get_profile(env: Env, subject: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::Profile(subject)) + .ok_or(ReputationError::ProfileNotFound) + } + + pub fn set_profile(env: Env, profile: Profile) -> Result<(), ReputationError> { + profile.subject.require_auth(); + + env.storage() + .persistent() + .set(&DataKey::Profile(profile.subject.clone()), &profile); + + env.storage().persistent().extend_ttl( + &DataKey::Profile(profile.subject), + PROFILE_TTL_LEDGERS, + PROFILE_TTL_LEDGERS, + ); + + Ok(()) + } + + pub fn profile_exists(env: Env, subject: Address) -> bool { + env.storage().persistent().has(&DataKey::Profile(subject)) + } + + /// Admin-only upsert: write a full profile snapshot for a subject. + pub fn upsert_profile( + env: Env, + subject: Address, + score: i64, + missions_completed: u32, + missions_created: u32, + total_earnings: i128, + ) -> Result<(), ReputationError> { + let admin = Self::get_admin(env.clone())?; + admin.require_auth(); + + if total_earnings < 0 { + return Err(ReputationError::InvalidRewardAmount); + } + + let profile = Profile { + subject: subject.clone(), + score, + missions_completed, + missions_created, + }; + + Self::store_profile(&env, &profile); + + env.events().publish( + ( + soroban_sdk::symbol_short!("profile"), + soroban_sdk::symbol_short!("updated"), + ), + subject, + ); + + Ok(()) + } + + fn get_next_attestation_id(env: &Env) -> u64 { + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::AttestationCount) + .unwrap_or(0); + count += 1; + env.storage() + .instance() + .set(&DataKey::AttestationCount, &count); + count + } +} + +#[allow(dead_code)] +impl QuidReputationContract { + pub(crate) fn require_admin(env: &Env, caller: &Address) -> Result<(), ReputationError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::NotAuthorized)?; + + admin.require_auth(); + + if *caller != admin { + return Err(ReputationError::NotAuthorized); + } + + Ok(()) + } + + pub(crate) fn store_profile(env: &Env, profile: &Profile) { + let key = DataKey::Profile(profile.subject.clone()); + env.storage().persistent().set(&key, profile); + env.storage() + .persistent() + .extend_ttl(&key, PROFILE_TTL_LEDGERS, PROFILE_TTL_LEDGERS); + } + + pub(crate) fn load_or_default(env: &Env, subject: Address) -> Profile { + env.storage() + .persistent() + .get(&DataKey::Profile(subject.clone())) + .unwrap_or(Profile { + subject, + score: 0, + missions_completed: 0, + missions_created: 0, + }) + } +} + +mod test; \ No newline at end of file diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs new file mode 100644 index 0000000..87cd6b0 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -0,0 +1,139 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +fn setup_test_env() -> (Env, Address, QuidReputationContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + (env, admin, client) +} + +#[test] +fn test_initialize_sets_admin() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let retrieved_admin = client.get_admin(); + assert_eq!(retrieved_admin, admin); +} + +#[test] +fn test_initialize_twice_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.initialize(&admin); + let result = client.try_initialize(&admin); + + assert!(result.is_err()); +} + +#[test] +fn test_issue_attestation() { + let (env, _admin, client) = setup_test_env(); + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_id = client.issue_attestation( + &issuer, + &subject, + &String::from_str(&env, "contributor"), + &String::from_str(&env, "QmExample123"), + ); + + assert_eq!(attestation_id, 1); + + let attestation = client.get_attestation(&attestation_id); + assert_eq!(attestation.issuer, issuer); + assert_eq!(attestation.subject, subject); + assert!(!attestation.revoked); +} + +#[test] +fn test_revoke_attestation() { + let (env, admin, client) = setup_test_env(); + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_id = client.issue_attestation( + &issuer, + &subject, + &String::from_str(&env, "contributor"), + &String::from_str(&env, "QmExample123"), + ); + + client.revoke_attestation(&admin, &attestation_id); + + let attestation = client.get_attestation(&attestation_id); + assert!(attestation.revoked); +} + +#[test] +fn test_get_profile_not_found() { + let (env, _admin, client) = setup_test_env(); + let subject = Address::generate(&env); + let result = client.try_get_profile(&subject); + assert!(result.is_err()); +} + +#[test] +fn test_upsert_profile_success() { + let (env, _admin, client) = setup_test_env(); + let subject = Address::generate(&env); + + client.upsert_profile(&subject, &85, &10, &2, &5000); + + let profile = client.get_profile(&subject); + assert_eq!(profile.score, 85); + assert_eq!(profile.missions_completed, 10); + assert_eq!(profile.missions_created, 2); +} + +#[test] +fn test_upsert_profile_updates_existing() { + let (env, _admin, client) = setup_test_env(); + let subject = Address::generate(&env); + + client.upsert_profile(&subject, &50, &5, &1, &1000); + client.upsert_profile(&subject, &95, &15, &3, &7500); + + let profile = client.get_profile(&subject); + assert_eq!(profile.score, 95); + assert_eq!(profile.missions_completed, 15); +} + +#[test] +fn test_upsert_profile_rejects_negative_earnings() { + let (env, _admin, client) = setup_test_env(); + let subject = Address::generate(&env); + + let result = client.try_upsert_profile(&subject, &50, &5, &1, &(-1)); + assert!(result.is_err()); +} + +#[test] +fn test_upsert_profile_emits_event() { + let (env, _admin, client) = setup_test_env(); + let subject = Address::generate(&env); + + client.upsert_profile(&subject, &85, &10, &2, &5000); + + let events = env.events().all(); + assert!(!events.is_empty()); +} \ No newline at end of file diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs new file mode 100644 index 0000000..d7deea2 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,32 @@ +use soroban_sdk::{contracttype, Address, String}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Attestation { + pub id: u64, + pub issuer: Address, + pub subject: Address, + pub attestation_type: String, + pub data_cid: String, + pub issued_at: u64, + pub revoked: bool, +} + +/// On-chain reputation profile for a single subject address. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Profile { + pub subject: Address, + pub score: i64, + pub missions_completed: u32, + pub missions_created: u32, +} + +#[contracttype] +pub enum DataKey { + Admin, + /// Per-subject reputation profile. + Profile(Address), + Attestation(u64), + AttestationCount, +}