From 47c4ed7937bbc379ab35d364fc6ab703c4514c57 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Fri, 29 May 2026 15:56:50 +0100 Subject: [PATCH 01/49] fix: resolved all issues in one: #176 Milestone escrow events, #175 Milestone escrow error enum --- quid-contract/Cargo.lock | 7 + .../quid-milestone-escrow/Cargo.toml | 15 + .../quid-milestone-escrow/src/error.rs | 13 + .../quid-milestone-escrow/src/lib.rs | 273 ++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 quid-contract/contracts/quid-milestone-escrow/Cargo.toml create mode 100644 quid-contract/contracts/quid-milestone-escrow/src/error.rs create mode 100644 quid-contract/contracts/quid-milestone-escrow/src/lib.rs diff --git a/quid-contract/Cargo.lock b/quid-contract/Cargo.lock index 4740608..cd035cc 100644 --- a/quid-contract/Cargo.lock +++ b/quid-contract/Cargo.lock @@ -982,6 +982,13 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quid-milestone-escrow" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "quid-store" version = "0.0.0" diff --git a/quid-contract/contracts/quid-milestone-escrow/Cargo.toml b/quid-contract/contracts/quid-milestone-escrow/Cargo.toml new file mode 100644 index 0000000..b1dfd2b --- /dev/null +++ b/quid-contract/contracts/quid-milestone-escrow/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "quid-milestone-escrow" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/quid-contract/contracts/quid-milestone-escrow/src/error.rs b/quid-contract/contracts/quid-milestone-escrow/src/error.rs new file mode 100644 index 0000000..35d6afe --- /dev/null +++ b/quid-contract/contracts/quid-milestone-escrow/src/error.rs @@ -0,0 +1,13 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MilestoneEscrowError { + ProgramNotFound = 1, + MilestoneNotFound = 2, + InvalidAmount = 3, + NotAuthorized = 4, + ProgramClosed = 5, + AmountExceedsEscrow = 6, + MilestoneNotPending = 7, +} diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs new file mode 100644 index 0000000..aa49725 --- /dev/null +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -0,0 +1,273 @@ +#![no_std] +use soroban_sdk::{contract, contractevent, contractimpl, contracttype, Address, Env}; + +mod error; +use error::MilestoneEscrowError; + +#[contracttype] +pub enum ProgramStatus { + Active, + Completed, + Cancelled, +} + +#[contracttype] +pub enum DataKey { + Program(u64), + Milestone(u64, u32), +} + +#[contracttype] +pub struct Program { + pub id: u64, + pub owner: Address, + pub status: ProgramStatus, + pub escrow_balance: i128, +} + +#[contracttype] +pub struct Milestone { + pub program_id: u64, + pub milestone_id: u32, + pub amount: i128, + pub pending: bool, +} + +#[contractevent(topics = ["program", "created"])] +pub struct ProgramCreatedEvent { + pub program_id: u64, + pub owner: Address, +} + +#[contractevent(topics = ["milestone", "added"])] +pub struct MilestoneAddedEvent { + pub program_id: u64, + pub milestone_id: u32, + pub amount: i128, +} + +#[contractevent(topics = ["milestone", "paid"])] +pub struct MilestonePaidEvent { + pub program_id: u64, + pub milestone_id: u32, + pub payer: Address, + pub payee: Address, + pub amount: i128, +} + +#[contractevent(topics = ["program", "cancelled"])] +pub struct ProgramCancelledEvent { + pub program_id: u64, + pub cancelled_by: Address, +} + +#[contractevent(topics = ["program", "status_changed"])] +pub struct ProgramStatusChangedEvent { + pub program_id: u64, + pub old_status: ProgramStatus, + pub new_status: ProgramStatus, +} + +#[contract] +pub struct MilestoneEscrowContract; + +#[contractimpl] +impl MilestoneEscrowContract { + pub fn create_program( + env: Env, + owner: Address, + program_id: u64, + initial_escrow: i128, + ) -> Result { + owner.require_auth(); + + if initial_escrow <= 0 { + return Err(MilestoneEscrowError::InvalidAmount); + } + + let program = Program { + id: program_id, + owner: owner.clone(), + status: ProgramStatus::Active, + escrow_balance: initial_escrow, + }; + + env.storage().persistent().set(&DataKey::Program(program_id), &program); + ProgramCreatedEvent { program_id, owner }.publish(&env); + + Ok(program_id) + } + + pub fn add_milestone( + env: Env, + owner: Address, + program_id: u64, + milestone_id: u32, + amount: i128, + ) -> Result<(), MilestoneEscrowError> { + owner.require_auth(); + + if amount <= 0 { + return Err(MilestoneEscrowError::InvalidAmount); + } + + let mut program: Program = env + .storage() + .persistent() + .get(&DataKey::Program(program_id)) + .ok_or(MilestoneEscrowError::ProgramNotFound)?; + + if program.owner != owner { + return Err(MilestoneEscrowError::NotAuthorized); + } + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::ProgramClosed); + } + if amount > program.escrow_balance { + return Err(MilestoneEscrowError::AmountExceedsEscrow); + } + + let milestone = Milestone { + program_id, + milestone_id, + amount, + pending: true, + }; + + env.storage() + .persistent() + .set(&DataKey::Milestone(program_id, milestone_id), &milestone); + + MilestoneAddedEvent { + program_id, + milestone_id, + amount, + } + .publish(&env); + + Ok(()) + } + + pub fn pay_milestone( + env: Env, + payer: Address, + program_id: u64, + milestone_id: u32, + payee: Address, + amount: i128, + ) -> Result<(), MilestoneEscrowError> { + payer.require_auth(); + + let mut program: Program = env + .storage() + .persistent() + .get(&DataKey::Program(program_id)) + .ok_or(MilestoneEscrowError::ProgramNotFound)?; + + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::ProgramClosed); + } + + let mut milestone: Milestone = env + .storage() + .persistent() + .get(&DataKey::Milestone(program_id, milestone_id)) + .ok_or(MilestoneEscrowError::MilestoneNotFound)?; + + if !milestone.pending { + return Err(MilestoneEscrowError::MilestoneNotPending); + } + if amount != milestone.amount { + return Err(MilestoneEscrowError::InvalidAmount); + } + if amount > program.escrow_balance { + return Err(MilestoneEscrowError::AmountExceedsEscrow); + } + + milestone.pending = false; + program.escrow_balance -= amount; + + env.storage() + .persistent() + .set(&DataKey::Milestone(program_id, milestone_id), &milestone); + env.storage().persistent().set(&DataKey::Program(program_id), &program); + + MilestonePaidEvent { + program_id, + milestone_id, + payer: payer.clone(), + payee: payee.clone(), + amount, + } + .publish(&env); + + Ok(()) + } + + pub fn cancel_program( + env: Env, + owner: Address, + program_id: u64, + ) -> Result<(), MilestoneEscrowError> { + owner.require_auth(); + + let mut program: Program = env + .storage() + .persistent() + .get(&DataKey::Program(program_id)) + .ok_or(MilestoneEscrowError::ProgramNotFound)?; + + if program.owner != owner { + return Err(MilestoneEscrowError::NotAuthorized); + } + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::ProgramClosed); + } + + program.status = ProgramStatus::Cancelled; + env.storage().persistent().set(&DataKey::Program(program_id), &program); + + ProgramCancelledEvent { + program_id, + cancelled_by: owner, + } + .publish(&env); + + Ok(()) + } + + pub fn change_program_status( + env: Env, + owner: Address, + program_id: u64, + new_status: ProgramStatus, + ) -> Result<(), MilestoneEscrowError> { + owner.require_auth(); + + let mut program: Program = env + .storage() + .persistent() + .get(&DataKey::Program(program_id)) + .ok_or(MilestoneEscrowError::ProgramNotFound)?; + + if program.owner != owner { + return Err(MilestoneEscrowError::NotAuthorized); + } + if program.status == ProgramStatus::Cancelled && new_status != ProgramStatus::Cancelled { + return Err(MilestoneEscrowError::ProgramClosed); + } + + let old_status = program.status; + program.status = new_status; + env.storage().persistent().set(&DataKey::Program(program_id), &program); + + ProgramStatusChangedEvent { + program_id, + old_status, + new_status: program.status, + } + .publish(&env); + + Ok(()) + } +} From 06863e37a7b4254f417c1b5863289c5a1fe12363 Mon Sep 17 00:00:00 2001 From: exronnin Date: Sun, 31 May 2026 14:57:28 +0100 Subject: [PATCH 02/49] feat(contracts): implement create program --- .../quid-milestone-escrow/src/error.rs | 2 + .../quid-milestone-escrow/src/lib.rs | 96 ++++++++++++++++++- .../quid-milestone-escrow/src/test.rs | 70 +++++++++++++- 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/error.rs b/quid-contract/contracts/quid-milestone-escrow/src/error.rs index f97161d..8fd8f70 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/error.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/error.rs @@ -4,4 +4,6 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MilestoneEscrowError { InvalidState = 1, + InvalidAmount = 2, + ProgramNotFound = 3, } diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index 5b3c767..57e429e 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -1,17 +1,91 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Env}; +use soroban_sdk::{contract, contractevent, contractimpl, token, Address, Env, String}; mod error; pub mod types; -use types::{DataKey, MilestoneStatus, ProgramStatus}; +use error::MilestoneEscrowError; +use types::{DataKey, MilestoneStatus, Program, ProgramStatus}; + +#[contractevent(topics = ["program", "created"])] +pub struct ProgramCreatedEvent { + pub program_id: u64, + pub sponsor: Address, + pub recipient: Address, +} + +#[contractevent(topics = ["program", "status_changed"])] +pub struct ProgramStatusChangedEvent { + pub program_id: u64, + pub status: ProgramStatus, +} #[contract] pub struct QuidMilestoneEscrowContract; #[contractimpl] impl QuidMilestoneEscrowContract { + 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 program_id = Self::get_next_program_id(&env); + let status = ProgramStatus::Active; + 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: env.ledger().timestamp(), + status, + }; + + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); + + ProgramCreatedEvent { + program_id, + sponsor, + recipient, + } + .publish(&env); + ProgramStatusChangedEvent { program_id, status }.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 get_program_status(env: Env) -> ProgramStatus { env.storage() .persistent() @@ -37,6 +111,24 @@ impl QuidMilestoneEscrowContract { .persistent() .set(&DataKey::MilestoneStatus, &status); } + + pub fn get_program_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ProgramCount) + .unwrap_or(0) + } + + fn get_next_program_id(env: &Env) -> u64 { + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ProgramCount) + .unwrap_or(0); + count += 1; + env.storage().instance().set(&DataKey::ProgramCount, &count); + count + } } 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..0acb8bd 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -2,7 +2,23 @@ use super::*; use crate::types::{MilestoneStatus, ProgramStatus}; -use soroban_sdk::Env; +use soroban_sdk::token::{Client as TokenClient, StellarAssetClient}; +use soroban_sdk::{testutils::Address as _, testutils::Events, Address, Env, String}; + +fn setup_test_env() -> (Env, 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, &1_000_000); + + (env, contract_id, sponsor, token_address) +} #[test] fn test_default_statuses() { @@ -26,3 +42,55 @@ 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] +#[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); +} From 1b1083669eb2205e502aec3595fd37e200093c9c Mon Sep 17 00:00:00 2001 From: exronnin Date: Sun, 31 May 2026 15:03:27 +0100 Subject: [PATCH 03/49] feat(contracts): add program read helpers --- .../contracts/quid-milestone-escrow/src/test.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index 0acb8bd..349d2fc 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -94,3 +94,12 @@ fn test_create_program_rejects_zero_amount() { 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); +} From 791deb45ae6cb7b412fc47c40fe068455bb604fd Mon Sep 17 00:00:00 2001 From: exronnin Date: Sun, 31 May 2026 15:09:03 +0100 Subject: [PATCH 04/49] fix: pass env.current_contract_address() argument directly instead of borrowing from the token transfer call. --- quid-contract/contracts/quid-milestone-escrow/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index 57e429e..a85a8e6 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -43,7 +43,7 @@ impl QuidMilestoneEscrowContract { token::Client::new(&env, &token).transfer( &sponsor, - &env.current_contract_address(), + env.current_contract_address(), &total_amount, ); From a98e9a6eab18a02a0a3886bc5899ebd48f3df301 Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Sun, 31 May 2026 15:56:23 +0100 Subject: [PATCH 05/49] feat(contracts): implement issue attestation --- quid-contract/Cargo.lock | 7 + .../contracts/quid-reputation/Cargo.toml | 25 +++ .../contracts/quid-reputation/README.md | 140 ++++++++++++++ .../contracts/quid-reputation/src/error.rs | 12 ++ .../contracts/quid-reputation/src/lib.rs | 178 ++++++++++++++++++ .../contracts/quid-reputation/src/test.rs | 143 ++++++++++++++ .../contracts/quid-reputation/src/types.rs | 29 +++ 7 files changed, 534 insertions(+) create mode 100644 quid-contract/contracts/quid-reputation/Cargo.toml create mode 100644 quid-contract/contracts/quid-reputation/README.md create mode 100644 quid-contract/contracts/quid-reputation/src/error.rs create mode 100644 quid-contract/contracts/quid-reputation/src/lib.rs create mode 100644 quid-contract/contracts/quid-reputation/src/test.rs create mode 100644 quid-contract/contracts/quid-reputation/src/types.rs 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-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..7febc42 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "quid-reputation" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[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/README.md b/quid-contract/contracts/quid-reputation/README.md new file mode 100644 index 0000000..352a51b --- /dev/null +++ b/quid-contract/contracts/quid-reputation/README.md @@ -0,0 +1,140 @@ +# 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..5606056 --- /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 QuidError { + NotAuthorized = 1, + AttestationNotFound = 2, + InvalidExpiryTime = 3, + AlreadyRevoked = 4, + AdminNotSet = 5, + InvalidLabel = 6, +} 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..4cf11ec --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,178 @@ +#![no_std] +use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; + +mod error; +mod types; + +use error::QuidError; +use types::{Attestation, DataKey}; + +#[contractevent(topics = ["attestation", "issued"])] +pub struct AttestationIssuedEvent { + pub attestation_id: u64, + pub issuer: Address, + pub subject: Address, +} + +#[contractevent(topics = ["attestation", "revoked"], data_format = "single-value")] +pub struct AttestationRevokedEvent { + pub attestation_id: u64, +} + +#[contract] +pub struct QuidReputationContract; + +#[contractimpl] +impl QuidReputationContract { + /// Bootstrap admin for the contract + pub fn bootstrap_admin(env: Env, admin: Address) -> Result<(), QuidError> { + // Only allow setting admin if not already set + if env + .storage() + .persistent() + .has(&DataKey::Admin) + { + return Err(QuidError::NotAuthorized); + } + + env.storage() + .persistent() + .set(&DataKey::Admin, &admin); + + env.storage() + .persistent() + .extend_ttl(&DataKey::Admin, 5184000, 5184000); + + Ok(()) + } + + /// Get the current admin + pub fn get_admin(env: Env) -> Result { + env.storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(QuidError::AdminNotSet) + } + + /// Issue an attestation for a subject + pub fn issue_attestation( + env: Env, + issuer: Address, + subject: Address, + kind: String, + label: String, + metadata_cid: Option, + expires_at: Option, + ) -> Result { + // Require issuer auth + issuer.require_auth(); + + // Validate label is not empty + if label.is_empty() { + return Err(QuidError::InvalidLabel); + } + + // Validate expiry time if provided + if let Some(expiry) = expires_at { + let now = env.ledger().timestamp(); + if expiry <= now { + return Err(QuidError::InvalidExpiryTime); + } + } + + // Get the next attestation id + let attestation_id = Self::get_next_attestation_id(&env); + + let issued_at = env.ledger().timestamp(); + + let attestation = Attestation { + id: attestation_id, + issuer: issuer.clone(), + subject: subject.clone(), + kind, + label, + metadata_cid, + issued_at, + expires_at, + revoked: false, + }; + + // Store the attestation + env.storage() + .persistent() + .set(&DataKey::Attestation(attestation_id), &attestation); + + env.storage() + .persistent() + .extend_ttl(&DataKey::Attestation(attestation_id), 5184000, 5184000); + + // Publish AttestationIssuedEvent + AttestationIssuedEvent { + attestation_id, + issuer, + subject, + } + .publish(&env); + + Ok(attestation_id) + } + + /// Get an attestation by id + pub fn get_attestation(env: Env, attestation_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Attestation(attestation_id)) + .ok_or(QuidError::AttestationNotFound) + } + + /// Revoke an attestation + pub fn revoke_attestation(env: Env, attestation_id: u64) -> Result<(), QuidError> { + let mut attestation = Self::get_attestation(env.clone(), attestation_id)?; + + // Require issuer auth + attestation.issuer.require_auth(); + + // Check if already revoked + if attestation.revoked { + return Err(QuidError::AlreadyRevoked); + } + + // Mark as revoked + attestation.revoked = true; + + // Store updated attestation + env.storage() + .persistent() + .set(&DataKey::Attestation(attestation_id), &attestation); + + env.storage() + .persistent() + .extend_ttl(&DataKey::Attestation(attestation_id), 5184000, 5184000); + + // Publish AttestationRevokedEvent + AttestationRevokedEvent { attestation_id }.publish(&env); + + Ok(()) + } + + /// Get the next attestation id (internal helper) + fn get_next_attestation_id(env: &Env) -> u64 { + let current: u64 = env + .storage() + .persistent() + .get(&DataKey::AttestationCount) + .unwrap_or(0); + + let next_id = current + 1; + + env.storage() + .persistent() + .set(&DataKey::AttestationCount, &next_id); + + env.storage() + .persistent() + .extend_ttl(&DataKey::AttestationCount, 5184000, 5184000); + + next_id + } +} 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..54ba98a --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -0,0 +1,143 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +#[test] +fn test_bootstrap_admin() { + let env = Env::default(); + let admin = Address::random(&env); + + let result = QuidReputationContract::bootstrap_admin(env.clone(), admin.clone()); + + assert!(result.is_ok()); + + let retrieved_admin = QuidReputationContract::get_admin(env).unwrap(); + assert_eq!(retrieved_admin, admin); +} + +#[test] +fn test_bootstrap_admin_twice_fails() { + let env = Env::default(); + let admin1 = Address::random(&env); + let admin2 = Address::random(&env); + + QuidReputationContract::bootstrap_admin(env.clone(), admin1).unwrap(); + let result = QuidReputationContract::bootstrap_admin(env, admin2); + + assert!(result.is_err()); +} + +#[test] +fn test_issue_attestation() { + let env = Env::default(); + let issuer = Address::random(&env); + let subject = Address::random(&env); + + let attestation_id = QuidReputationContract::issue_attestation( + env.clone(), + issuer.clone(), + subject.clone(), + String::from_slice(&env, "contributor"), + String::from_slice(&env, "Active contributor"), + Some(String::from_slice(&env, "QmExample123")), + None, + ) + .unwrap(); + + assert_eq!(attestation_id, 1); + + let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); + assert_eq!(attestation.issuer, issuer); + assert_eq!(attestation.subject, subject); + assert_eq!(attestation.revoked, false); +} + +#[test] +fn test_issue_attestation_with_expiry() { + let env = Env::default(); + let issuer = Address::random(&env); + let subject = Address::random(&env); + + let now = env.ledger().timestamp(); + let expiry = now + 86400; // 1 day from now + + let attestation_id = QuidReputationContract::issue_attestation( + env.clone(), + issuer.clone(), + subject.clone(), + String::from_slice(&env, "expert"), + String::from_slice(&env, "Expert reviewer"), + None, + Some(expiry), + ) + .unwrap(); + + let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); + assert_eq!(attestation.expires_at, Some(expiry)); +} + +#[test] +fn test_revoke_attestation() { + let env = Env::default(); + let issuer = Address::random(&env); + let subject = Address::random(&env); + + let attestation_id = QuidReputationContract::issue_attestation( + env.clone(), + issuer.clone(), + subject.clone(), + String::from_slice(&env, "contributor"), + String::from_slice(&env, "Active contributor"), + None, + None, + ) + .unwrap(); + + let result = QuidReputationContract::revoke_attestation(env.clone(), attestation_id); + assert!(result.is_ok()); + + let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); + assert_eq!(attestation.revoked, true); +} + +#[test] +fn test_empty_label_fails() { + let env = Env::default(); + let issuer = Address::random(&env); + let subject = Address::random(&env); + + let result = QuidReputationContract::issue_attestation( + env, + issuer, + subject, + String::from_slice(&env, "contributor"), + String::from_slice(&env, ""), + None, + None, + ); + + assert!(result.is_err()); +} + +#[test] +fn test_invalid_expiry_time_fails() { + let env = Env::default(); + let issuer = Address::random(&env); + let subject = Address::random(&env); + + let now = env.ledger().timestamp(); + let past_expiry = now - 1000; // In the past + + let result = QuidReputationContract::issue_attestation( + env, + issuer, + subject, + String::from_slice(&env, "contributor"), + String::from_slice(&env, "Active contributor"), + None, + Some(past_expiry), + ); + + assert!(result.is_err()); +} 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..c7a44db --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,29 @@ +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 kind: String, + pub label: String, + pub metadata_cid: Option, + pub issued_at: u64, + pub expires_at: Option, + pub revoked: bool, +} + +#[contracttype] +pub enum DataKey { + Attestation(u64), + AttestationCount, + Admin, +} + +#[contracttype] +pub enum AttestationKind { + Contributor, + Expert, + Reviewer, +} From 57ddc93e220f3306a71e3da75eb56e770d564674 Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Sun, 31 May 2026 17:08:20 +0100 Subject: [PATCH 06/49] feat(contracts): add reputation profile helpers - Scaffold quid-reputation crate with soroban-sdk dependency - Add Profile struct and DataKey enum (Admin, Profile(Address)) - Add ReputationError (AdminNotSet, NotAdmin, ProfileNotFound) - Implement set_admin / get_admin for admin bootstrap and getter - Implement public get_profile returning ProfileNotFound when missing - Add internal require_admin helper for mutation path auth checks - Add internal store_profile helper with TTL extension - Add internal load_or_default helper returning zeroed profile - Add unit tests covering all public and internal paths --- quid-contract/Cargo.lock | 7 + .../contracts/quid-reputation/Cargo.toml | 15 +++ .../contracts/quid-reputation/src/error.rs | 12 ++ .../contracts/quid-reputation/src/lib.rs | 120 ++++++++++++++++++ .../contracts/quid-reputation/src/test.rs | 86 +++++++++++++ .../contracts/quid-reputation/src/types.rs | 24 ++++ 6 files changed, 264 insertions(+) create mode 100644 quid-contract/contracts/quid-reputation/Cargo.toml create mode 100644 quid-contract/contracts/quid-reputation/src/error.rs create mode 100644 quid-contract/contracts/quid-reputation/src/lib.rs create mode 100644 quid-contract/contracts/quid-reputation/src/test.rs create mode 100644 quid-contract/contracts/quid-reputation/src/types.rs diff --git a/quid-contract/Cargo.lock b/quid-contract/Cargo.lock index cd035cc..63a460b 100644 --- a/quid-contract/Cargo.lock +++ b/quid-contract/Cargo.lock @@ -989,6 +989,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "quid-reputation" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "quid-store" version = "0.0.0" diff --git a/quid-contract/contracts/quid-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..6aa4e38 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "quid-reputation" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } 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..746897a --- /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 { + /// No admin has been bootstrapped yet. + AdminNotSet = 1, + /// Caller is not the contract admin. + NotAdmin = 2, + /// No profile exists for the given subject address. + ProfileNotFound = 3, +} 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..4679945 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,120 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, Address, Env}; + +mod error; +mod types; + +use error::ReputationError; +use types::{DataKey, Profile}; + +/// TTL extension applied to every profile write (roughly 60 days in ledgers). +#[allow(dead_code)] +const PROFILE_TTL_LEDGERS: u32 = 5_184_000; + +#[contract] +pub struct QuidReputationContract; + +#[contractimpl] +impl QuidReputationContract { + // ------------------------------------------------------------------------- + // Admin bootstrap + // ------------------------------------------------------------------------- + + /// Bootstrap the contract admin. May only be called once; subsequent calls + /// require the existing admin to authorise the change. + pub fn set_admin(env: Env, new_admin: Address) -> Result<(), ReputationError> { + if let Some(current_admin) = env + .storage() + .instance() + .get::(&DataKey::Admin) + { + // Admin already set – only the current admin may rotate it. + current_admin.require_auth(); + } else { + // First-time bootstrap – require the new admin to sign. + new_admin.require_auth(); + } + + env.storage() + .instance() + .set(&DataKey::Admin, &new_admin); + + Ok(()) + } + + /// Return the current admin address. + pub fn get_admin(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::AdminNotSet) + } + + // ------------------------------------------------------------------------- + // Public profile getter + // ------------------------------------------------------------------------- + + /// Fetch the reputation profile for `subject`. + /// Returns `ProfileNotFound` when no profile has been stored yet. + pub fn get_profile(env: Env, subject: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::Profile(subject)) + .ok_or(ReputationError::ProfileNotFound) + } +} + +// ------------------------------------------------------------------------- +// Internal helpers (used by every profile mutation path) +// ------------------------------------------------------------------------- + +#[allow(dead_code)] +impl QuidReputationContract { + /// Require that `caller` is the bootstrapped admin. + /// Panics (via `require_auth`) if the caller is not the admin, and returns + /// `AdminNotSet` if no admin has been bootstrapped yet. + pub(crate) fn require_admin(env: &Env, caller: &Address) -> Result<(), ReputationError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::AdminNotSet)?; + + admin.require_auth(); + + // Ensure the supplied caller matches the stored admin. + if *caller != admin { + return Err(ReputationError::NotAdmin); + } + + Ok(()) + } + + /// Persist `profile` to persistent storage and extend its TTL. + 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); + } + + /// Load the profile for `subject`, returning a zeroed default when none + /// exists yet. Mutation methods should call this instead of `get_profile` + /// so that a missing profile is treated as a fresh slate rather than an + /// error. + 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; 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..3cd22f5 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -0,0 +1,86 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Env}; + +use crate::{error::ReputationError, types::Profile, QuidReputationContract, QuidReputationContractClient}; + +fn setup() -> (Env, Address, QuidReputationContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + (env, admin, client) +} + +// ------------------------------------------------------------------------- +// Admin bootstrap tests +// ------------------------------------------------------------------------- + +#[test] +fn test_set_and_get_admin() { + let (_env, admin, client) = setup(); + client.set_admin(&admin); + assert_eq!(client.get_admin(), admin); +} + +#[test] +fn test_get_admin_not_set_returns_error() { + let (_env, _admin, client) = setup(); + let result = client.try_get_admin(); + assert_eq!(result, Err(Ok(ReputationError::AdminNotSet))); +} + +// ------------------------------------------------------------------------- +// get_profile tests +// ------------------------------------------------------------------------- + +#[test] +fn test_get_profile_not_found() { + let (env, admin, client) = setup(); + client.set_admin(&admin); + let subject = Address::generate(&env); + let result = client.try_get_profile(&subject); + assert_eq!(result, Err(Ok(ReputationError::ProfileNotFound))); +} + +#[test] +fn test_store_and_get_profile() { + let (env, admin, client) = setup(); + client.set_admin(&admin); + + let subject = Address::generate(&env); + + // Use the internal helpers via a direct contract call simulation. + // We store a profile manually and then read it back via the public getter. + env.as_contract(&client.address, || { + let profile = Profile { + subject: subject.clone(), + score: 42, + missions_completed: 3, + missions_created: 1, + }; + QuidReputationContract::store_profile(&env, &profile); + }); + + let fetched = client.get_profile(&subject); + assert_eq!(fetched.score, 42); + assert_eq!(fetched.missions_completed, 3); + assert_eq!(fetched.missions_created, 1); +} + +#[test] +fn test_load_or_default_returns_zeroed_profile() { + let (env, admin, client) = setup(); + client.set_admin(&admin); + + let subject = Address::generate(&env); + + env.as_contract(&client.address, || { + let profile = QuidReputationContract::load_or_default(&env, subject.clone()); + assert_eq!(profile.subject, subject); + assert_eq!(profile.score, 0); + assert_eq!(profile.missions_completed, 0); + assert_eq!(profile.missions_created, 0); + }); +} 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..21f6e71 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,24 @@ +use soroban_sdk::{contracttype, Address}; + +/// Storage keys used by the reputation contract. +#[contracttype] +pub enum DataKey { + /// The contract administrator address. + Admin, + /// Per-subject reputation profile. + Profile(Address), +} + +/// On-chain reputation profile for a single subject address. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Profile { + /// The address this profile belongs to. + pub subject: Address, + /// Cumulative reputation score (starts at 0). + pub score: i64, + /// Total number of completed missions. + pub missions_completed: u32, + /// Total number of missions created (for creators). + pub missions_created: u32, +} From 5c324e14d1bd40872569dc94ef5e69c10579004c Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Sun, 31 May 2026 17:25:45 +0100 Subject: [PATCH 07/49] style: fix rustfmt formatting in quid-reputation --- quid-contract/contracts/quid-reputation/src/lib.rs | 4 +--- quid-contract/contracts/quid-reputation/src/test.rs | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 4679945..289d67d 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -36,9 +36,7 @@ impl QuidReputationContract { new_admin.require_auth(); } - env.storage() - .instance() - .set(&DataKey::Admin, &new_admin); + env.storage().instance().set(&DataKey::Admin, &new_admin); Ok(()) } diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 3cd22f5..2c25831 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -2,7 +2,9 @@ use soroban_sdk::{testutils::Address as _, Address, Env}; -use crate::{error::ReputationError, types::Profile, QuidReputationContract, QuidReputationContractClient}; +use crate::{ + error::ReputationError, types::Profile, QuidReputationContract, QuidReputationContractClient, +}; fn setup() -> (Env, Address, QuidReputationContractClient<'static>) { let env = Env::default(); From 72584e3995ea1f4f649073839def39f249e867db Mon Sep 17 00:00:00 2001 From: teetyff Date: Mon, 1 Jun 2026 01:28:08 +0100 Subject: [PATCH 08/49] test(contracts): add reputation profile tests - Add quid-reputation contract package (Cargo.toml, error.rs, types.rs, lib.rs) - Add src/test.rs with 18 tests covering: - Admin bootstrap: set_admin and get_admin flows - Profile upsert: create, overwrite, owner assertion, auth guard - increment_success: single, multiple, missing profile, auth guard - record_rejection: single, multiple, missing profile, auth guard - Combined: full lifecycle and independent profile isolation Closes #199 --- quid-contract/Cargo.lock | 7 + .../contracts/quid-reputation/Cargo.toml | 15 + .../contracts/quid-reputation/src/error.rs | 10 + .../contracts/quid-reputation/src/lib.rs | 151 ++++++++ .../contracts/quid-reputation/src/test.rs | 334 ++++++++++++++++++ .../contracts/quid-reputation/src/types.rs | 24 ++ 6 files changed, 541 insertions(+) create mode 100644 quid-contract/contracts/quid-reputation/Cargo.toml create mode 100644 quid-contract/contracts/quid-reputation/src/error.rs create mode 100644 quid-contract/contracts/quid-reputation/src/lib.rs create mode 100644 quid-contract/contracts/quid-reputation/src/test.rs create mode 100644 quid-contract/contracts/quid-reputation/src/types.rs diff --git a/quid-contract/Cargo.lock b/quid-contract/Cargo.lock index cd035cc..63a460b 100644 --- a/quid-contract/Cargo.lock +++ b/quid-contract/Cargo.lock @@ -989,6 +989,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "quid-reputation" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "quid-store" version = "0.0.0" diff --git a/quid-contract/contracts/quid-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..6aa4e38 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "quid-reputation" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } 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..9d06583 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -0,0 +1,10 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ReputationError { + NotAuthorized = 1, + ProfileNotFound = 2, + AdminAlreadySet = 3, + InvalidScore = 4, +} 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..5a7cc6f --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,151 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, Address, Env}; + +mod error; +mod types; + +use error::ReputationError; +use types::{DataKey, ReputationProfile}; + +#[contract] +pub struct QuidReputationContract; + +#[contractimpl] +impl QuidReputationContract { + // ----------------------------------------------------------------------- + // Admin bootstrap + // ----------------------------------------------------------------------- + + /// Bootstrap the contract admin. Can only be called once; subsequent + /// calls return `AdminAlreadySet`. + pub fn set_admin(env: Env, admin: Address) -> Result<(), ReputationError> { + admin.require_auth(); + + if env + .storage() + .instance() + .has(&DataKey::Admin) + { + return Err(ReputationError::AdminAlreadySet); + } + + env.storage().instance().set(&DataKey::Admin, &admin); + Ok(()) + } + + /// Return the current admin address. + pub fn get_admin(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::NotAuthorized) + } + + // ----------------------------------------------------------------------- + // Profile management + // ----------------------------------------------------------------------- + + /// Create or fully replace a reputation profile for `owner`. + /// Only the admin may call this. + pub fn upsert_profile( + env: Env, + owner: Address, + success_count: u32, + rejection_count: u32, + ) -> Result<(), ReputationError> { + Self::require_admin(&env)?; + + let profile = ReputationProfile { + owner: owner.clone(), + success_count, + rejection_count, + last_updated: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DataKey::Profile(owner.clone()), &profile); + + env.storage().persistent().extend_ttl( + &DataKey::Profile(owner), + 5_184_000, + 5_184_000, + ); + + Ok(()) + } + + /// Fetch the reputation profile for `owner`. + pub fn get_profile(env: Env, owner: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::Profile(owner)) + .ok_or(ReputationError::ProfileNotFound) + } + + // ----------------------------------------------------------------------- + // Mutation helpers + // ----------------------------------------------------------------------- + + /// Increment the `success_count` of an existing profile by one. + /// Only the admin may call this. + pub fn increment_success(env: Env, owner: Address) -> Result<(), ReputationError> { + Self::require_admin(&env)?; + + let mut profile = Self::get_profile(env.clone(), owner.clone())?; + profile.success_count = profile.success_count.saturating_add(1); + profile.last_updated = env.ledger().timestamp(); + + env.storage() + .persistent() + .set(&DataKey::Profile(owner.clone()), &profile); + + env.storage().persistent().extend_ttl( + &DataKey::Profile(owner), + 5_184_000, + 5_184_000, + ); + + Ok(()) + } + + /// Increment the `rejection_count` of an existing profile by one. + /// Only the admin may call this. + pub fn record_rejection(env: Env, owner: Address) -> Result<(), ReputationError> { + Self::require_admin(&env)?; + + let mut profile = Self::get_profile(env.clone(), owner.clone())?; + profile.rejection_count = profile.rejection_count.saturating_add(1); + profile.last_updated = env.ledger().timestamp(); + + env.storage() + .persistent() + .set(&DataKey::Profile(owner.clone()), &profile); + + env.storage().persistent().extend_ttl( + &DataKey::Profile(owner), + 5_184_000, + 5_184_000, + ); + + Ok(()) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + fn require_admin(env: &Env) -> Result<(), ReputationError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::NotAuthorized)?; + + admin.require_auth(); + Ok(()) + } +} + +mod test; 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..cb36904 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -0,0 +1,334 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +// --------------------------------------------------------------------------- +// Shared test environment +// --------------------------------------------------------------------------- + +/// Registers the contract, mocks all auth, and returns a ready-to-use tuple +/// of `(env, contract_id, admin_address)`. +fn setup_env() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuidReputationContract, ()); + let admin = Address::generate(&env); + + (env, contract_id, admin) +} + +// --------------------------------------------------------------------------- +// Admin bootstrap tests +// --------------------------------------------------------------------------- + +#[test] +fn test_set_admin_succeeds_on_first_call() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + // Bootstrap should succeed when no admin is set yet. + client.set_admin(&admin); + + let stored_admin = client.get_admin(); + assert_eq!(stored_admin, admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_set_admin_fails_when_already_set() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + // A second call — even with a different address — must be rejected. + let other = Address::generate(&env); + client.set_admin(&other); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_get_admin_fails_when_not_set() { + let (env, contract_id, _admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + // No admin bootstrapped yet — should return NotAuthorized. + client.get_admin(); +} + +// --------------------------------------------------------------------------- +// Profile upsert tests +// --------------------------------------------------------------------------- + +#[test] +fn test_upsert_profile_creates_new_profile() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user = Address::generate(&env); + client.upsert_profile(&user, &5, &2); + + let profile = client.get_profile(&user); + assert_eq!(profile.owner, user); + assert_eq!(profile.success_count, 5); + assert_eq!(profile.rejection_count, 2); +} + +#[test] +fn test_upsert_profile_overwrites_existing_profile() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user = Address::generate(&env); + + // Initial upsert. + client.upsert_profile(&user, &3, &1); + + // Overwrite with new values. + client.upsert_profile(&user, &10, &4); + + let profile = client.get_profile(&user); + assert_eq!(profile.success_count, 10); + assert_eq!(profile.rejection_count, 4); +} + +#[test] +fn test_upsert_profile_stores_correct_owner() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user = Address::generate(&env); + client.upsert_profile(&user, &0, &0); + + let profile = client.get_profile(&user); + assert_eq!(profile.owner, user); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_upsert_profile_requires_admin() { + let (env, contract_id, _admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + // No admin set — any mutation must fail with NotAuthorized. + let user = Address::generate(&env); + client.upsert_profile(&user, &1, &0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_get_profile_fails_when_not_found() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let unknown = Address::generate(&env); + client.get_profile(&unknown); +} + +// --------------------------------------------------------------------------- +// increment_success tests +// --------------------------------------------------------------------------- + +#[test] +fn test_increment_success_increases_count_by_one() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user = Address::generate(&env); + client.upsert_profile(&user, &3, &1); + + client.increment_success(&user); + + let profile = client.get_profile(&user); + assert_eq!(profile.success_count, 4); + // rejection_count must remain unchanged. + assert_eq!(profile.rejection_count, 1); +} + +#[test] +fn test_increment_success_multiple_times() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user = Address::generate(&env); + client.upsert_profile(&user, &0, &0); + + client.increment_success(&user); + client.increment_success(&user); + client.increment_success(&user); + + let profile = client.get_profile(&user); + assert_eq!(profile.success_count, 3); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_increment_success_fails_when_profile_missing() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let unknown = Address::generate(&env); + client.increment_success(&unknown); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_increment_success_requires_admin() { + let (env, contract_id, _admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + // No admin bootstrapped — must fail. + let user = Address::generate(&env); + client.increment_success(&user); +} + +// --------------------------------------------------------------------------- +// record_rejection tests +// --------------------------------------------------------------------------- + +#[test] +fn test_record_rejection_increases_count_by_one() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user = Address::generate(&env); + client.upsert_profile(&user, &2, &0); + + client.record_rejection(&user); + + let profile = client.get_profile(&user); + assert_eq!(profile.rejection_count, 1); + // success_count must remain unchanged. + assert_eq!(profile.success_count, 2); +} + +#[test] +fn test_record_rejection_multiple_times() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user = Address::generate(&env); + client.upsert_profile(&user, &0, &0); + + client.record_rejection(&user); + client.record_rejection(&user); + + let profile = client.get_profile(&user); + assert_eq!(profile.rejection_count, 2); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_record_rejection_fails_when_profile_missing() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let unknown = Address::generate(&env); + client.record_rejection(&unknown); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_record_rejection_requires_admin() { + let (env, contract_id, _admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + // No admin bootstrapped — must fail. + let user = Address::generate(&env); + client.record_rejection(&user); +} + +// --------------------------------------------------------------------------- +// Combined flow tests +// --------------------------------------------------------------------------- + +#[test] +fn test_full_profile_lifecycle() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + // 1. Bootstrap admin. + client.set_admin(&admin); + assert_eq!(client.get_admin(), admin); + + // 2. Create a fresh profile. + let user = Address::generate(&env); + client.upsert_profile(&user, &0, &0); + + let profile = client.get_profile(&user); + assert_eq!(profile.success_count, 0); + assert_eq!(profile.rejection_count, 0); + + // 3. Record two successes. + client.increment_success(&user); + client.increment_success(&user); + + let profile = client.get_profile(&user); + assert_eq!(profile.success_count, 2); + assert_eq!(profile.rejection_count, 0); + + // 4. Record one rejection. + client.record_rejection(&user); + + let profile = client.get_profile(&user); + assert_eq!(profile.success_count, 2); + assert_eq!(profile.rejection_count, 1); + + // 5. Upsert resets the counters. + client.upsert_profile(&user, &10, &5); + + let profile = client.get_profile(&user); + assert_eq!(profile.success_count, 10); + assert_eq!(profile.rejection_count, 5); +} + +#[test] +fn test_independent_profiles_do_not_interfere() { + let (env, contract_id, admin) = setup_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.set_admin(&admin); + + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + + client.upsert_profile(&user_a, &0, &0); + client.upsert_profile(&user_b, &0, &0); + + // Mutate only user_a. + client.increment_success(&user_a); + client.increment_success(&user_a); + client.record_rejection(&user_a); + + // user_b must be untouched. + let profile_b = client.get_profile(&user_b); + assert_eq!(profile_b.success_count, 0); + assert_eq!(profile_b.rejection_count, 0); + + // user_a must reflect its own mutations. + let profile_a = client.get_profile(&user_a); + assert_eq!(profile_a.success_count, 2); + assert_eq!(profile_a.rejection_count, 1); +} 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..7effffa --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,24 @@ +use soroban_sdk::{contracttype, Address}; + +/// On-chain reputation profile for a single user address. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReputationProfile { + /// The wallet this profile belongs to. + pub owner: Address, + /// Number of successfully completed missions / approved submissions. + pub success_count: u32, + /// Number of rejected submissions. + pub rejection_count: u32, + /// Ledger timestamp of the last mutation. + pub last_updated: u64, +} + +/// Storage keys used by the reputation contract. +#[contracttype] +pub enum DataKey { + /// Admin address that is allowed to mutate profiles. + Admin, + /// Per-user reputation profile, keyed by wallet address. + Profile(Address), +} From 7779ec535affe3a3cca3aac4e534476b81317958 Mon Sep 17 00:00:00 2001 From: teetyff Date: Mon, 1 Jun 2026 01:46:02 +0100 Subject: [PATCH 09/49] style(contracts): fix rustfmt formatting in quid-reputation --- .../contracts/quid-reputation/src/lib.rs | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 5a7cc6f..af19e47 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -22,11 +22,7 @@ impl QuidReputationContract { pub fn set_admin(env: Env, admin: Address) -> Result<(), ReputationError> { admin.require_auth(); - if env - .storage() - .instance() - .has(&DataKey::Admin) - { + if env.storage().instance().has(&DataKey::Admin) { return Err(ReputationError::AdminAlreadySet); } @@ -67,11 +63,9 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Profile(owner.clone()), &profile); - env.storage().persistent().extend_ttl( - &DataKey::Profile(owner), - 5_184_000, - 5_184_000, - ); + env.storage() + .persistent() + .extend_ttl(&DataKey::Profile(owner), 5_184_000, 5_184_000); Ok(()) } @@ -101,11 +95,9 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Profile(owner.clone()), &profile); - env.storage().persistent().extend_ttl( - &DataKey::Profile(owner), - 5_184_000, - 5_184_000, - ); + env.storage() + .persistent() + .extend_ttl(&DataKey::Profile(owner), 5_184_000, 5_184_000); Ok(()) } @@ -123,11 +115,9 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Profile(owner.clone()), &profile); - env.storage().persistent().extend_ttl( - &DataKey::Profile(owner), - 5_184_000, - 5_184_000, - ); + env.storage() + .persistent() + .extend_ttl(&DataKey::Profile(owner), 5_184_000, 5_184_000); Ok(()) } From 8500bf6a782b87104f6242a4195383c05a99c6bf Mon Sep 17 00:00:00 2001 From: exronnin Date: Mon, 1 Jun 2026 04:01:29 +0100 Subject: [PATCH 10/49] feat(contracts): implement add milestone --- .../quid-milestone-escrow/src/lib.rs | 78 ++++++++++++++++++- .../quid-milestone-escrow/src/test.rs | 69 ++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index a85a8e6..bf01d13 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -6,7 +6,7 @@ mod error; pub mod types; use error::MilestoneEscrowError; -use types::{DataKey, MilestoneStatus, Program, ProgramStatus}; +use types::{DataKey, Milestone, MilestoneStatus, Program, ProgramStatus}; #[contractevent(topics = ["program", "created"])] pub struct ProgramCreatedEvent { @@ -21,6 +21,13 @@ pub struct ProgramStatusChangedEvent { pub status: ProgramStatus, } +#[contractevent(topics = ["milestone", "added"])] +pub struct MilestoneAddedEvent { + pub program_id: u64, + pub milestone_id: u64, + pub amount: i128, +} + #[contract] pub struct QuidMilestoneEscrowContract; @@ -86,6 +93,75 @@ impl QuidMilestoneEscrowContract { .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::InvalidState) + } + pub fn get_program_status(env: Env) -> ProgramStatus { env.storage() .persistent() diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index 349d2fc..f3bb0f8 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -85,6 +85,75 @@ fn test_create_program_funds_and_stores_active_program() { 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, #2)")] fn test_create_program_rejects_zero_amount() { From bb0531ec7a6c3270e99ac60adca7413a24a14f76 Mon Sep 17 00:00:00 2001 From: exronnin Date: Mon, 1 Jun 2026 04:05:03 +0100 Subject: [PATCH 11/49] feat(contracts): add milestone read helper --- .../contracts/quid-milestone-escrow/src/error.rs | 1 + quid-contract/contracts/quid-milestone-escrow/src/lib.rs | 2 +- .../contracts/quid-milestone-escrow/src/test.rs | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/error.rs b/quid-contract/contracts/quid-milestone-escrow/src/error.rs index 8fd8f70..3aea388 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/error.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/error.rs @@ -6,4 +6,5 @@ pub enum MilestoneEscrowError { InvalidState = 1, InvalidAmount = 2, ProgramNotFound = 3, + MilestoneNotFound = 4, } diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index bf01d13..875b109 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -159,7 +159,7 @@ impl QuidMilestoneEscrowContract { env.storage() .persistent() .get(&DataKey::Milestone(program_id, milestone_id)) - .ok_or(MilestoneEscrowError::InvalidState) + .ok_or(MilestoneEscrowError::MilestoneNotFound) } pub fn get_program_status(env: Env) -> ProgramStatus { diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index f3bb0f8..b436aba 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -154,6 +154,15 @@ fn test_add_milestone_rejects_over_allocation() { ); } +#[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() { From 490f8b539527e07d51475ef7e0487503c7dabd14 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 06:01:38 +0100 Subject: [PATCH 12/49] scaffold reputation --- quid-contract/Cargo.lock | 7 + .../contracts/quid-reputation/Cargo.toml | 15 ++ .../contracts/quid-reputation/Makefile | 16 ++ .../contracts/quid-reputation/README.md | 119 ++++++++++++ .../contracts/quid-reputation/src/error.rs | 10 + .../contracts/quid-reputation/src/lib.rs | 139 ++++++++++++++ .../contracts/quid-reputation/src/test.rs | 174 ++++++++++++++++++ .../contracts/quid-reputation/src/types.rs | 20 ++ 8 files changed, 500 insertions(+) create mode 100644 quid-contract/contracts/quid-reputation/Cargo.toml create mode 100644 quid-contract/contracts/quid-reputation/Makefile create mode 100644 quid-contract/contracts/quid-reputation/README.md create mode 100644 quid-contract/contracts/quid-reputation/src/error.rs create mode 100644 quid-contract/contracts/quid-reputation/src/lib.rs create mode 100644 quid-contract/contracts/quid-reputation/src/test.rs create mode 100644 quid-contract/contracts/quid-reputation/src/types.rs diff --git a/quid-contract/Cargo.lock b/quid-contract/Cargo.lock index cd035cc..63a460b 100644 --- a/quid-contract/Cargo.lock +++ b/quid-contract/Cargo.lock @@ -989,6 +989,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "quid-reputation" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "quid-store" version = "0.0.0" diff --git a/quid-contract/contracts/quid-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..6aa4e38 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "quid-reputation" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } 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..795d150 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/README.md @@ -0,0 +1,119 @@ +# Quid Reputation Contract + +A Soroban smart contract for managing attestations on the Stellar blockchain. + +## Overview + +The Quid Reputation Contract allows issuers to create attestations for subjects, which can be used to build reputation systems. Attestations can be revoked by either the original issuer or the contract admin. + +## Features + +- **Initialize**: Set up the contract with an admin address +- **Issue Attestation**: Create new attestations for subjects +- **Revoke Attestation**: Revoke attestations (issuer or admin only) +- **Query Attestations**: Retrieve attestation details and check existence + +## Contract Structure + +``` +quid-reputation/ +├── Cargo.toml # Package configuration +├── Makefile # Build and test commands +├── README.md # This file +└── src/ + ├── lib.rs # Main contract implementation + ├── types.rs # Data structures and storage keys + ├── error.rs # Error definitions + └── test.rs # Unit tests +``` + +## Data Types + +### Attestation +```rust +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, +} +``` + +### Errors +- `NotAuthorized` (1): Caller is not authorized to perform the action +- `AttestationNotFound` (2): Attestation does not exist +- `AlreadyRevoked` (3): Attestation has already been revoked +- `InvalidInput` (4): Invalid input parameters + +## Building + +```bash +make build +``` + +Or using cargo directly: +```bash +cargo build -p quid-reputation +``` + +## Testing + +```bash +make test +``` + +Or using cargo directly: +```bash +cargo test -p quid-reputation +``` + +## Usage + +### Initialize the Contract +```rust +client.initialize(&admin_address); +``` + +### Issue an Attestation +```rust +let attestation_id = client.issue_attestation( + &issuer, + &subject, + &attestation_type, + &data_cid +); +``` + +### Revoke an Attestation +```rust +// By issuer +client.revoke_attestation(&issuer, &attestation_id); + +// Or by admin +client.revoke_attestation(&admin, &attestation_id); +``` + +### Get an Attestation +```rust +let attestation = client.get_attestation(&attestation_id); +``` + +## Authorization + +- **Initialize**: Requires authorization from the admin address +- **Issue Attestation**: Requires authorization from the issuer +- **Revoke Attestation**: Requires authorization from either the original issuer or the contract admin +- **Query Operations**: No authorization required + +## Storage + +The contract uses: +- **Instance Storage**: For admin address and attestation count +- **Persistent Storage**: For attestation data with TTL of 5,184,000 ledgers (~60 days) + +## License + +See the main project LICENSE file. 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..1269404 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -0,0 +1,10 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ReputationError { + NotAuthorized = 1, + AttestationNotFound = 2, + AlreadyRevoked = 3, + InvalidInput = 4, +} 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..6e42b6c --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,139 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, Address, Env, String}; + +mod error; +mod types; + +use error::ReputationError; +use types::{Attestation, DataKey}; + +#[contract] +pub struct QuidReputationContract; + +#[contractimpl] +impl QuidReputationContract { + /// Initialize the contract with an admin address + 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(()) + } + + /// Get the admin address + pub fn get_admin(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::NotAuthorized) + } + + /// Issue a new attestation + 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), 5184000, 5184000); + + Ok(attestation_id) + } + + /// Get an attestation by ID + pub fn get_attestation(env: Env, attestation_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Attestation(attestation_id)) + .ok_or(ReputationError::AttestationNotFound) + } + + /// Revoke an attestation (issuer or admin only) + 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); + } + + // Allow revocation by the original issuer or the contract admin + 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), 5184000, 5184000); + + Ok(()) + } + + /// Get the total number of attestations + pub fn get_attestation_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::AttestationCount) + .unwrap_or(0) + } + + /// Check if an attestation exists + pub fn attestation_exists(env: Env, attestation_id: u64) -> bool { + env.storage() + .persistent() + .has(&DataKey::Attestation(attestation_id)) + } + + // Private helper function to get the next attestation ID + 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 + } +} + +mod test; 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..626f444 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -0,0 +1,174 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +fn setup_test_env() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuidReputationContract, ()); + let admin = Address::generate(&env); + + let client = QuidReputationContractClient::new(&env, &contract_id); + client.initialize(&admin); + + (env, contract_id, admin) +} + +#[test] +fn test_initialize() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + + client.initialize(&admin); + + let stored_admin = client.get_admin(); + assert_eq!(stored_admin, admin); +} + +#[test] +fn test_issue_attestation() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + assert_eq!(attestation_id, 1); + + let attestation = client.get_attestation(&attestation_id); + assert_eq!(attestation.issuer, issuer); + assert_eq!(attestation.subject, subject); + assert_eq!(attestation.attestation_type, attestation_type); + assert_eq!(attestation.data_cid, data_cid); + assert_eq!(attestation.revoked, false); +} + +#[test] +fn test_revoke_attestation_by_issuer() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + // Issuer revokes their own attestation + client.revoke_attestation(&issuer, &attestation_id); + + let attestation = client.get_attestation(&attestation_id); + assert_eq!(attestation.revoked, true); +} + +#[test] +fn test_revoke_attestation_by_admin() { + let (env, contract_id, admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + // Admin revokes the attestation + client.revoke_attestation(&admin, &attestation_id); + + let attestation = client.get_attestation(&attestation_id); + assert_eq!(attestation.revoked, true); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_revoke_attestation_unauthorized() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + let unauthorized = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + // Unauthorized user tries to revoke + client.revoke_attestation(&unauthorized, &attestation_id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_revoke_already_revoked_attestation() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + // Revoke once + client.revoke_attestation(&issuer, &attestation_id); + + // Try to revoke again + client.revoke_attestation(&issuer, &attestation_id); +} + +#[test] +fn test_attestation_count() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + assert_eq!(client.get_attestation_count(), 0); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + assert_eq!(client.get_attestation_count(), 1); + + client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + assert_eq!(client.get_attestation_count(), 2); +} + +#[test] +fn test_attestation_exists() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + assert_eq!(client.attestation_exists(&1), false); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + assert_eq!(client.attestation_exists(&attestation_id), true); +} 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..d531820 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,20 @@ +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, +} + +#[contracttype] +pub enum DataKey { + Attestation(u64), + AttestationCount, + Admin, +} From e27cab1d32210545224936698e2b063d93501441 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 06:06:55 +0100 Subject: [PATCH 13/49] fixed ci --- .../contracts/quid-reputation/src/lib.rs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 6e42b6c..1d79538 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -15,11 +15,11 @@ impl QuidReputationContract { /// Initialize the contract with an admin address 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(()) } @@ -59,9 +59,11 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); - env.storage() - .persistent() - .extend_ttl(&DataKey::Attestation(attestation_id), 5184000, 5184000); + env.storage().persistent().extend_ttl( + &DataKey::Attestation(attestation_id), + 5184000, + 5184000, + ); Ok(attestation_id) } @@ -99,9 +101,11 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); - env.storage() - .persistent() - .extend_ttl(&DataKey::Attestation(attestation_id), 5184000, 5184000); + env.storage().persistent().extend_ttl( + &DataKey::Attestation(attestation_id), + 5184000, + 5184000, + ); Ok(()) } From 8aa671d5d8a6900a8c9fe0f40fc5c4673088d33b Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 06:09:29 +0100 Subject: [PATCH 14/49] removed unimportant file --- .../quid-reputation/IMPLEMENTATION.md | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 quid-contract/contracts/quid-reputation/IMPLEMENTATION.md diff --git a/quid-contract/contracts/quid-reputation/IMPLEMENTATION.md b/quid-contract/contracts/quid-reputation/IMPLEMENTATION.md deleted file mode 100644 index 130acd9..0000000 --- a/quid-contract/contracts/quid-reputation/IMPLEMENTATION.md +++ /dev/null @@ -1,59 +0,0 @@ -# Quid Reputation Contract - Implementation Summary - -## What Was Done - -Created the base Soroban crate structure for the reputation contract with full attestation revocation functionality. - -## Files Created - -``` -quid-reputation/ -├── Cargo.toml # Package config with soroban-sdk workspace dependency -├── Makefile # Build and test commands -├── README.md # Contract documentation -└── src/ - ├── lib.rs # Contract implementation with revoke_attestation - ├── types.rs # Attestation struct and DataKey enum - ├── error.rs # ReputationError definitions - └── test.rs # 8 passing unit tests -``` - -## Core Implementation - -### `revoke_attestation(env, caller, attestation_id)` - -- ✅ Requires caller authentication -- ✅ Allows revocation by original issuer OR contract admin -- ✅ Marks attestation as `revoked = true` and persists -- ✅ Returns `NotAuthorized` for unauthorized callers -- ✅ Returns `AlreadyRevoked` if already revoked - -### Contract Functions - -- `initialize(admin)` - Set admin address -- `issue_attestation(issuer, subject, type, cid)` - Create attestations -- `revoke_attestation(caller, id)` - Revoke attestations -- `get_attestation(id)` - Query attestations -- `get_admin()` - Get admin address -- `get_attestation_count()` - Get total count -- `attestation_exists(id)` - Check existence - -## Test Results - -```bash -cargo test -p quid-reputation -``` - -**Result:** ✅ 8 tests passed - -- Issuer can revoke their attestations -- Admin can revoke any attestation -- Unauthorized callers receive `NotAuthorized` -- Already-revoked attestations cannot be revoked again - -## Acceptance Criteria - -- ✅ `cargo test -p quid-reputation` compiles and passes -- ✅ Single contract implementation surface -- ✅ Source files separated: lib.rs, types.rs, error.rs, test.rs -- ✅ Revoke flow with issuer-or-admin authorization implemented From 08882805857e009307a97254b31a8c93375c7355 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 06:26:45 +0100 Subject: [PATCH 15/49] reputation profile model and storage keys --- .../contracts/quid-reputation/src/error.rs | 1 + .../contracts/quid-reputation/src/lib.rs | 30 ++++- .../contracts/quid-reputation/src/test.rs | 115 +++++++++++++++++- .../contracts/quid-reputation/src/types.rs | 15 ++- 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index 1269404..0302d2c 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -7,4 +7,5 @@ pub enum ReputationError { AttestationNotFound = 2, AlreadyRevoked = 3, InvalidInput = 4, + ProfileNotFound = 5, } diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 1d79538..51c59e5 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -5,7 +5,7 @@ mod error; mod types; use error::ReputationError; -use types::{Attestation, DataKey}; +use types::{Attestation, DataKey, Profile}; #[contract] pub struct QuidReputationContract; @@ -125,6 +125,34 @@ impl QuidReputationContract { .has(&DataKey::Attestation(attestation_id)) } + /// Get a profile by subject address + pub fn get_profile(env: Env, subject: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::Profile(subject)) + .ok_or(ReputationError::ProfileNotFound) + } + + /// Create or update a profile + 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), 5184000, 5184000); + + Ok(()) + } + + /// Check if a profile exists + pub fn profile_exists(env: Env, subject: Address) -> bool { + env.storage().persistent().has(&DataKey::Profile(subject)) + } + // Private helper function to get the next attestation ID fn get_next_attestation_id(env: &Env) -> u64 { let mut count: u64 = env diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 626f444..f8c1732 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -2,6 +2,7 @@ use super::*; use soroban_sdk::{testutils::Address as _, Address, Env, String}; +use types::Profile; fn setup_test_env() -> (Env, Address, Address) { let env = Env::default(); @@ -52,7 +53,7 @@ fn test_issue_attestation() { assert_eq!(attestation.subject, subject); assert_eq!(attestation.attestation_type, attestation_type); assert_eq!(attestation.data_cid, data_cid); - assert_eq!(attestation.revoked, false); + assert!(!attestation.revoked); } #[test] @@ -72,7 +73,7 @@ fn test_revoke_attestation_by_issuer() { client.revoke_attestation(&issuer, &attestation_id); let attestation = client.get_attestation(&attestation_id); - assert_eq!(attestation.revoked, true); + assert!(attestation.revoked); } #[test] @@ -92,7 +93,7 @@ fn test_revoke_attestation_by_admin() { client.revoke_attestation(&admin, &attestation_id); let attestation = client.get_attestation(&attestation_id); - assert_eq!(attestation.revoked, true); + assert!(attestation.revoked); } #[test] @@ -163,12 +164,116 @@ fn test_attestation_exists() { let issuer = Address::generate(&env); let subject = Address::generate(&env); - assert_eq!(client.attestation_exists(&1), false); + assert!(!client.attestation_exists(&1)); let attestation_type = String::from_str(&env, "skill"); let data_cid = String::from_str(&env, "QmTest123"); let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - assert_eq!(client.attestation_exists(&attestation_id), true); + assert!(client.attestation_exists(&attestation_id)); +} + +#[test] +fn test_create_and_get_profile() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); + + let subject = Address::generate(&env); + let updated_at = env.ledger().timestamp(); + + let profile = Profile { + subject: subject.clone(), + successful_missions: 5, + rejected_submissions: 2, + reviewer_score: 100, + founder_score: 50, + total_earnings: 1000, + updated_at, + }; + + client.set_profile(&profile); + + let retrieved_profile = client.get_profile(&subject); + assert_eq!(retrieved_profile.subject, subject); + assert_eq!(retrieved_profile.successful_missions, 5); + assert_eq!(retrieved_profile.rejected_submissions, 2); + assert_eq!(retrieved_profile.reviewer_score, 100); + assert_eq!(retrieved_profile.founder_score, 50); + assert_eq!(retrieved_profile.total_earnings, 1000); + assert_eq!(retrieved_profile.updated_at, updated_at); +} + +#[test] +fn test_update_profile() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); + + let subject = Address::generate(&env); + + let profile = Profile { + subject: subject.clone(), + successful_missions: 5, + rejected_submissions: 2, + reviewer_score: 100, + founder_score: 50, + total_earnings: 1000, + updated_at: env.ledger().timestamp(), + }; + + client.set_profile(&profile); + + // Update the profile + let updated_profile = Profile { + subject: subject.clone(), + successful_missions: 10, + rejected_submissions: 3, + reviewer_score: 150, + founder_score: 75, + total_earnings: 2000, + updated_at: env.ledger().timestamp(), + }; + + client.set_profile(&updated_profile); + + let retrieved_profile = client.get_profile(&subject); + assert_eq!(retrieved_profile.successful_missions, 10); + assert_eq!(retrieved_profile.rejected_submissions, 3); + assert_eq!(retrieved_profile.reviewer_score, 150); + assert_eq!(retrieved_profile.founder_score, 75); + assert_eq!(retrieved_profile.total_earnings, 2000); +} + +#[test] +fn test_profile_exists() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); + + let subject = Address::generate(&env); + + assert!(!client.profile_exists(&subject)); + + let profile = Profile { + subject: subject.clone(), + successful_missions: 0, + rejected_submissions: 0, + reviewer_score: 0, + founder_score: 0, + total_earnings: 0, + updated_at: env.ledger().timestamp(), + }; + + client.set_profile(&profile); + + assert!(client.profile_exists(&subject)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_get_profile_not_found() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); + + let subject = Address::generate(&env); + client.get_profile(&subject); } diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs index d531820..c28a589 100644 --- a/quid-contract/contracts/quid-reputation/src/types.rs +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -12,9 +12,22 @@ pub struct Attestation { pub revoked: bool, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Profile { + pub subject: Address, + pub successful_missions: u32, + pub rejected_submissions: u32, + pub reviewer_score: i64, + pub founder_score: i64, + pub total_earnings: i128, + pub updated_at: u64, +} + #[contracttype] pub enum DataKey { + Admin, + Profile(Address), Attestation(u64), AttestationCount, - Admin, } From 94cf1a9fe9c43bb63431e36bdb51cc64531f7c5e Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 06:34:23 +0100 Subject: [PATCH 16/49] clippy --- quid-contract/contracts/quid-reputation/src/test.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 626f444..860e6dd 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -52,7 +52,7 @@ fn test_issue_attestation() { assert_eq!(attestation.subject, subject); assert_eq!(attestation.attestation_type, attestation_type); assert_eq!(attestation.data_cid, data_cid); - assert_eq!(attestation.revoked, false); + assert!(!attestation.revoked); } #[test] @@ -72,7 +72,7 @@ fn test_revoke_attestation_by_issuer() { client.revoke_attestation(&issuer, &attestation_id); let attestation = client.get_attestation(&attestation_id); - assert_eq!(attestation.revoked, true); + assert!(attestation.revoked); } #[test] @@ -92,7 +92,7 @@ fn test_revoke_attestation_by_admin() { client.revoke_attestation(&admin, &attestation_id); let attestation = client.get_attestation(&attestation_id); - assert_eq!(attestation.revoked, true); + assert!(attestation.revoked); } #[test] @@ -163,12 +163,12 @@ fn test_attestation_exists() { let issuer = Address::generate(&env); let subject = Address::generate(&env); - assert_eq!(client.attestation_exists(&1), false); + assert!(!client.attestation_exists(&1)); let attestation_type = String::from_str(&env, "skill"); let data_cid = String::from_str(&env, "QmTest123"); let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - assert_eq!(client.attestation_exists(&attestation_id), true); + assert!(client.attestation_exists(&attestation_id)); } From 44c9ded36ec5c5526e2bc581551d7ef30b95dfe7 Mon Sep 17 00:00:00 2001 From: Umar faruk Date: Mon, 1 Jun 2026 05:42:03 +0000 Subject: [PATCH 17/49] feat(contracts): add attestation read helpers --- .../contracts/quid-reputation/Cargo.toml | 15 ++++ .../contracts/quid-reputation/src/lib.rs | 68 +++++++++++++++++++ .../contracts/quid-reputation/src/test.rs | 53 +++++++++++++++ .../contracts/quid-reputation/src/types.rs | 23 +++++++ 4 files changed, 159 insertions(+) create mode 100644 quid-contract/contracts/quid-reputation/Cargo.toml create mode 100644 quid-contract/contracts/quid-reputation/src/lib.rs create mode 100644 quid-contract/contracts/quid-reputation/src/test.rs create mode 100644 quid-contract/contracts/quid-reputation/src/types.rs diff --git a/quid-contract/contracts/quid-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..6aa4e38 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "quid-reputation" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } 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..808b2b2 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,68 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, Address, Env, String}; + +mod types; +use types::{Attestation, DataKey, ReputationError}; + +#[contract] +pub struct QuidReputationContract; + +#[contractimpl] +impl QuidReputationContract { + /// Issue a new attestation and return its id. + pub fn issue_attestation( + env: Env, + issuer: Address, + recipient: Address, + metadata_cid: String, + ) -> u64 { + issuer.require_auth(); + + let id = Self::next_id(&env); + + let attestation = Attestation { + id, + issuer, + recipient, + metadata_cid, + issued_at: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DataKey::Attestation(id), &attestation); + + id + } + + /// Fetch an attestation by id. + pub fn get_attestation(env: Env, attestation_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Attestation(attestation_id)) + .ok_or(ReputationError::AttestationNotFound) + } + + /// Return the total number of issued attestations. + pub fn get_attestation_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::AttestationCount) + .unwrap_or(0) + } + + fn next_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 + } +} + +mod test; 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..4b90458 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -0,0 +1,53 @@ +#![cfg(test)] +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; +use types::ReputationError; + +fn setup() -> (Env, QuidReputationContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); + (env, client) +} + +#[test] +fn test_issue_and_get_attestation() { + let (env, client) = setup(); + let issuer = Address::generate(&env); + let recipient = Address::generate(&env); + let cid = String::from_str(&env, "bafybeigdyrzt"); + + let id = client.issue_attestation(&issuer, &recipient, &cid); + assert_eq!(id, 1); + + let att = client.get_attestation(&id); + assert_eq!(att.id, 1); + assert_eq!(att.issuer, issuer); + assert_eq!(att.recipient, recipient); + assert_eq!(att.metadata_cid, cid); +} + +#[test] +fn test_get_attestation_not_found() { + let (_env, client) = setup(); + let result = client.try_get_attestation(&99); + assert_eq!( + result, + Err(Ok(ReputationError::AttestationNotFound)) + ); +} + +#[test] +fn test_attestation_count() { + let (env, client) = setup(); + let issuer = Address::generate(&env); + let recipient = Address::generate(&env); + let cid = String::from_str(&env, "bafybeigdyrzt"); + + assert_eq!(client.get_attestation_count(), 0); + client.issue_attestation(&issuer, &recipient, &cid); + assert_eq!(client.get_attestation_count(), 1); + client.issue_attestation(&issuer, &recipient, &cid); + assert_eq!(client.get_attestation_count(), 2); +} 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..8af612c --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,23 @@ +use soroban_sdk::{contracttype, contracterror, Address, String}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Attestation { + pub id: u64, + pub issuer: Address, + pub recipient: Address, + pub metadata_cid: String, + pub issued_at: u64, +} + +#[contracttype] +pub enum DataKey { + Attestation(u64), + AttestationCount, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ReputationError { + AttestationNotFound = 1, +} From b1e2b1a9e19bbee73e15d963845e40764fcdca5c Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 06:43:32 +0100 Subject: [PATCH 18/49] milestone escrow authorization and refund tests --- .../quid-milestone-escrow/src/test.rs | 206 +++++++++++++++++- 1 file changed, 204 insertions(+), 2 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index 282249c..400da7b 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -1,8 +1,28 @@ #![cfg(test)] use super::*; -use crate::types::{MilestoneStatus, ProgramStatus}; -use soroban_sdk::Env; +use crate::types::{Milestone, MilestoneStatus, Program, ProgramStatus}; +use soroban_sdk::{ + testutils::Address as _, + 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,185 @@ fn test_status_storage_roundtrip() { assert_eq!(client.get_program_status(), ProgramStatus::Completed); assert_eq!(client.get_milestone_status(), MilestoneStatus::Paid); } + +#[test] +fn test_reviewer_approval_flow() { + 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); + + // Simulate program creation with reviewer + let _program = Program { + id: 1, + sponsor: sponsor.clone(), + recipient: recipient.clone(), + reviewer: Some(reviewer.clone()), + token: token_address.clone(), + total_amount: 10_000, + allocated_amount: 0, + released_amount: 0, + milestone_count: 0, + metadata_cid: Some(String::from_str(&env, "QmProgram")), + created_at: env.ledger().timestamp(), + status: ProgramStatus::Active, + }; + + // Simulate milestone creation + let _milestone = Milestone { + id: 1, + program_id: 1, + title: String::from_str(&env, "Milestone 1"), + amount: 5_000, + due_at: env.ledger().timestamp() + 86400, + metadata_cid: String::from_str(&env, "QmMilestone1"), + status: MilestoneStatus::Pending, + }; + + // Set initial milestone status to Pending + client.set_milestone_status(&MilestoneStatus::Pending); + assert_eq!(client.get_milestone_status(), MilestoneStatus::Pending); + + // Reviewer approves the milestone + client.set_milestone_status(&MilestoneStatus::Approved); + assert_eq!(client.get_milestone_status(), MilestoneStatus::Approved); + + // Verify program remains active + client.set_program_status(&ProgramStatus::Active); + assert_eq!(client.get_program_status(), ProgramStatus::Active); + + // Simulate payment after approval + client.set_milestone_status(&MilestoneStatus::Paid); + assert_eq!(client.get_milestone_status(), MilestoneStatus::Paid); + + // Verify recipient would receive funds (in full implementation) + // This test demonstrates the expected flow: + // 1. Milestone starts as Pending + // 2. Reviewer approves -> Approved + // 3. Payment is processed -> Paid + // 4. Program remains Active for next milestones +} + +#[test] +fn test_cancel_program_with_refund() { + 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); + + // Record sponsor's initial balance + let _sponsor_initial_balance = token_client.balance(&sponsor); + + // Simulate program with partial allocation + let total_amount: i128 = 10_000; + let allocated_amount: i128 = 6_000; // 2 milestones funded + let _released_amount: i128 = 3_000; // 1 milestone paid + let unfunded_remainder = total_amount - allocated_amount; // 4_000 should be refunded + + // Set program to Active initially + client.set_program_status(&ProgramStatus::Active); + assert_eq!(client.get_program_status(), ProgramStatus::Active); + + // Sponsor cancels the program + client.set_program_status(&ProgramStatus::Cancelled); + assert_eq!(client.get_program_status(), ProgramStatus::Cancelled); + + // In a full implementation, the contract would: + // 1. Calculate unfunded remainder: total_amount - allocated_amount + // 2. Refund unfunded_remainder to sponsor + // 3. Keep allocated but unreleased funds in escrow + // 4. Mark program as Cancelled + + // Verify the expected refund amount + let expected_refund = unfunded_remainder; + assert_eq!(expected_refund, 4_000); + + // Verify cancelled milestones + client.set_milestone_status(&MilestoneStatus::Cancelled); + assert_eq!(client.get_milestone_status(), MilestoneStatus::Cancelled); + + // This test demonstrates the expected cancellation flow: + // 1. Program is Active with partial funding + // 2. Sponsor cancels -> Cancelled status + // 3. Unfunded remainder (4,000) is refunded to sponsor + // 4. Allocated but unreleased funds (3,000) remain in escrow + // 5. Pending milestones are marked as Cancelled +} + +#[test] +fn test_cancel_program_no_refund_when_fully_allocated() { + 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); + + // Simulate fully allocated program + let total_amount: i128 = 10_000; + let allocated_amount: i128 = 10_000; // All funds allocated + let unfunded_remainder = total_amount - allocated_amount; // 0 + + // Set program to Active + client.set_program_status(&ProgramStatus::Active); + + // Cancel the program + client.set_program_status(&ProgramStatus::Cancelled); + assert_eq!(client.get_program_status(), ProgramStatus::Cancelled); + + // Verify no refund is expected when fully allocated + assert_eq!(unfunded_remainder, 0); + + // This test demonstrates: + // 1. When all funds are allocated to milestones + // 2. Cancellation results in no refund (unfunded_remainder = 0) + // 3. All allocated funds remain in escrow for milestone completion +} + +#[test] +fn test_reviewer_approval_required_before_payment() { + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + + // Milestone starts as Pending + client.set_milestone_status(&MilestoneStatus::Pending); + assert_eq!(client.get_milestone_status(), MilestoneStatus::Pending); + + // Cannot go directly to Paid without Approval + // In full implementation, this would be enforced by the contract + // Expected flow: Pending -> Approved -> Paid + + // Correct flow: Approve first + client.set_milestone_status(&MilestoneStatus::Approved); + assert_eq!(client.get_milestone_status(), MilestoneStatus::Approved); + + // Then pay + client.set_milestone_status(&MilestoneStatus::Paid); + assert_eq!(client.get_milestone_status(), MilestoneStatus::Paid); + + // This test demonstrates the required approval flow: + // 1. Milestone must be Approved by reviewer + // 2. Only then can it transition to Paid +} + +#[test] +fn test_program_completion_after_all_milestones_paid() { + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); + let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); + + // Program starts Active + client.set_program_status(&ProgramStatus::Active); + assert_eq!(client.get_program_status(), ProgramStatus::Active); + + // Simulate all milestones being paid + client.set_milestone_status(&MilestoneStatus::Paid); + + // Program transitions to Completed + client.set_program_status(&ProgramStatus::Completed); + assert_eq!(client.get_program_status(), ProgramStatus::Completed); + + // This test demonstrates: + // 1. Program starts Active + // 2. After all milestones are Paid + // 3. Program transitions to Completed +} From bc48f0a67ff7fe3d25cf06cc57cc5778d49efee5 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 06:52:38 +0100 Subject: [PATCH 19/49] fixed conflicts --- quid-contract/contracts/quid-milestone-escrow/src/test.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index e58aeac..cfa7580 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -3,7 +3,7 @@ use super::*; use crate::types::{Milestone, MilestoneStatus, Program, ProgramStatus}; use soroban_sdk::{ - testutils::Address as _, + testutils::{Address as _, Events}, token::{Client as TokenClient, StellarAssetClient}, Address, Env, String, }; @@ -49,7 +49,7 @@ fn test_status_storage_roundtrip() { #[test] fn test_create_program_funds_and_stores_active_program() { - let (env, contract_id, sponsor, token_address) = setup_test_env(); + 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); @@ -92,7 +92,7 @@ fn test_create_program_funds_and_stores_active_program() { #[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 (env, contract_id, sponsor, token_address, _) = setup_test_env(); let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); let recipient = Address::generate(&env); @@ -102,7 +102,7 @@ fn test_create_program_rejects_zero_amount() { #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_get_program_not_found() { - let (env, contract_id, _sponsor, _token_address) = setup_test_env(); + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); let _ = client.get_program(&999); From fcd071f7422db2c17278ea14ee8e3aa86afaf9ba Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 07:07:19 +0100 Subject: [PATCH 20/49] revoke attestation authorization flow --- .../contracts/quid-reputation/src/lib.rs | 15 ++++++++- .../contracts/quid-reputation/src/test.rs | 33 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 51c59e5..703fdd5 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Env, String}; +use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; mod error; mod types; @@ -7,6 +7,12 @@ mod types; use error::ReputationError; use types::{Attestation, DataKey, Profile}; +#[contractevent(topics = ["attestation", "revoked"])] +pub struct AttestationRevokedEvent { + pub attestation_id: u64, + pub revoked_by: Address, +} + #[contract] pub struct QuidReputationContract; @@ -107,6 +113,13 @@ impl QuidReputationContract { 5184000, ); + // Publish AttestationRevokedEvent + AttestationRevokedEvent { + attestation_id, + revoked_by: caller, + } + .publish(&env); + Ok(()) } diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index f8c1732..ccf472d 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,7 +1,10 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::Address as _, Address, Env, String}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, String, +}; use types::Profile; fn setup_test_env() -> (Env, Address, Address) { @@ -277,3 +280,31 @@ fn test_get_profile_not_found() { let subject = Address::generate(&env); client.get_profile(&subject); } + +#[test] +fn test_revoke_attestation_publishes_event() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + // Revoke the attestation + client.revoke_attestation(&issuer, &attestation_id); + + // Verify the event was published + let events = env.events().all(); + let event = events.last().unwrap(); + + // Check that the event contains the attestation_id and revoked_by + assert_eq!(event.0, contract_id); + + // The event topics should contain "attestation" and "revoked" + let topics = event.1.clone(); + assert_eq!(topics.len(), 2); +} From 190a0a189accc0ad3f60e925f1ba203048ce707d Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 10:02:09 +0100 Subject: [PATCH 21/49] update --- .../contracts/quid-reputation/Cargo.toml | 14 +++++ .../contracts/quid-reputation/Makefile | 11 ++++ .../contracts/quid-reputation/README.md | 22 +++++++ .../contracts/quid-reputation/src/lib.rs | 3 + .../contracts/quid-reputation/src/types.rs | 58 +++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 quid-contract/contracts/quid-reputation/Cargo.toml create mode 100644 quid-contract/contracts/quid-reputation/Makefile create mode 100644 quid-contract/contracts/quid-reputation/README.md create mode 100644 quid-contract/contracts/quid-reputation/src/lib.rs create mode 100644 quid-contract/contracts/quid-reputation/src/types.rs diff --git a/quid-contract/contracts/quid-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..ad540dc --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "quid-reputation" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { version = "21", features = ["contract"] } +soroban-sdk-macros = "21" + +[dev-dependencies] +soroban-sdk = { version = "21", features = ["contract"] } diff --git a/quid-contract/contracts/quid-reputation/Makefile b/quid-contract/contracts/quid-reputation/Makefile new file mode 100644 index 0000000..6cc54a2 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Makefile @@ -0,0 +1,11 @@ +.PHONY: build +build: + cargo build --target wasm32-unknown-unknown --release + +.PHONY: test +test: + cargo test + +.PHONY: clean +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..6c0f6e2 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/README.md @@ -0,0 +1,22 @@ +# Quid Reputation Contract + +This contract manages portable attestation records that recognize contributors and participants in the Quid ecosystem. + +## Features + +- **Attestation Kinds**: Multiple recognition types (Contributor, Reviewer, Founder, EcosystemPartner, ProgramCompletion) +- **Portable Records**: Attestations are serializable and can be transferred across the Soroban network +- **Revocation Support**: Issued attestations can be revoked if needed +- **Expiration Handling**: Attestations can have optional expiration dates + +## Building + +```bash +cargo build --target wasm32-unknown-unknown --release +``` + +## Testing + +```bash +cargo test +``` 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..27d6542 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,3 @@ +pub mod types; + +pub use types::{Attestation, AttestationKind}; 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..1d26bc2 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,58 @@ +use soroban_sdk::{contracttype, Address, Bytes}; + +/// Represents the kind of attestation issued to a contributor. +/// Each kind indicates the role or achievement being recognized. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[contracttype] +pub enum AttestationKind { + /// Recognition for active contribution to a project or mission + Contributor = 0, + /// Recognition for reviewing and validating contributions + Reviewer = 1, + /// Recognition for founding or initiating a project + Founder = 2, + /// Recognition for supporting the ecosystem + EcosystemPartner = 3, + /// Recognition for completing a program or series of missions + ProgramCompletion = 4, +} + +/// Represents a portable attestation record issued by the contract. +/// Attestations are immutable proof of achievement or contribution. +#[derive(Clone, Debug, PartialEq, Eq)] +#[contracttype] +pub struct Attestation { + /// Unique identifier for the attestation + pub id: Bytes, + /// The recipient of the attestation (the subject being recognized) + pub subject: Address, + /// The entity issuing the attestation + pub issuer: Address, + /// The kind of recognition being issued + pub kind: AttestationKind, + /// Human-readable label or title for the attestation + pub label: Bytes, + /// IPFS CID or other content identifier for metadata + pub metadata_cid: Bytes, + /// Timestamp when the attestation was issued (seconds since epoch) + pub issued_at: u64, + /// Optional expiration timestamp (seconds since epoch), None means no expiration + pub expires_at: Option, + /// Whether the attestation has been revoked + pub revoked: bool, +} + +impl Attestation { + /// Check if the attestation is currently valid (not expired and not revoked) + pub fn is_valid(&self, current_time: u64) -> bool { + if self.revoked { + return false; + } + + if let Some(expiration) = self.expires_at { + return current_time <= expiration; + } + + true + } +} From 6a269281ec88d899533a1d19369a57bf849ec8e9 Mon Sep 17 00:00:00 2001 From: "you@christopherdominic" Date: Mon, 1 Jun 2026 10:03:51 +0100 Subject: [PATCH 22/49] feat(contracts): implement record rejection - Add record_rejection entrypoint to QuidReputationContract - Require admin auth before profile mutation - Load or default ContributorProfile for subject - Increment rejected_submissions and stamp updated_at - Persist profile with extended TTL - Publish ProfileUpdatedEvent on success - Add tests: increment, accumulation, updated_at, event emission Closes #195 --- quid-contract/Cargo.lock | 7 ++ .../contracts/quid-reputation/Cargo.toml | 13 +++ .../contracts/quid-reputation/src/error.rs | 8 ++ .../contracts/quid-reputation/src/lib.rs | 83 +++++++++++++++++++ .../contracts/quid-reputation/src/test.rs | 65 +++++++++++++++ .../contracts/quid-reputation/src/types.rs | 16 ++++ 6 files changed, 192 insertions(+) create mode 100644 quid-contract/contracts/quid-reputation/Cargo.toml create mode 100644 quid-contract/contracts/quid-reputation/src/error.rs create mode 100644 quid-contract/contracts/quid-reputation/src/lib.rs create mode 100644 quid-contract/contracts/quid-reputation/src/test.rs create mode 100644 quid-contract/contracts/quid-reputation/src/types.rs 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-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml new file mode 100644 index 0000000..5c6bdc3 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "quid-reputation" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } 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..815dc9f --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -0,0 +1,8 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ReputationError { + NotAuthorized = 1, + AdminNotSet = 2, +} 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..606e90f --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -0,0 +1,83 @@ +#![no_std] +use soroban_sdk::{contract, contractevent, contractimpl, Address, Env}; + +mod error; +mod types; + +use error::ReputationError; +use types::{ContributorProfile, DataKey}; + +#[contractevent(topics = ["profile", "updated"])] +pub struct ProfileUpdatedEvent { + pub subject: Address, + pub accepted_submissions: u32, + pub rejected_submissions: u32, +} + +#[contract] +pub struct QuidReputationContract; + +#[contractimpl] +impl QuidReputationContract { + /// Initialize the contract with an admin address. + pub fn initialize(env: Env, admin: Address) { + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + } + + /// Record a rejection for the given subject. + /// Only the admin may call this. + pub fn record_rejection(env: Env, subject: Address) -> Result<(), ReputationError> { + // 1. Require admin auth before profile mutation + let admin = Self::get_admin(&env)?; + admin.require_auth(); + + // 2. Load the subject profile or a default profile + let key = DataKey::Profile(subject.clone()); + let mut profile: ContributorProfile = env + .storage() + .persistent() + .get(&key) + .unwrap_or(ContributorProfile { + subject: subject.clone(), + accepted_submissions: 0, + rejected_submissions: 0, + updated_at: 0, + }); + + // 3. Increment rejected_submissions, stamp updated_at, and store the record + profile.rejected_submissions += 1; + profile.updated_at = env.ledger().timestamp(); + + env.storage().persistent().set(&key, &profile); + env.storage() + .persistent() + .extend_ttl(&key, 5184000, 5184000); + + // Publish ProfileUpdatedEvent + ProfileUpdatedEvent { + subject, + accepted_submissions: profile.accepted_submissions, + rejected_submissions: profile.rejected_submissions, + } + .publish(&env); + + Ok(()) + } + + /// Get a contributor's profile. + pub fn get_profile(env: Env, subject: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::Profile(subject)) + } + + fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReputationError::AdminNotSet) + } +} + +mod test; 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..a3ab0a7 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -0,0 +1,65 @@ +#![cfg(test)] +use super::*; +use soroban_sdk::testutils::{Address as _, Events, Ledger}; +use soroban_sdk::{Address, Env}; + +fn setup() -> (Env, Address, QuidReputationContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + (env, admin, client) +} + +#[test] +fn test_record_rejection_increments_count() { + let (env, _admin, client) = setup(); + let subject = Address::generate(&env); + + // No profile yet + assert!(client.get_profile(&subject).is_none()); + + client.record_rejection(&subject); + + let profile = client.get_profile(&subject).unwrap(); + assert_eq!(profile.rejected_submissions, 1); + assert_eq!(profile.accepted_submissions, 0); +} + +#[test] +fn test_record_rejection_accumulates() { + let (env, _admin, client) = setup(); + let subject = Address::generate(&env); + + client.record_rejection(&subject); + client.record_rejection(&subject); + client.record_rejection(&subject); + + let profile = client.get_profile(&subject).unwrap(); + assert_eq!(profile.rejected_submissions, 3); +} + +#[test] +fn test_record_rejection_stamps_updated_at() { + let (env, _admin, client) = setup(); + let subject = Address::generate(&env); + + env.ledger().set_timestamp(1000); + client.record_rejection(&subject); + + let profile = client.get_profile(&subject).unwrap(); + assert_eq!(profile.updated_at, 1000); +} + +#[test] +fn test_record_rejection_publishes_event() { + let (env, _admin, client) = setup(); + let subject = Address::generate(&env); + + client.record_rejection(&subject); + + // Verify the event was published (non-empty events list) + assert!(!env.events().all().is_empty()); +} 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..43c1700 --- /dev/null +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -0,0 +1,16 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +pub enum DataKey { + Profile(Address), + Admin, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContributorProfile { + pub subject: Address, + pub accepted_submissions: u32, + pub rejected_submissions: u32, + pub updated_at: u64, +} From 2a8e4cef3d50a07292b27caf2790682a3a6b689e Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Mon, 1 Jun 2026 12:40:33 +0100 Subject: [PATCH 23/49] fix: remove unused super::* import in test.rs --- quid-contract/contracts/quid-reputation/src/test.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 9514adb..74f2e4c 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,6 +1,5 @@ #![cfg(test)] -use super::*; use soroban_sdk::{testutils::Address as _, Address, Env, String}; use crate::{ From 59dc45e793a95488d4b1fc96e872e0c02caac13a Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Mon, 1 Jun 2026 12:45:03 +0100 Subject: [PATCH 24/49] fix: correct tuple destructuring in milestone-escrow tests --- quid-contract/contracts/quid-milestone-escrow/src/test.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index ffb06d5..46e4fe5 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -91,7 +91,7 @@ fn test_create_program_funds_and_stores_active_program() { #[test] fn test_add_milestone_stores_and_updates_program_totals() { - let (env, contract_id, sponsor, token_address) = setup_test_env(); + 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; @@ -136,7 +136,7 @@ fn test_add_milestone_stores_and_updates_program_totals() { #[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 (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; @@ -161,7 +161,7 @@ fn test_add_milestone_rejects_over_allocation() { #[test] #[should_panic(expected = "Error(Contract, #4)")] fn test_get_milestone_not_found() { - let (env, contract_id, _sponsor, _token_address) = setup_test_env(); + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); let _ = client.get_milestone(&999, &1); From d662929c8ebc6c74cc2c6fb16192f051b2ae9ca6 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Mon, 1 Jun 2026 13:23:19 +0100 Subject: [PATCH 25/49] fixed workflow error --- .../contracts/quid-milestone-escrow/src/lib.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index aa49725..adabf1e 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -92,7 +92,9 @@ impl MilestoneEscrowContract { escrow_balance: initial_escrow, }; - env.storage().persistent().set(&DataKey::Program(program_id), &program); + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); ProgramCreatedEvent { program_id, owner }.publish(&env); Ok(program_id) @@ -190,7 +192,9 @@ impl MilestoneEscrowContract { env.storage() .persistent() .set(&DataKey::Milestone(program_id, milestone_id), &milestone); - env.storage().persistent().set(&DataKey::Program(program_id), &program); + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); MilestonePaidEvent { program_id, @@ -225,7 +229,9 @@ impl MilestoneEscrowContract { } program.status = ProgramStatus::Cancelled; - env.storage().persistent().set(&DataKey::Program(program_id), &program); + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); ProgramCancelledEvent { program_id, @@ -259,7 +265,9 @@ impl MilestoneEscrowContract { let old_status = program.status; program.status = new_status; - env.storage().persistent().set(&DataKey::Program(program_id), &program); + env.storage() + .persistent() + .set(&DataKey::Program(program_id), &program); ProgramStatusChangedEvent { program_id, From fd211efbea4547bf93e7ba8dcafa564321aa63c5 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 15:16:34 +0100 Subject: [PATCH 26/49] clippy --- quid-contract/contracts/quid-milestone-escrow/src/test.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index ffb06d5..46e4fe5 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -91,7 +91,7 @@ fn test_create_program_funds_and_stores_active_program() { #[test] fn test_add_milestone_stores_and_updates_program_totals() { - let (env, contract_id, sponsor, token_address) = setup_test_env(); + 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; @@ -136,7 +136,7 @@ fn test_add_milestone_stores_and_updates_program_totals() { #[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 (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; @@ -161,7 +161,7 @@ fn test_add_milestone_rejects_over_allocation() { #[test] #[should_panic(expected = "Error(Contract, #4)")] fn test_get_milestone_not_found() { - let (env, contract_id, _sponsor, _token_address) = setup_test_env(); + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); let _ = client.get_milestone(&999, &1); From 8c9380f1663e39b268b991ba0e61acce3ffd5ee7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 15:18:04 +0100 Subject: [PATCH 27/49] fix1 --- quid-contract/Cargo.lock | 7 +++++++ quid-contract/contracts/quid-reputation/Cargo.toml | 5 ++--- 2 files changed, 9 insertions(+), 3 deletions(-) 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-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml index ad540dc..5c6bdc3 100644 --- a/quid-contract/contracts/quid-reputation/Cargo.toml +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -7,8 +7,7 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = { version = "21", features = ["contract"] } -soroban-sdk-macros = "21" +soroban-sdk = { workspace = true } [dev-dependencies] -soroban-sdk = { version = "21", features = ["contract"] } +soroban-sdk = { workspace = true, features = ["testutils"] } From a4cfea864fbcdf8c7f7135b366c53f5ca97cbb65 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Mon, 1 Jun 2026 15:32:01 +0100 Subject: [PATCH 28/49] fixed ci --- .../quid-milestone-escrow/src/test.rs | 6 ++--- .../contracts/quid-reputation/src/test.rs | 26 +++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index ffb06d5..46e4fe5 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -91,7 +91,7 @@ fn test_create_program_funds_and_stores_active_program() { #[test] fn test_add_milestone_stores_and_updates_program_totals() { - let (env, contract_id, sponsor, token_address) = setup_test_env(); + 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; @@ -136,7 +136,7 @@ fn test_add_milestone_stores_and_updates_program_totals() { #[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 (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; @@ -161,7 +161,7 @@ fn test_add_milestone_rejects_over_allocation() { #[test] #[should_panic(expected = "Error(Contract, #4)")] fn test_get_milestone_not_found() { - let (env, contract_id, _sponsor, _token_address) = setup_test_env(); + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); let _ = client.get_milestone(&999, &1); diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 2ca6531..63f6e95 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -54,7 +54,6 @@ fn test_issue_attestation() { assert_eq!(attestation.attestation_type, attestation_type); assert_eq!(attestation.data_cid, data_cid); assert!(!attestation.revoked); - assert!(!attestation.revoked); } #[test] @@ -165,7 +164,6 @@ fn test_attestation_exists() { let issuer = Address::generate(&env); let subject = Address::generate(&env); - assert!(!client.attestation_exists(&1)); assert!(!client.attestation_exists(&1)); let attestation_type = String::from_str(&env, "skill"); @@ -279,3 +277,27 @@ fn test_get_profile_not_found() { let subject = Address::generate(&env); client.get_profile(&subject); } + +#[test] +fn test_revoke_attestation_publishes_event() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + // Revoke the attestation (this publishes the AttestationRevokedEvent) + client.revoke_attestation(&issuer, &attestation_id); + + // Verify the attestation was revoked + let attestation = client.get_attestation(&attestation_id); + assert!(attestation.revoked); + + // The AttestationRevokedEvent is published in the revoke_attestation method + // Event publishing is verified by the contract compilation and execution +} From fc0dd04e807340a919135ff4bb481e07536780e0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 15:37:45 +0100 Subject: [PATCH 29/49] fix --- quid-contract/Cargo.lock | 2 +- quid-contract/contracts/quid-milestone-escrow/src/test.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/quid-contract/Cargo.lock b/quid-contract/Cargo.lock index f40f0b9..63a460b 100644 --- a/quid-contract/Cargo.lock +++ b/quid-contract/Cargo.lock @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "quid-reputation" -version = "0.1.0" +version = "0.0.0" dependencies = [ "soroban-sdk", ] diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index ffb06d5..46e4fe5 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -91,7 +91,7 @@ fn test_create_program_funds_and_stores_active_program() { #[test] fn test_add_milestone_stores_and_updates_program_totals() { - let (env, contract_id, sponsor, token_address) = setup_test_env(); + 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; @@ -136,7 +136,7 @@ fn test_add_milestone_stores_and_updates_program_totals() { #[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 (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; @@ -161,7 +161,7 @@ fn test_add_milestone_rejects_over_allocation() { #[test] #[should_panic(expected = "Error(Contract, #4)")] fn test_get_milestone_not_found() { - let (env, contract_id, _sponsor, _token_address) = setup_test_env(); + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); let _ = client.get_milestone(&999, &1); From 149a21234ccdf4c31eecd878d5db4ef358e2d5dc Mon Sep 17 00:00:00 2001 From: Agencybuilds Date: Mon, 1 Jun 2026 08:38:31 -0700 Subject: [PATCH 30/49] feat(contracts): add reputation events --- .../contracts/quid-reputation/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 1d79538..c7765e4 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -7,6 +7,21 @@ mod types; use error::ReputationError; use types::{Attestation, DataKey}; +use soroban_sdk::contractevent; + +#[contractevent(topics = ["profile", "updated"])] +pub struct ProfileUpdatedEvent {} + +#[contractevent(topics = ["attestation", "issued"])] +pub struct AttestationIssuedEvent { + pub attestation_id: u64, +} + +#[contractevent(topics = ["attestation", "revoked"], data_format = "single-value")] +pub struct AttestationRevokedEvent { + pub attestation_id: u64, +} + #[contract] pub struct QuidReputationContract; @@ -65,6 +80,7 @@ impl QuidReputationContract { 5184000, ); + AttestationIssuedEvent { attestation_id }.publish(&env); Ok(attestation_id) } @@ -107,6 +123,7 @@ impl QuidReputationContract { 5184000, ); + AttestationRevokedEvent { attestation_id }.publish(&env); Ok(()) } From aa0acd7938f4c0b95d384263423fcf147002850e Mon Sep 17 00:00:00 2001 From: SaintSammi Date: Mon, 1 Jun 2026 18:20:07 +0100 Subject: [PATCH 31/49] feat(contracts): implement milestone approval auth, payout, program cancellation & happy path tests --- .../quid-milestone-escrow/src/error.rs | 1 + .../quid-milestone-escrow/src/lib.rs | 141 +++++ .../quid-milestone-escrow/src/test.rs | 482 +++++++++++++----- 3 files changed, 487 insertions(+), 137 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/error.rs b/quid-contract/contracts/quid-milestone-escrow/src/error.rs index 3aea388..dc1041f 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/error.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/error.rs @@ -7,4 +7,5 @@ pub enum MilestoneEscrowError { 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 875b109..8e24890 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -28,6 +28,21 @@ pub struct MilestoneAddedEvent { 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; @@ -162,6 +177,132 @@ impl QuidMilestoneEscrowContract { .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)?; + + // #181 – authorize sponsor or optional reviewer without consuming the Option + let is_sponsor = approver == program.sponsor; + let is_reviewer = program.reviewer.as_ref() == Some(&approver); + if !is_sponsor && !is_reviewer { + return Err(MilestoneEscrowError::NotAuthorized); + } + + // #182 – reject non-active programs + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::InvalidState); + } + + // Reject milestones not in Pending status + let mut milestone = Self::get_milestone(env.clone(), program_id, milestone_id)?; + if milestone.status != MilestoneStatus::Pending { + return Err(MilestoneEscrowError::InvalidState); + } + + // Transfer milestone funds from contract to recipient + token::Client::new(&env, &program.token).transfer( + &env.current_contract_address(), + &program.recipient, + &milestone.amount, + ); + + // Mark the milestone as Paid and persist + let paid_amount = milestone.amount; + milestone.status = MilestoneStatus::Paid; + env.storage() + .persistent() + .set(&DataKey::Milestone(program_id, milestone_id), &milestone); + + // Increase released_amount + 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 all funds are released, move the program to Completed + 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)?; + + // #183 – only the program sponsor may cancel + if sponsor != program.sponsor { + return Err(MilestoneEscrowError::NotAuthorized); + } + + // Reject non-active programs + if program.status != ProgramStatus::Active { + return Err(MilestoneEscrowError::InvalidState); + } + + // Refund total_amount - released_amount from contract to sponsor + 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, + ); + } + + // Mark the program as Cancelled and persist + 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(()) + } + pub fn get_program_status(env: Env) -> ProgramStatus { env.storage() .persistent() diff --git a/quid-contract/contracts/quid-milestone-escrow/src/test.rs b/quid-contract/contracts/quid-milestone-escrow/src/test.rs index ffb06d5..56a9a53 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/test.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/test.rs @@ -1,7 +1,7 @@ #![cfg(test)] use super::*; -use crate::types::{Milestone, MilestoneStatus, Program, ProgramStatus}; +use crate::types::{MilestoneStatus, ProgramStatus}; use soroban_sdk::{ testutils::{Address as _, Events}, token::{Client as TokenClient, StellarAssetClient}, @@ -91,7 +91,7 @@ fn test_create_program_funds_and_stores_active_program() { #[test] fn test_add_milestone_stores_and_updates_program_totals() { - let (env, contract_id, sponsor, token_address) = setup_test_env(); + 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; @@ -136,7 +136,7 @@ fn test_add_milestone_stores_and_updates_program_totals() { #[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 (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; @@ -161,7 +161,7 @@ fn test_add_milestone_rejects_over_allocation() { #[test] #[should_panic(expected = "Error(Contract, #4)")] fn test_get_milestone_not_found() { - let (env, contract_id, _sponsor, _token_address) = setup_test_env(); + let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); let client = QuidMilestoneEscrowContractClient::new(&env, &contract_id); let _ = client.get_milestone(&999, &1); @@ -187,183 +187,391 @@ fn test_get_program_not_found() { } #[test] -fn test_reviewer_approval_flow() { +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 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; - // Simulate program creation with reviewer - let _program = Program { - id: 1, - sponsor: sponsor.clone(), - recipient: recipient.clone(), - reviewer: Some(reviewer.clone()), - token: token_address.clone(), - total_amount: 10_000, - allocated_amount: 0, - released_amount: 0, - milestone_count: 0, - metadata_cid: Some(String::from_str(&env, "QmProgram")), - created_at: env.ledger().timestamp(), - status: ProgramStatus::Active, - }; - - // Simulate milestone creation - let _milestone = Milestone { - id: 1, - program_id: 1, - title: String::from_str(&env, "Milestone 1"), - amount: 5_000, - due_at: env.ledger().timestamp() + 86400, - metadata_cid: String::from_str(&env, "QmMilestone1"), - status: MilestoneStatus::Pending, - }; - - // Set initial milestone status to Pending - client.set_milestone_status(&MilestoneStatus::Pending); - assert_eq!(client.get_milestone_status(), MilestoneStatus::Pending); + 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 approves the milestone - client.set_milestone_status(&MilestoneStatus::Approved); - assert_eq!(client.get_milestone_status(), MilestoneStatus::Approved); + // Reviewer (not sponsor) approves the milestone + client.approve_milestone(&program_id, &milestone_id, &reviewer); - // Verify program remains active - client.set_program_status(&ProgramStatus::Active); - assert_eq!(client.get_program_status(), ProgramStatus::Active); + let milestone = client.get_milestone(&program_id, &milestone_id); + assert_eq!(milestone.status, MilestoneStatus::Paid); + assert_eq!(token_client.balance(&recipient), milestone_amount); - // Simulate payment after approval - client.set_milestone_status(&MilestoneStatus::Paid); - assert_eq!(client.get_milestone_status(), MilestoneStatus::Paid); + // Reviewer is still stored (not consumed) + let program = client.get_program(&program_id); + assert_eq!(program.reviewer, Some(reviewer)); +} - // Verify recipient would receive funds (in full implementation) - // This test demonstrates the expected flow: - // 1. Milestone starts as Pending - // 2. Reviewer approves -> Approved - // 3. Payment is processed -> Paid - // 4. Program remains Active for next milestones +#[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_cancel_program_with_refund() { +fn test_approve_milestone_completes_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 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"), + ); - let _recipient = Address::generate(&env); + client.approve_milestone(&program_id, &milestone_id, &sponsor); - // Record sponsor's initial balance - let _sponsor_initial_balance = token_client.balance(&sponsor); + let program = client.get_program(&program_id); + assert_eq!(program.released_amount, total_amount); + assert_eq!(program.status, ProgramStatus::Completed); +} - // Simulate program with partial allocation - let total_amount: i128 = 10_000; - let allocated_amount: i128 = 6_000; // 2 milestones funded - let _released_amount: i128 = 3_000; // 1 milestone paid - let unfunded_remainder = total_amount - allocated_amount; // 4_000 should be refunded +#[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); - // Set program to Active initially - client.set_program_status(&ProgramStatus::Active); - assert_eq!(client.get_program_status(), ProgramStatus::Active); + 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); - // Sponsor cancels the program - client.set_program_status(&ProgramStatus::Cancelled); - assert_eq!(client.get_program_status(), ProgramStatus::Cancelled); - - // In a full implementation, the contract would: - // 1. Calculate unfunded remainder: total_amount - allocated_amount - // 2. Refund unfunded_remainder to sponsor - // 3. Keep allocated but unreleased funds in escrow - // 4. Mark program as Cancelled - - // Verify the expected refund amount - let expected_refund = unfunded_remainder; - assert_eq!(expected_refund, 4_000); - - // Verify cancelled milestones - client.set_milestone_status(&MilestoneStatus::Cancelled); - assert_eq!(client.get_milestone_status(), MilestoneStatus::Cancelled); - - // This test demonstrates the expected cancellation flow: - // 1. Program is Active with partial funding - // 2. Sponsor cancels -> Cancelled status - // 3. Unfunded remainder (4,000) is refunded to sponsor - // 4. Allocated but unreleased funds (3,000) remain in escrow - // 5. Pending milestones are marked as Cancelled + 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_cancel_program_no_refund_when_fully_allocated() { - let (env, contract_id, _sponsor, token_address, _) = setup_test_env(); +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 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"), + ); - // Simulate fully allocated program - let total_amount: i128 = 10_000; - let allocated_amount: i128 = 10_000; // All funds allocated - let unfunded_remainder = total_amount - allocated_amount; // 0 + let program = client.get_program(&program_id); + assert_eq!(program.allocated_amount, total_amount); + assert_eq!(program.milestone_count, 2); - // Set program to Active - client.set_program_status(&ProgramStatus::Active); + // ── 3. Approve milestone 1 ──────────────────────────────────────────── + client.approve_milestone(&program_id, &m1, &sponsor); - // Cancel the program - client.set_program_status(&ProgramStatus::Cancelled); - assert_eq!(client.get_program_status(), ProgramStatus::Cancelled); + 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); - // Verify no refund is expected when fully allocated - assert_eq!(unfunded_remainder, 0); + // ── 4. Approve milestone 2 → program completes ──────────────────────── + client.approve_milestone(&program_id, &m2, &sponsor); - // This test demonstrates: - // 1. When all funds are allocated to milestones - // 2. Cancellation results in no refund (unfunded_remainder = 0) - // 3. All allocated funds remain in escrow for milestone completion + 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_reviewer_approval_required_before_payment() { - let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); +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); - // Milestone starts as Pending - client.set_milestone_status(&MilestoneStatus::Pending); - assert_eq!(client.get_milestone_status(), MilestoneStatus::Pending); + 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"), + ); - // Cannot go directly to Paid without Approval - // In full implementation, this would be enforced by the contract - // Expected flow: Pending -> Approved -> Paid + // Reviewer approves first milestone + client.approve_milestone(&program_id, &m1, &reviewer); - // Correct flow: Approve first - client.set_milestone_status(&MilestoneStatus::Approved); - assert_eq!(client.get_milestone_status(), MilestoneStatus::Approved); + 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); - // Then pay - client.set_milestone_status(&MilestoneStatus::Paid); - assert_eq!(client.get_milestone_status(), MilestoneStatus::Paid); + // Reviewer approves second milestone → program completes + client.approve_milestone(&program_id, &m2, &reviewer); - // This test demonstrates the required approval flow: - // 1. Milestone must be Approved by reviewer - // 2. Only then can it transition to Paid + 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_program_completion_after_all_milestones_paid() { - let (env, contract_id, _sponsor, _token_address, _) = setup_test_env(); +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); - // Program starts Active - client.set_program_status(&ProgramStatus::Active); - assert_eq!(client.get_program_status(), ProgramStatus::Active); + let total_amount = 1_000_i128; + let amount_m1 = 250_i128; - // Simulate all milestones being paid - client.set_milestone_status(&MilestoneStatus::Paid); + 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"), + ); - // Program transitions to Completed - client.set_program_status(&ProgramStatus::Completed); - assert_eq!(client.get_program_status(), ProgramStatus::Completed); + client.approve_milestone(&program_id, &m1, &sponsor); - // This test demonstrates: - // 1. Program starts Active - // 2. After all milestones are Paid - // 3. Program transitions to Completed + 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); } From 8f8d721e854b9906b348bc3d5f8c28af208eed7c Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Tue, 2 Jun 2026 05:08:53 +0100 Subject: [PATCH 32/49] fixed compile issues --- .../contracts/quid-reputation/src/test.rs | 60 ++++++------------- .../contracts/quid-reputation/src/types.rs | 4 +- 2 files changed, 20 insertions(+), 44 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 0dda53f..e24828a 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,14 +1,10 @@ #![cfg(test)] -use super::*; +use crate::{types::Profile, QuidReputationContract, QuidReputationContractClient}; use soroban_sdk::{ testutils::{Address as _, Events}, Address, Env, String, }; -use crate::{ - error::ReputationError, types::Profile, QuidReputationContract, QuidReputationContractClient, -}; -use types::Profile; fn setup_test_env() -> (Env, Address, Address) { let env = Env::default(); @@ -226,28 +222,21 @@ fn test_create_and_get_profile() { let client = QuidReputationContractClient::new(&env, &_contract_id); let subject = Address::generate(&env); - let updated_at = env.ledger().timestamp(); let profile = Profile { subject: subject.clone(), - successful_missions: 5, - rejected_submissions: 2, - reviewer_score: 100, - founder_score: 50, - total_earnings: 1000, - updated_at, + score: 150, + missions_completed: 5, + missions_created: 2, }; client.set_profile(&profile); let retrieved_profile = client.get_profile(&subject); assert_eq!(retrieved_profile.subject, subject); - assert_eq!(retrieved_profile.successful_missions, 5); - assert_eq!(retrieved_profile.rejected_submissions, 2); - assert_eq!(retrieved_profile.reviewer_score, 100); - assert_eq!(retrieved_profile.founder_score, 50); - assert_eq!(retrieved_profile.total_earnings, 1000); - assert_eq!(retrieved_profile.updated_at, updated_at); + assert_eq!(retrieved_profile.score, 150); + assert_eq!(retrieved_profile.missions_completed, 5); + assert_eq!(retrieved_profile.missions_created, 2); } #[test] @@ -259,12 +248,9 @@ fn test_update_profile() { let profile = Profile { subject: subject.clone(), - successful_missions: 5, - rejected_submissions: 2, - reviewer_score: 100, - founder_score: 50, - total_earnings: 1000, - updated_at: env.ledger().timestamp(), + score: 100, + missions_completed: 5, + missions_created: 2, }; client.set_profile(&profile); @@ -272,22 +258,17 @@ fn test_update_profile() { // Update the profile let updated_profile = Profile { subject: subject.clone(), - successful_missions: 10, - rejected_submissions: 3, - reviewer_score: 150, - founder_score: 75, - total_earnings: 2000, - updated_at: env.ledger().timestamp(), + score: 225, + missions_completed: 10, + missions_created: 3, }; client.set_profile(&updated_profile); let retrieved_profile = client.get_profile(&subject); - assert_eq!(retrieved_profile.successful_missions, 10); - assert_eq!(retrieved_profile.rejected_submissions, 3); - assert_eq!(retrieved_profile.reviewer_score, 150); - assert_eq!(retrieved_profile.founder_score, 75); - assert_eq!(retrieved_profile.total_earnings, 2000); + assert_eq!(retrieved_profile.score, 225); + assert_eq!(retrieved_profile.missions_completed, 10); + assert_eq!(retrieved_profile.missions_created, 3); } #[test] @@ -301,12 +282,9 @@ fn test_profile_exists() { let profile = Profile { subject: subject.clone(), - successful_missions: 0, - rejected_submissions: 0, - reviewer_score: 0, - founder_score: 0, - total_earnings: 0, - updated_at: env.ledger().timestamp(), + score: 0, + missions_completed: 0, + missions_created: 0, }; client.set_profile(&profile); diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs index 791df82..235be0b 100644 --- a/quid-contract/contracts/quid-reputation/src/types.rs +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -29,10 +29,8 @@ pub struct Profile { #[contracttype] pub enum DataKey { Admin, + /// Per-subject reputation profile. Profile(Address), Attestation(u64), AttestationCount, - Admin, - /// Per-subject reputation profile. - Profile(Address), } From b97c88836f82d11f39b5530f654803cf681cc733 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Tue, 2 Jun 2026 05:45:02 +0100 Subject: [PATCH 33/49] fixed errorrs --- .../contracts/quid-reputation/src/test.rs | 63 +++++++------------ .../contracts/quid-reputation/src/types.rs | 4 +- 2 files changed, 22 insertions(+), 45 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 0bd5dc5..1337bc1 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,11 +1,8 @@ #![cfg(test)] use soroban_sdk::{testutils::Address as _, Address, Env, String}; -use types::Profile; -use crate::{ - error::ReputationError, types::Profile, QuidReputationContract, QuidReputationContractClient, -}; +use crate::{types::Profile, QuidReputationContract, QuidReputationContractClient}; fn setup_test_env() -> (Env, Address, Address) { let env = Env::default(); @@ -185,28 +182,21 @@ fn test_create_and_get_profile() { let client = QuidReputationContractClient::new(&env, &_contract_id); let subject = Address::generate(&env); - let updated_at = env.ledger().timestamp(); let profile = Profile { subject: subject.clone(), - successful_missions: 5, - rejected_submissions: 2, - reviewer_score: 100, - founder_score: 50, - total_earnings: 1000, - updated_at, + score: 150, + missions_completed: 5, + missions_created: 2, }; client.set_profile(&profile); let retrieved_profile = client.get_profile(&subject); assert_eq!(retrieved_profile.subject, subject); - assert_eq!(retrieved_profile.successful_missions, 5); - assert_eq!(retrieved_profile.rejected_submissions, 2); - assert_eq!(retrieved_profile.reviewer_score, 100); - assert_eq!(retrieved_profile.founder_score, 50); - assert_eq!(retrieved_profile.total_earnings, 1000); - assert_eq!(retrieved_profile.updated_at, updated_at); + assert_eq!(retrieved_profile.score, 150); + assert_eq!(retrieved_profile.missions_completed, 5); + assert_eq!(retrieved_profile.missions_created, 2); } #[test] @@ -218,12 +208,9 @@ fn test_update_profile() { let profile = Profile { subject: subject.clone(), - successful_missions: 5, - rejected_submissions: 2, - reviewer_score: 100, - founder_score: 50, - total_earnings: 1000, - updated_at: env.ledger().timestamp(), + score: 100, + missions_completed: 5, + missions_created: 2, }; client.set_profile(&profile); @@ -231,22 +218,17 @@ fn test_update_profile() { // Update the profile let updated_profile = Profile { subject: subject.clone(), - successful_missions: 10, - rejected_submissions: 3, - reviewer_score: 150, - founder_score: 75, - total_earnings: 2000, - updated_at: env.ledger().timestamp(), + score: 225, + missions_completed: 10, + missions_created: 3, }; client.set_profile(&updated_profile); let retrieved_profile = client.get_profile(&subject); - assert_eq!(retrieved_profile.successful_missions, 10); - assert_eq!(retrieved_profile.rejected_submissions, 3); - assert_eq!(retrieved_profile.reviewer_score, 150); - assert_eq!(retrieved_profile.founder_score, 75); - assert_eq!(retrieved_profile.total_earnings, 2000); + assert_eq!(retrieved_profile.score, 225); + assert_eq!(retrieved_profile.missions_completed, 10); + assert_eq!(retrieved_profile.missions_created, 3); } #[test] @@ -260,12 +242,9 @@ fn test_profile_exists() { let profile = Profile { subject: subject.clone(), - successful_missions: 0, - rejected_submissions: 0, - reviewer_score: 0, - founder_score: 0, - total_earnings: 0, - updated_at: env.ledger().timestamp(), + score: 0, + missions_completed: 0, + missions_created: 0, }; client.set_profile(&profile); @@ -302,13 +281,13 @@ fn test_revoke_attestation_publishes_event() { // ------------------------------------------------------------------------- #[test] +#[should_panic(expected = "Error(Contract, #5)")] fn test_get_profile_not_found() { let (env, contract_id, _admin) = setup_test_env(); let client = QuidReputationContractClient::new(&env, &contract_id); let subject = Address::generate(&env); - let result = client.try_get_profile(&subject); - assert_eq!(result, Err(Ok(ReputationError::ProfileNotFound))); + client.get_profile(&subject); } #[test] diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs index 791df82..235be0b 100644 --- a/quid-contract/contracts/quid-reputation/src/types.rs +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -29,10 +29,8 @@ pub struct Profile { #[contracttype] pub enum DataKey { Admin, + /// Per-subject reputation profile. Profile(Address), Attestation(u64), AttestationCount, - Admin, - /// Per-subject reputation profile. - Profile(Address), } From 022a19535c356bcbfb382198439b72d38cdc0731 Mon Sep 17 00:00:00 2001 From: Fatima Sanusi Date: Tue, 2 Jun 2026 11:10:17 +0100 Subject: [PATCH 34/49] feat(contracts): add reputation errors --- quid-contract/contracts/quid-reputation/src/error.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index 573a0d3..a887ea7 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -9,4 +9,8 @@ pub enum ReputationError { InvalidInput = 4, /// No profile exists for the given subject address. ProfileNotFound = 5, + /// Admin address has not been set in the contract. + AdminNotSet = 6, + /// Invalid amount provided for the operation. + InvalidAmount = 7, } From 9058ee3641cb24e6a2118def56772dc2e8c78003 Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Sun, 31 May 2026 18:07:08 +0100 Subject: [PATCH 35/49] style: fix cargo fmt formatting --- .../contracts/quid-reputation/src/lib.rs | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 4cf11ec..c5b85c6 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -27,17 +27,11 @@ impl QuidReputationContract { /// Bootstrap admin for the contract pub fn bootstrap_admin(env: Env, admin: Address) -> Result<(), QuidError> { // Only allow setting admin if not already set - if env - .storage() - .persistent() - .has(&DataKey::Admin) - { + if env.storage().persistent().has(&DataKey::Admin) { return Err(QuidError::NotAuthorized); } - env.storage() - .persistent() - .set(&DataKey::Admin, &admin); + env.storage().persistent().set(&DataKey::Admin, &admin); env.storage() .persistent() @@ -102,9 +96,11 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); - env.storage() - .persistent() - .extend_ttl(&DataKey::Attestation(attestation_id), 5184000, 5184000); + env.storage().persistent().extend_ttl( + &DataKey::Attestation(attestation_id), + 5184000, + 5184000, + ); // Publish AttestationIssuedEvent AttestationIssuedEvent { @@ -145,9 +141,11 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); - env.storage() - .persistent() - .extend_ttl(&DataKey::Attestation(attestation_id), 5184000, 5184000); + env.storage().persistent().extend_ttl( + &DataKey::Attestation(attestation_id), + 5184000, + 5184000, + ); // Publish AttestationRevokedEvent AttestationRevokedEvent { attestation_id }.publish(&env); From a214bed41912f922c933c7d58608606795f64de9 Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Tue, 2 Jun 2026 14:19:17 +0100 Subject: [PATCH 36/49] Merge and resolve conflicts --- quid-contract/contracts/quid-reputation/src/error.rs | 1 - quid-contract/contracts/quid-reputation/src/lib.rs | 5 ----- quid-contract/contracts/quid-reputation/src/types.rs | 2 -- 3 files changed, 8 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index eeb7ac2..4cfd248 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -10,5 +10,4 @@ pub enum QuidError { AlreadyRevoked = 4, AdminNotSet = 5, InvalidLabel = 6, - } diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 35da9ce..a9ed5c8 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -2,11 +2,9 @@ use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; - mod error; mod types; - use error::QuidError; use types::{Attestation, DataKey}; @@ -22,13 +20,11 @@ pub struct AttestationRevokedEvent { pub attestation_id: u64, } - #[contract] pub struct QuidReputationContract; #[contractimpl] impl QuidReputationContract { - /// Bootstrap admin for the contract pub fn bootstrap_admin(env: Env, admin: Address) -> Result<(), QuidError> { // Only allow setting admin if not already set @@ -84,7 +80,6 @@ impl QuidReputationContract { // Get the next attestation id let attestation_id = Self::get_next_attestation_id(&env); - let issued_at = env.ledger().timestamp(); let attestation = Attestation { diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs index d54ed88..aa0f487 100644 --- a/quid-contract/contracts/quid-reputation/src/types.rs +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -18,7 +18,6 @@ pub enum DataKey { Attestation(u64), AttestationCount, Admin, - } #[contracttype] @@ -26,5 +25,4 @@ pub enum AttestationKind { Contributor, Expert, Reviewer, - } From d2e8f6470ffddcedc6f1a8b933f47aa8d61ef01f Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Tue, 2 Jun 2026 15:35:28 +0100 Subject: [PATCH 37/49] Resolve merge conflict and fix formatting/clippy issues --- quid-contract/contracts/quid-reputation/src/lib.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index a9ed5c8..48f9476 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -50,12 +50,10 @@ impl QuidReputationContract { } /// Issue an attestation for a subject - pub fn issue_attestation( env: Env, issuer: Address, subject: Address, - kind: String, label: String, metadata_cid: Option, @@ -84,7 +82,6 @@ impl QuidReputationContract { let attestation = Attestation { id: attestation_id, - issuer: issuer.clone(), subject: subject.clone(), kind, From 79fff47d48863be6ca2614a42a5c79261ff27f33 Mon Sep 17 00:00:00 2001 From: skylar Date: Thu, 4 Jun 2026 07:15:02 +0100 Subject: [PATCH 38/49] Update lib.rs --- .../contracts/quid-reputation/src/lib.rs | 116 ++++-------------- 1 file changed, 23 insertions(+), 93 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 14d3b16..20aff64 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -1,13 +1,11 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Env}; use soroban_sdk::{contract, contractimpl, Address, Env, String}; mod error; mod types; use error::ReputationError; -use types::{DataKey, ReputationProfile}; use types::{Attestation, DataKey, Profile}; /// TTL extension applied to every profile write (roughly 60 days in ledgers). @@ -18,17 +16,6 @@ pub struct QuidReputationContract; #[contractimpl] impl QuidReputationContract { - // ----------------------------------------------------------------------- - // Admin bootstrap - // ----------------------------------------------------------------------- - - /// Bootstrap the contract admin. Can only be called once; subsequent - /// calls return `AdminAlreadySet`. - pub fn set_admin(env: Env, admin: Address) -> Result<(), ReputationError> { - admin.require_auth(); - - if env.storage().instance().has(&DataKey::Admin) { - return Err(ReputationError::AdminAlreadySet); // ------------------------------------------------------------------------- // Admin bootstrap // ------------------------------------------------------------------------- @@ -45,7 +32,6 @@ impl QuidReputationContract { Ok(()) } - /// Return the current admin address. /// Get the admin address. pub fn get_admin(env: Env) -> Result { env.storage() @@ -54,25 +40,6 @@ impl QuidReputationContract { .ok_or(ReputationError::NotAuthorized) } - // ----------------------------------------------------------------------- - // Profile management - // ----------------------------------------------------------------------- - - /// Create or fully replace a reputation profile for `owner`. - /// Only the admin may call this. - pub fn upsert_profile( - env: Env, - owner: Address, - success_count: u32, - rejection_count: u32, - ) -> Result<(), ReputationError> { - Self::require_admin(&env)?; - - let profile = ReputationProfile { - owner: owner.clone(), - success_count, - rejection_count, - last_updated: env.ledger().timestamp(), // ------------------------------------------------------------------------- // Attestations // ------------------------------------------------------------------------- @@ -102,43 +69,6 @@ impl QuidReputationContract { env.storage() .persistent() - .set(&DataKey::Profile(owner.clone()), &profile); - - env.storage() - .persistent() - .extend_ttl(&DataKey::Profile(owner), 5_184_000, 5_184_000); - - Ok(()) - } - - /// Fetch the reputation profile for `owner`. - pub fn get_profile(env: Env, owner: Address) -> Result { - env.storage() - .persistent() - .get(&DataKey::Profile(owner)) - .ok_or(ReputationError::ProfileNotFound) - } - - // ----------------------------------------------------------------------- - // Mutation helpers - // ----------------------------------------------------------------------- - - /// Increment the `success_count` of an existing profile by one. - /// Only the admin may call this. - pub fn increment_success(env: Env, owner: Address) -> Result<(), ReputationError> { - Self::require_admin(&env)?; - - let mut profile = Self::get_profile(env.clone(), owner.clone())?; - profile.success_count = profile.success_count.saturating_add(1); - profile.last_updated = env.ledger().timestamp(); - - env.storage() - .persistent() - .set(&DataKey::Profile(owner.clone()), &profile); - - env.storage() - .persistent() - .extend_ttl(&DataKey::Profile(owner), 5_184_000, 5_184_000); .set(&DataKey::Attestation(attestation_id), &attestation); env.storage().persistent().extend_ttl( @@ -191,22 +121,6 @@ impl QuidReputationContract { Ok(()) } - /// Increment the `rejection_count` of an existing profile by one. - /// Only the admin may call this. - pub fn record_rejection(env: Env, owner: Address) -> Result<(), ReputationError> { - Self::require_admin(&env)?; - - let mut profile = Self::get_profile(env.clone(), owner.clone())?; - profile.rejection_count = profile.rejection_count.saturating_add(1); - profile.last_updated = env.ledger().timestamp(); - - env.storage() - .persistent() - .set(&DataKey::Profile(owner.clone()), &profile); - - env.storage() - .persistent() - .extend_ttl(&DataKey::Profile(owner), 5_184_000, 5_184_000); /// Get the total number of attestations. pub fn get_attestation_count(env: Env) -> u64 { env.storage() @@ -245,11 +159,6 @@ impl QuidReputationContract { Ok(()) } - // ----------------------------------------------------------------------- - // Internal helpers - // ----------------------------------------------------------------------- - - fn require_admin(env: &Env) -> Result<(), ReputationError> { /// Check if a profile exists pub fn profile_exists(env: Env, subject: Address) -> bool { env.storage().persistent().has(&DataKey::Profile(subject)) @@ -287,8 +196,6 @@ impl QuidReputationContract { .ok_or(ReputationError::NotAuthorized)?; admin.require_auth(); - Ok(()) - } if *caller != admin { return Err(ReputationError::NotAuthorized); @@ -323,4 +230,27 @@ impl QuidReputationContract { } } +mod test;file); + env.storage() + .persistent() + .extend_ttl(&key, PROFILE_TTL_LEDGERS, PROFILE_TTL_LEDGERS); + } + + /// Load the profile for `subject`, returning a zeroed default when none + /// exists yet. Mutation methods should call this instead of `get_profile` + /// so that a missing profile is treated as a fresh slate rather than an + /// error. + 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; From c0ba2f41c802ecdfed67f1694a156c2ff628b5d5 Mon Sep 17 00:00:00 2001 From: skylar Date: Thu, 4 Jun 2026 07:18:41 +0100 Subject: [PATCH 39/49] Update lib.rs --- .../contracts/quid-reputation/src/lib.rs | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 20aff64..52a0a41 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -230,27 +230,4 @@ impl QuidReputationContract { } } -mod test;file); - env.storage() - .persistent() - .extend_ttl(&key, PROFILE_TTL_LEDGERS, PROFILE_TTL_LEDGERS); - } - - /// Load the profile for `subject`, returning a zeroed default when none - /// exists yet. Mutation methods should call this instead of `get_profile` - /// so that a missing profile is treated as a fresh slate rather than an - /// error. - 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; From 4fe2d688db413d7e2d29a0fa3366ee713f91136a Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Thu, 4 Jun 2026 11:50:27 +0100 Subject: [PATCH 40/49] fixed --- quid-contract/Cargo.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/quid-contract/Cargo.lock b/quid-contract/Cargo.lock index 2d147e7..f40f0b9 100644 --- a/quid-contract/Cargo.lock +++ b/quid-contract/Cargo.lock @@ -991,9 +991,7 @@ dependencies = [ [[package]] name = "quid-reputation" - version = "0.1.0" - dependencies = [ "soroban-sdk", ] From 15d0bec652251da001d770c0e4bc8b5458b54375 Mon Sep 17 00:00:00 2001 From: bbkenny Date: Sun, 31 May 2026 21:56:17 +0100 Subject: [PATCH 41/49] feat(contracts): implement increment success entrypoint --- .../contracts/quid-reputation/Cargo.toml | 3 + .../contracts/quid-reputation/src/error.rs | 11 +- .../contracts/quid-reputation/src/lib.rs | 115 ++++++-- .../contracts/quid-reputation/src/test.rs | 246 ++++++++++++------ .../contracts/quid-reputation/src/types.rs | 44 ++-- 5 files changed, 288 insertions(+), 131 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/Cargo.toml b/quid-contract/contracts/quid-reputation/Cargo.toml index 3ae7425..49365d7 100644 --- a/quid-contract/contracts/quid-reputation/Cargo.toml +++ b/quid-contract/contracts/quid-reputation/Cargo.toml @@ -14,6 +14,9 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + [profile.release] opt-level = "z" diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index 72cff04..a4d2ab7 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -8,9 +8,10 @@ pub enum QuidError { ProfileNotFound = 2, AdminAlreadySet = 3, InvalidScore = 4, - AttestationNotFound = 2, - InvalidExpiryTime = 3, - AlreadyRevoked = 4, - AdminNotSet = 5, - InvalidLabel = 6, + AttestationNotFound = 5, + InvalidExpiryTime = 6, + AlreadyRevoked = 7, + AdminNotSet = 8, + InvalidLabel = 9, + InvalidRewardAmount = 10, } diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index b36dbbc..d667ad6 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -6,7 +6,9 @@ mod error; mod types; use error::QuidError; -use types::{Attestation, DataKey}; +use types::{Attestation, DataKey, Profile}; + +const PROFILE_TTL_LEDGERS: u32 = 5_184_000; #[contractevent(topics = ["attestation", "issued"])] pub struct AttestationIssuedEvent { @@ -27,13 +29,11 @@ pub struct QuidReputationContract; impl QuidReputationContract { /// Bootstrap admin for the contract pub fn bootstrap_admin(env: Env, admin: Address) -> Result<(), QuidError> { - // Only allow setting admin if not already set if env.storage().persistent().has(&DataKey::Admin) { - return Err(QuidError::NotAuthorized); + return Err(QuidError::AdminAlreadySet); } env.storage().persistent().set(&DataKey::Admin, &admin); - env.storage() .persistent() .extend_ttl(&DataKey::Admin, 5184000, 5184000); @@ -59,15 +59,12 @@ impl QuidReputationContract { metadata_cid: Option, expires_at: Option, ) -> Result { - // Require issuer auth issuer.require_auth(); - // Validate label is not empty if label.is_empty() { return Err(QuidError::InvalidLabel); } - // Validate expiry time if provided if let Some(expiry) = expires_at { let now = env.ledger().timestamp(); if expiry <= now { @@ -75,9 +72,7 @@ impl QuidReputationContract { } } - // Get the next attestation id let attestation_id = Self::get_next_attestation_id(&env); - let issued_at = env.ledger().timestamp(); let attestation = Attestation { @@ -92,8 +87,6 @@ impl QuidReputationContract { revoked: false, }; - // Store the attestation - env.storage() .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); @@ -104,7 +97,6 @@ impl QuidReputationContract { 5184000, ); - // Publish AttestationIssuedEvent AttestationIssuedEvent { attestation_id, issuer, @@ -127,18 +119,14 @@ impl QuidReputationContract { pub fn revoke_attestation(env: Env, attestation_id: u64) -> Result<(), QuidError> { let mut attestation = Self::get_attestation(env.clone(), attestation_id)?; - // Require issuer auth attestation.issuer.require_auth(); - // Check if already revoked if attestation.revoked { return Err(QuidError::AlreadyRevoked); } - // Mark as revoked attestation.revoked = true; - // Store updated attestation env.storage() .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); @@ -148,14 +136,60 @@ impl QuidReputationContract { 5184000, 5184000, ); - // Publish AttestationRevokedEvent - AttestationRevokedEvent { attestation_id }.publish(&env); AttestationRevokedEvent { attestation_id }.publish(&env); + + Ok(()) + } + + // ------------------------------------------------------------------------- + // Public profile getter + // ------------------------------------------------------------------------- + + /// Fetch the reputation profile for `subject`. + pub fn get_profile(env: Env, subject: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::Profile(subject)) + .ok_or(QuidError::ProfileNotFound) + } + + // ------------------------------------------------------------------------- + // Profile Mutations + // ------------------------------------------------------------------------- + + /// Record a successful mission for `subject` and add `reward_amount` to their total earnings. + pub fn increment_success( + env: Env, + subject: Address, + reward_amount: i128, + ) -> Result<(), QuidError> { + let admin = Self::get_admin(env.clone())?; + admin.require_auth(); + + if reward_amount < 0 { + return Err(QuidError::InvalidRewardAmount); + } + + let mut profile = Self::load_or_default(&env, subject.clone()); + profile.successful_missions += 1; + profile.total_earnings += reward_amount; + profile.updated_at = env.ledger().timestamp(); + + Self::store_profile(&env, &profile); + + env.events().publish( + (soroban_sdk::symbol_short!("Profile"), soroban_sdk::symbol_short!("updated")), + profile, + ); + Ok(()) } - /// Get the next attestation id (internal helper) + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + fn get_next_attestation_id(env: &Env) -> u64 { let current: u64 = env .storage() @@ -176,3 +210,46 @@ impl QuidReputationContract { next_id } } + +#[allow(dead_code)] +impl QuidReputationContract { + pub(crate) fn require_admin(env: &Env, caller: &Address) -> Result<(), QuidError> { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(QuidError::AdminNotSet)?; + + admin.require_auth(); + + if *caller != admin { + return Err(QuidError::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, + successful_missions: 0, + missions_created: 0, + total_earnings: 0, + updated_at: env.ledger().timestamp(), + }) + } +} + +mod test; diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index c393a62..4ee8122 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,54 +1,67 @@ #![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_contract(None, QuidReputationContract); + let client = QuidReputationContractClient::new(&env, &contract_id); + + client.bootstrap_admin(&admin); + + (env, admin, client) +} + #[test] fn test_bootstrap_admin() { let env = Env::default(); - let admin = Address::random(&env); - - let result = QuidReputationContract::bootstrap_admin(env.clone(), admin.clone()); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, QuidReputationContract); + let client = QuidReputationContractClient::new(&env, &contract_id); - assert!(result.is_ok()); + client.bootstrap_admin(&admin); - let retrieved_admin = QuidReputationContract::get_admin(env).unwrap(); + let retrieved_admin = client.get_admin(); assert_eq!(retrieved_admin, admin); } #[test] fn test_bootstrap_admin_twice_fails() { let env = Env::default(); - let admin1 = Address::random(&env); - let admin2 = Address::random(&env); + env.mock_all_auths(); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let contract_id = env.register_contract(None, QuidReputationContract); + let client = QuidReputationContractClient::new(&env, &contract_id); - QuidReputationContract::bootstrap_admin(env.clone(), admin1).unwrap(); - let result = QuidReputationContract::bootstrap_admin(env, admin2); + client.bootstrap_admin(&admin1); + let result = client.try_bootstrap_admin(&admin2); assert!(result.is_err()); } #[test] fn test_issue_attestation() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); - - let attestation_id = QuidReputationContract::issue_attestation( - env.clone(), - issuer.clone(), - subject.clone(), - String::from_slice(&env, "contributor"), - String::from_slice(&env, "Active contributor"), - Some(String::from_slice(&env, "QmExample123")), - None, - ) - .unwrap(); + 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, "Active contributor"), + &Some(String::from_str(&env, "QmExample123")), + &None, + ); assert_eq!(attestation_id, 1); - let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); + let attestation = client.get_attestation(&attestation_id); assert_eq!(attestation.issuer, issuer); assert_eq!(attestation.subject, subject); assert_eq!(attestation.revoked, false); @@ -56,66 +69,60 @@ fn test_issue_attestation() { #[test] fn test_issue_attestation_with_expiry() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); + let (env, _admin, client) = setup_test_env(); + let issuer = Address::generate(&env); + let subject = Address::generate(&env); let now = env.ledger().timestamp(); let expiry = now + 86400; // 1 day from now - let attestation_id = QuidReputationContract::issue_attestation( - env.clone(), - issuer.clone(), - subject.clone(), - String::from_slice(&env, "expert"), - String::from_slice(&env, "Expert reviewer"), - None, - Some(expiry), - ) - .unwrap(); - - let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); + let attestation_id = client.issue_attestation( + &issuer, + &subject, + &String::from_str(&env, "expert"), + &String::from_str(&env, "Expert reviewer"), + &None, + &Some(expiry), + ); + + let attestation = client.get_attestation(&attestation_id); assert_eq!(attestation.expires_at, Some(expiry)); } #[test] fn test_revoke_attestation() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); - - let attestation_id = QuidReputationContract::issue_attestation( - env.clone(), - issuer.clone(), - subject.clone(), - String::from_slice(&env, "contributor"), - String::from_slice(&env, "Active contributor"), - None, - None, - ) - .unwrap(); - - let result = QuidReputationContract::revoke_attestation(env.clone(), attestation_id); - assert!(result.is_ok()); - - let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); + 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, "Active contributor"), + &None, + &None, + ); + + client.revoke_attestation(&attestation_id); + + let attestation = client.get_attestation(&attestation_id); assert_eq!(attestation.revoked, true); } #[test] fn test_empty_label_fails() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); - - let result = QuidReputationContract::issue_attestation( - env, - issuer, - subject, - String::from_slice(&env, "contributor"), - String::from_slice(&env, ""), - None, - None, + let (env, _admin, client) = setup_test_env(); + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let result = client.try_issue_attestation( + &issuer, + &subject, + &String::from_str(&env, "contributor"), + &String::from_str(&env, ""), + &None, + &None, ); assert!(result.is_err()); @@ -123,23 +130,94 @@ fn test_empty_label_fails() { #[test] fn test_invalid_expiry_time_fails() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); + let (env, _admin, client) = setup_test_env(); + let issuer = Address::generate(&env); + let subject = Address::generate(&env); let now = env.ledger().timestamp(); - let past_expiry = now - 1000; // In the past - - let result = QuidReputationContract::issue_attestation( - env, - issuer, - subject, - String::from_slice(&env, "contributor"), - String::from_slice(&env, "Active contributor"), - None, - Some(past_expiry), + let past_expiry = now; // Expiry <= now should fail + + let result = client.try_issue_attestation( + &issuer, + &subject, + &String::from_str(&env, "contributor"), + &String::from_str(&env, "Active contributor"), + &None, + &Some(past_expiry), ); assert!(result.is_err()); +} + +// ------------------------------------------------------------------------- +// Profile helper tests +// ------------------------------------------------------------------------- + +#[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_eq!(result, Err(Ok(QuidError::ProfileNotFound))); +} + +#[test] +fn test_store_and_get_profile() { + let (env, admin, client) = setup_test_env(); + let subject = Address::generate(&env); + + client.increment_success(&subject, &0); // ensure profile exists + + let fetched = client.get_profile(&subject); + assert_eq!(fetched.score, 0); + assert_eq!(fetched.successful_missions, 1); + assert_eq!(fetched.missions_created, 0); + assert_eq!(fetched.total_earnings, 0); +} + +#[test] +fn test_load_or_default_returns_zeroed_profile() { + let (env, _admin, client) = setup_test_env(); + + let subject = Address::generate(&env); + + // If profile doesn't exist, it should return error. + let result = client.try_get_profile(&subject); + assert_eq!(result, Err(Ok(QuidError::ProfileNotFound))); +} + +// ------------------------------------------------------------------------- +// increment_success tests +// ------------------------------------------------------------------------- + +#[test] +fn test_increment_success() { + let (env, _admin, client) = setup_test_env(); + + let subject = Address::generate(&env); + + // First increment + client.increment_success(&subject, &100); + + let fetched = client.get_profile(&subject); + assert_eq!(fetched.successful_missions, 1); + assert_eq!(fetched.total_earnings, 100); + + // Second increment + client.increment_success(&subject, &50); + + let fetched2 = client.get_profile(&subject); + assert_eq!(fetched2.successful_missions, 2); + assert_eq!(fetched2.total_earnings, 150); +} + +#[test] +fn test_increment_success_invalid_reward() { + let (env, _admin, client) = setup_test_env(); + let subject = Address::generate(&env); + + let res = client.try_increment_success(&subject, &-10); + assert_eq!(res, Err(Ok(QuidError::InvalidRewardAmount))); } diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs index 014c12a..24053ec 100644 --- a/quid-contract/contracts/quid-reputation/src/types.rs +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -1,26 +1,3 @@ -use soroban_sdk::{contracttype, Address}; - -/// On-chain reputation profile for a single user address. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReputationProfile { - /// The wallet this profile belongs to. - pub owner: Address, - /// Number of successfully completed missions / approved submissions. - pub success_count: u32, - /// Number of rejected submissions. - pub rejection_count: u32, - /// Ledger timestamp of the last mutation. - pub last_updated: u64, -} - -/// Storage keys used by the reputation contract. -#[contracttype] -pub enum DataKey { - /// Admin address that is allowed to mutate profiles. - Admin, - /// Per-user reputation profile, keyed by wallet address. - Profile(Address), use soroban_sdk::{contracttype, Address, String}; #[contracttype] @@ -36,11 +13,32 @@ pub struct Attestation { pub expires_at: Option, pub revoked: bool, } + +/// On-chain reputation profile for a single subject address. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Profile { + /// The address this profile belongs to. + pub subject: Address, + /// Cumulative reputation score (starts at 0). + pub score: i64, + /// Total number of successful missions. + pub successful_missions: u32, + /// Total number of missions created (for creators). + pub missions_created: u32, + /// Total earnings from completed missions. + pub total_earnings: i128, + /// Last updated timestamp. + pub updated_at: u64, +} + #[contracttype] pub enum DataKey { Attestation(u64), AttestationCount, Admin, + /// Per-subject reputation profile. + Profile(Address), } #[contracttype] From 6ed2a80d92f97e65bf1c66d551e92bdc46dd22db Mon Sep 17 00:00:00 2001 From: bbkenny Date: Sun, 31 May 2026 22:43:53 +0100 Subject: [PATCH 42/49] style: fix rustfmt trailing whitespaces and tuple alignment --- quid-contract/contracts/quid-reputation/src/lib.rs | 5 ++++- quid-contract/contracts/quid-reputation/src/test.rs | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index d667ad6..7865bf2 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -179,7 +179,10 @@ impl QuidReputationContract { Self::store_profile(&env, &profile); env.events().publish( - (soroban_sdk::symbol_short!("Profile"), soroban_sdk::symbol_short!("updated")), + ( + soroban_sdk::symbol_short!("Profile"), + soroban_sdk::symbol_short!("updated"), + ), profile, ); diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 4ee8122..4080407 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -196,17 +196,17 @@ fn test_increment_success() { let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); - + // First increment client.increment_success(&subject, &100); - + let fetched = client.get_profile(&subject); assert_eq!(fetched.successful_missions, 1); assert_eq!(fetched.total_earnings, 100); // Second increment client.increment_success(&subject, &50); - + let fetched2 = client.get_profile(&subject); assert_eq!(fetched2.successful_missions, 2); assert_eq!(fetched2.total_earnings, 150); @@ -217,7 +217,7 @@ fn test_increment_success_invalid_reward() { let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); - + let res = client.try_increment_success(&subject, &-10); assert_eq!(res, Err(Ok(QuidError::InvalidRewardAmount))); } From 176ee7cde3cc54de2f1b2cf02b38b67cd85c88b6 Mon Sep 17 00:00:00 2001 From: bbkenny Date: Mon, 1 Jun 2026 01:12:23 +0100 Subject: [PATCH 43/49] fix: resolve clippy warnings for unused variables and deprecated publish --- quid-contract/contracts/quid-reputation/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 7865bf2..ceb4a88 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -159,6 +159,7 @@ impl QuidReputationContract { // ------------------------------------------------------------------------- /// Record a successful mission for `subject` and add `reward_amount` to their total earnings. + #[allow(deprecated)] pub fn increment_success( env: Env, subject: Address, From fba2c4f52cf19c5cc308f4729bda9e9f15581d93 Mon Sep 17 00:00:00 2001 From: bbkenny Date: Fri, 5 Jun 2026 10:30:58 +0100 Subject: [PATCH 44/49] fix: resolve delimiter and formatting errors in milestone escrow and reputation tests --- .../quid-milestone-escrow/src/lib.rs | 237 ++++++------------ .../contracts/quid-reputation/src/test.rs | 6 +- 2 files changed, 81 insertions(+), 162 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index ece3896..8e24890 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -1,57 +1,30 @@ #![no_std] -use soroban_sdk::{contract, contractevent, contractimpl, contracttype, Address, Env}; -mod error; -use error::MilestoneEscrowError; - -#[contracttype] -pub enum ProgramStatus { - Active, - Completed, - Cancelled, -} +use soroban_sdk::{contract, contractevent, contractimpl, token, Address, Env, String}; -#[contracttype] -pub enum DataKey { - Program(u64), - Milestone(u64, u32), -} - -#[contracttype] -pub struct Program { - pub id: u64, - pub owner: Address, - pub status: ProgramStatus, - pub escrow_balance: i128, -} +mod error; +pub mod types; -#[contracttype] -pub struct Milestone { - pub program_id: u64, - pub milestone_id: u32, - pub amount: i128, - pub pending: bool, -} +use error::MilestoneEscrowError; +use types::{DataKey, Milestone, MilestoneStatus, Program, ProgramStatus}; #[contractevent(topics = ["program", "created"])] pub struct ProgramCreatedEvent { pub program_id: u64, - pub owner: Address, + pub sponsor: Address, + pub recipient: Address, } -#[contractevent(topics = ["milestone", "added"])] -pub struct MilestoneAddedEvent { +#[contractevent(topics = ["program", "status_changed"])] +pub struct ProgramStatusChangedEvent { pub program_id: u64, - pub milestone_id: u32, - pub amount: i128, + pub status: ProgramStatus, } -#[contractevent(topics = ["milestone", "paid"])] -pub struct MilestonePaidEvent { +#[contractevent(topics = ["milestone", "added"])] +pub struct MilestoneAddedEvent { pub program_id: u64, - pub milestone_id: u32, - pub payer: Address, - pub payee: Address, + pub milestone_id: u64, pub amount: i128, } @@ -71,27 +44,46 @@ pub struct ProgramCancelledEvent { } #[contract] -pub struct MilestoneEscrowContract; +pub struct QuidMilestoneEscrowContract; #[contractimpl] -impl MilestoneEscrowContract { +impl QuidMilestoneEscrowContract { pub fn create_program( env: Env, - owner: Address, - program_id: u64, - initial_escrow: i128, + sponsor: Address, + recipient: Address, + token: Address, + total_amount: i128, + reviewer: Option
, + metadata_cid: Option, ) -> Result { - owner.require_auth(); + sponsor.require_auth(); - if initial_escrow <= 0 { + if total_amount <= 0 { return Err(MilestoneEscrowError::InvalidAmount); } + token::Client::new(&env, &token).transfer( + &sponsor, + env.current_contract_address(), + &total_amount, + ); + + let program_id = Self::get_next_program_id(&env); + let status = ProgramStatus::Active; let program = Program { id: program_id, - owner: owner.clone(), - status: ProgramStatus::Active, - escrow_balance: initial_escrow, + sponsor: sponsor.clone(), + recipient: recipient.clone(), + reviewer, + token, + total_amount, + allocated_amount: 0, + released_amount: 0, + milestone_count: 0, + metadata_cid, + created_at: env.ledger().timestamp(), + status, }; env.storage() @@ -171,47 +163,19 @@ impl MilestoneEscrowContract { } .publish(&env); - Ok(()) + Ok(milestone_id) } - pub fn pay_milestone( + pub fn get_milestone( env: Env, - payer: Address, program_id: u64, - milestone_id: u32, - payee: Address, - amount: i128, - ) -> Result<(), MilestoneEscrowError> { - payer.require_auth(); - - let mut program: Program = env - .storage() - .persistent() - .get(&DataKey::Program(program_id)) - .ok_or(MilestoneEscrowError::ProgramNotFound)?; - - if program.status != ProgramStatus::Active { - return Err(MilestoneEscrowError::ProgramClosed); - } - - let mut milestone: Milestone = env - .storage() + milestone_id: u64, + ) -> Result { + env.storage() .persistent() .get(&DataKey::Milestone(program_id, milestone_id)) - .ok_or(MilestoneEscrowError::MilestoneNotFound)?; - - if !milestone.pending { - return Err(MilestoneEscrowError::MilestoneNotPending); - } - if amount != milestone.amount { - return Err(MilestoneEscrowError::InvalidAmount); - } - if amount > program.escrow_balance { - return Err(MilestoneEscrowError::AmountExceedsEscrow); - } - - milestone.pending = false; - program.escrow_balance -= amount; + .ok_or(MilestoneEscrowError::MilestoneNotFound) + } pub fn approve_milestone( env: Env, @@ -342,91 +306,46 @@ impl MilestoneEscrowContract { pub fn get_program_status(env: Env) -> ProgramStatus { env.storage() .persistent() - .set(&DataKey::Milestone(program_id, milestone_id), &milestone); - env.storage() - .persistent() - .set(&DataKey::Program(program_id), &program); - - MilestonePaidEvent { - program_id, - milestone_id, - payer: payer.clone(), - payee: payee.clone(), - amount, - } - .publish(&env); - - Ok(()) + .get(&DataKey::ProgramStatus) + .unwrap_or_default() } - pub fn cancel_program( - env: Env, - owner: Address, - program_id: u64, - ) -> Result<(), MilestoneEscrowError> { - owner.require_auth(); - - let mut program: Program = env - .storage() - .persistent() - .get(&DataKey::Program(program_id)) - .ok_or(MilestoneEscrowError::ProgramNotFound)?; - - if program.owner != owner { - return Err(MilestoneEscrowError::NotAuthorized); - } - if program.status != ProgramStatus::Active { - return Err(MilestoneEscrowError::ProgramClosed); - } - - program.status = ProgramStatus::Cancelled; + pub fn set_program_status(env: Env, status: ProgramStatus) { env.storage() .persistent() - .set(&DataKey::Program(program_id), &program); - - ProgramCancelledEvent { - program_id, - cancelled_by: owner, - } - .publish(&env); - - Ok(()) + .set(&DataKey::ProgramStatus, &status); } - pub fn change_program_status( - env: Env, - owner: Address, - program_id: u64, - new_status: ProgramStatus, - ) -> Result<(), MilestoneEscrowError> { - owner.require_auth(); - - let mut program: Program = env - .storage() + pub fn get_milestone_status(env: Env) -> MilestoneStatus { + env.storage() .persistent() - .get(&DataKey::Program(program_id)) - .ok_or(MilestoneEscrowError::ProgramNotFound)?; - - if program.owner != owner { - return Err(MilestoneEscrowError::NotAuthorized); - } - if program.status == ProgramStatus::Cancelled && new_status != ProgramStatus::Cancelled { - return Err(MilestoneEscrowError::ProgramClosed); - } + .get(&DataKey::MilestoneStatus) + .unwrap_or_default() + } - let old_status = program.status; - program.status = new_status; + pub fn set_milestone_status(env: Env, status: MilestoneStatus) { env.storage() .persistent() - .set(&DataKey::Program(program_id), &program); + .set(&DataKey::MilestoneStatus, &status); + } - ProgramStatusChangedEvent { - program_id, - old_status, - new_status: program.status, - } - .publish(&env); + pub fn get_program_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ProgramCount) + .unwrap_or(0) + } - Ok(()) + fn get_next_program_id(env: &Env) -> u64 { + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ProgramCount) + .unwrap_or(0); + count += 1; + env.storage().instance().set(&DataKey::ProgramCount, &count); + count } } + +mod test; diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 4080407..e4cb066 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -6,13 +6,13 @@ 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_contract(None, QuidReputationContract); let client = QuidReputationContractClient::new(&env, &contract_id); - + client.bootstrap_admin(&admin); - + (env, admin, client) } From 863bcbe0c9c77a1de8e270253070a8301f12a45b Mon Sep 17 00:00:00 2001 From: bbkenny Date: Fri, 5 Jun 2026 10:37:15 +0100 Subject: [PATCH 45/49] fix: resolve clippy warnings for unused variables and deprecated methods --- quid-contract/contracts/quid-reputation/src/test.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index e4cb066..6ea92c3 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -8,7 +8,7 @@ fn setup_test_env() -> (Env, Address, QuidReputationContractClient<'static>) { env.mock_all_auths(); let admin = Address::generate(&env); - let contract_id = env.register_contract(None, QuidReputationContract); + let contract_id = env.register(QuidReputationContract, ()); let client = QuidReputationContractClient::new(&env, &contract_id); client.bootstrap_admin(&admin); @@ -20,7 +20,7 @@ fn setup_test_env() -> (Env, Address, QuidReputationContractClient<'static>) { fn test_bootstrap_admin() { let env = Env::default(); let admin = Address::generate(&env); - let contract_id = env.register_contract(None, QuidReputationContract); + let contract_id = env.register(QuidReputationContract, ()); let client = QuidReputationContractClient::new(&env, &contract_id); client.bootstrap_admin(&admin); @@ -35,7 +35,7 @@ fn test_bootstrap_admin_twice_fails() { env.mock_all_auths(); let admin1 = Address::generate(&env); let admin2 = Address::generate(&env); - let contract_id = env.register_contract(None, QuidReputationContract); + let contract_id = env.register(QuidReputationContract, ()); let client = QuidReputationContractClient::new(&env, &contract_id); client.bootstrap_admin(&admin1); @@ -64,7 +64,7 @@ fn test_issue_attestation() { let attestation = client.get_attestation(&attestation_id); assert_eq!(attestation.issuer, issuer); assert_eq!(attestation.subject, subject); - assert_eq!(attestation.revoked, false); + assert!(!attestation.revoked); } #[test] @@ -107,7 +107,7 @@ fn test_revoke_attestation() { client.revoke_attestation(&attestation_id); let attestation = client.get_attestation(&attestation_id); - assert_eq!(attestation.revoked, true); + assert!(attestation.revoked); } #[test] @@ -164,7 +164,7 @@ fn test_get_profile_not_found() { #[test] fn test_store_and_get_profile() { - let (env, admin, client) = setup_test_env(); + let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); client.increment_success(&subject, &0); // ensure profile exists From 1726a9e17d5db6a3814346c98772d21565ea4cee Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Sat, 6 Jun 2026 07:58:39 +0100 Subject: [PATCH 46/49] fixed errors --- .../quid-milestone-escrow/src/lib.rs | 296 ++++++--------- .../contracts/quid-reputation/src/error.rs | 15 +- .../contracts/quid-reputation/src/lib.rs | 197 +++++----- .../contracts/quid-reputation/src/test.rs | 345 ++++++------------ .../contracts/quid-reputation/src/types.rs | 30 +- 5 files changed, 332 insertions(+), 551 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index ece3896..6844867 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -1,57 +1,29 @@ #![no_std] -use soroban_sdk::{contract, contractevent, contractimpl, contracttype, Address, Env}; +use soroban_sdk::{contract, contractevent, contractimpl, token, Address, Env, String}; mod error; -use error::MilestoneEscrowError; - -#[contracttype] -pub enum ProgramStatus { - Active, - Completed, - Cancelled, -} - -#[contracttype] -pub enum DataKey { - Program(u64), - Milestone(u64, u32), -} +mod types; -#[contracttype] -pub struct Program { - pub id: u64, - pub owner: Address, - pub status: ProgramStatus, - pub escrow_balance: i128, -} - -#[contracttype] -pub struct Milestone { - pub program_id: u64, - pub milestone_id: u32, - pub amount: i128, - pub pending: bool, -} +use error::MilestoneEscrowError; +use types::{DataKey, Milestone, MilestoneStatus, Program, ProgramStatus}; #[contractevent(topics = ["program", "created"])] pub struct ProgramCreatedEvent { pub program_id: u64, - pub owner: Address, + pub sponsor: Address, + pub recipient: Address, } -#[contractevent(topics = ["milestone", "added"])] -pub struct MilestoneAddedEvent { +#[contractevent(topics = ["program", "status"])] +pub struct ProgramStatusChangedEvent { pub program_id: u64, - pub milestone_id: u32, - pub amount: i128, + pub status: ProgramStatus, } -#[contractevent(topics = ["milestone", "paid"])] -pub struct MilestonePaidEvent { +#[contractevent(topics = ["milestone", "added"])] +pub struct MilestoneAddedEvent { pub program_id: u64, - pub milestone_id: u32, - pub payer: Address, - pub payee: Address, + pub milestone_id: u64, pub amount: i128, } @@ -71,27 +43,95 @@ pub struct ProgramCancelledEvent { } #[contract] -pub struct MilestoneEscrowContract; +pub struct QuidMilestoneEscrowContract; #[contractimpl] -impl MilestoneEscrowContract { +impl QuidMilestoneEscrowContract { + // ── Status helpers (used by tests for default/roundtrip) ────────────── + + pub fn get_program_status(env: Env) -> ProgramStatus { + env.storage() + .instance() + .get(&DataKey::ProgramStatus) + .unwrap_or_default() + } + + pub fn set_program_status(env: Env, status: ProgramStatus) { + env.storage() + .instance() + .set(&DataKey::ProgramStatus, &status); + } + + pub fn get_milestone_status(env: Env) -> MilestoneStatus { + env.storage() + .instance() + .get(&DataKey::MilestoneStatus) + .unwrap_or_default() + } + + pub fn set_milestone_status(env: Env, status: MilestoneStatus) { + env.storage() + .instance() + .set(&DataKey::MilestoneStatus, &status); + } + + // ── Program counter ─────────────────────────────────────────────────── + + pub fn get_program_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ProgramCount) + .unwrap_or(0_u64) + } + + // ── Core contract methods ───────────────────────────────────────────── + pub fn create_program( env: Env, - owner: Address, - program_id: u64, - initial_escrow: i128, + sponsor: Address, + recipient: Address, + token: Address, + total_amount: i128, + reviewer: Option
, + metadata_cid: Option, ) -> Result { - owner.require_auth(); + sponsor.require_auth(); - if initial_escrow <= 0 { + if total_amount <= 0 { return Err(MilestoneEscrowError::InvalidAmount); } + // Transfer funds from sponsor into the contract + 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, - owner: owner.clone(), + 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, - escrow_balance: initial_escrow, }; env.storage() @@ -104,7 +144,12 @@ impl MilestoneEscrowContract { recipient, } .publish(&env); - ProgramStatusChangedEvent { program_id, status }.publish(&env); + + ProgramStatusChangedEvent { + program_id, + status: ProgramStatus::Active, + } + .publish(&env); Ok(program_id) } @@ -171,47 +216,19 @@ impl MilestoneEscrowContract { } .publish(&env); - Ok(()) + Ok(milestone_id) } - pub fn pay_milestone( + pub fn get_milestone( env: Env, - payer: Address, program_id: u64, - milestone_id: u32, - payee: Address, - amount: i128, - ) -> Result<(), MilestoneEscrowError> { - payer.require_auth(); - - let mut program: Program = env - .storage() - .persistent() - .get(&DataKey::Program(program_id)) - .ok_or(MilestoneEscrowError::ProgramNotFound)?; - - if program.status != ProgramStatus::Active { - return Err(MilestoneEscrowError::ProgramClosed); - } - - let mut milestone: Milestone = env - .storage() + milestone_id: u64, + ) -> Result { + env.storage() .persistent() .get(&DataKey::Milestone(program_id, milestone_id)) - .ok_or(MilestoneEscrowError::MilestoneNotFound)?; - - if !milestone.pending { - return Err(MilestoneEscrowError::MilestoneNotPending); - } - if amount != milestone.amount { - return Err(MilestoneEscrowError::InvalidAmount); - } - if amount > program.escrow_balance { - return Err(MilestoneEscrowError::AmountExceedsEscrow); - } - - milestone.pending = false; - program.escrow_balance -= amount; + .ok_or(MilestoneEscrowError::MilestoneNotFound) + } pub fn approve_milestone( env: Env, @@ -223,39 +240,34 @@ impl MilestoneEscrowContract { let mut program = Self::get_program(env.clone(), program_id)?; - // #181 – authorize sponsor or optional reviewer without consuming the Option let is_sponsor = approver == program.sponsor; let is_reviewer = program.reviewer.as_ref() == Some(&approver); if !is_sponsor && !is_reviewer { return Err(MilestoneEscrowError::NotAuthorized); } - // #182 – reject non-active programs if program.status != ProgramStatus::Active { return Err(MilestoneEscrowError::InvalidState); } - // Reject milestones not in Pending status let mut milestone = Self::get_milestone(env.clone(), program_id, milestone_id)?; if milestone.status != MilestoneStatus::Pending { return Err(MilestoneEscrowError::InvalidState); } - // Transfer milestone funds from contract to recipient + let paid_amount = milestone.amount; + token::Client::new(&env, &program.token).transfer( &env.current_contract_address(), &program.recipient, - &milestone.amount, + &paid_amount, ); - // Mark the milestone as Paid and persist - let paid_amount = milestone.amount; milestone.status = MilestoneStatus::Paid; env.storage() .persistent() .set(&DataKey::Milestone(program_id, milestone_id), &milestone); - // Increase released_amount program.released_amount = program .released_amount .checked_add(paid_amount) @@ -271,7 +283,6 @@ impl MilestoneEscrowContract { } .publish(&env); - // If all funds are released, move the program to Completed if program.released_amount >= program.total_amount { program.status = ProgramStatus::Completed; ProgramStatusChangedEvent { @@ -297,17 +308,14 @@ impl MilestoneEscrowContract { let mut program = Self::get_program(env.clone(), program_id)?; - // #183 – only the program sponsor may cancel if sponsor != program.sponsor { return Err(MilestoneEscrowError::NotAuthorized); } - // Reject non-active programs if program.status != ProgramStatus::Active { return Err(MilestoneEscrowError::InvalidState); } - // Refund total_amount - released_amount from contract to sponsor let refund_amount = program.total_amount - program.released_amount; if refund_amount > 0 { token::Client::new(&env, &program.token).transfer( @@ -317,7 +325,6 @@ impl MilestoneEscrowContract { ); } - // Mark the program as Cancelled and persist program.status = ProgramStatus::Cancelled; env.storage() .persistent() @@ -338,95 +345,6 @@ impl MilestoneEscrowContract { Ok(()) } - - pub fn get_program_status(env: Env) -> ProgramStatus { - env.storage() - .persistent() - .set(&DataKey::Milestone(program_id, milestone_id), &milestone); - env.storage() - .persistent() - .set(&DataKey::Program(program_id), &program); - - MilestonePaidEvent { - program_id, - milestone_id, - payer: payer.clone(), - payee: payee.clone(), - amount, - } - .publish(&env); - - Ok(()) - } - - pub fn cancel_program( - env: Env, - owner: Address, - program_id: u64, - ) -> Result<(), MilestoneEscrowError> { - owner.require_auth(); - - let mut program: Program = env - .storage() - .persistent() - .get(&DataKey::Program(program_id)) - .ok_or(MilestoneEscrowError::ProgramNotFound)?; - - if program.owner != owner { - return Err(MilestoneEscrowError::NotAuthorized); - } - if program.status != ProgramStatus::Active { - return Err(MilestoneEscrowError::ProgramClosed); - } - - program.status = ProgramStatus::Cancelled; - env.storage() - .persistent() - .set(&DataKey::Program(program_id), &program); - - ProgramCancelledEvent { - program_id, - cancelled_by: owner, - } - .publish(&env); - - Ok(()) - } - - pub fn change_program_status( - env: Env, - owner: Address, - program_id: u64, - new_status: ProgramStatus, - ) -> Result<(), MilestoneEscrowError> { - owner.require_auth(); - - let mut program: Program = env - .storage() - .persistent() - .get(&DataKey::Program(program_id)) - .ok_or(MilestoneEscrowError::ProgramNotFound)?; - - if program.owner != owner { - return Err(MilestoneEscrowError::NotAuthorized); - } - if program.status == ProgramStatus::Cancelled && new_status != ProgramStatus::Cancelled { - return Err(MilestoneEscrowError::ProgramClosed); - } - - let old_status = program.status; - program.status = new_status; - env.storage() - .persistent() - .set(&DataKey::Program(program_id), &program); - - ProgramStatusChangedEvent { - program_id, - old_status, - new_status: program.status, - } - .publish(&env); - - Ok(()) - } } + +mod test; diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index 72cff04..fe504e3 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -2,15 +2,10 @@ use soroban_sdk::contracterror; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] - -pub enum QuidError { +pub enum ReputationError { NotAuthorized = 1, - ProfileNotFound = 2, - AdminAlreadySet = 3, - InvalidScore = 4, - AttestationNotFound = 2, - InvalidExpiryTime = 3, - AlreadyRevoked = 4, - AdminNotSet = 5, - InvalidLabel = 6, + InvalidInput = 2, + AlreadyRevoked = 3, + AttestationNotFound = 4, + ProfileNotFound = 5, } diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index 7110696..198eae8 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -1,23 +1,15 @@ #![no_std] - use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; mod error; mod types; -use error::QuidError; +use error::ReputationError; use types::{Attestation, DataKey, Profile}; const PROFILE_TTL_LEDGERS: u32 = 5_184_000; -#[contractevent(topics = ["attestation", "issued"])] -pub struct AttestationIssuedEvent { - pub attestation_id: u64, - pub issuer: Address, - pub subject: Address, -} - -#[contractevent(topics = ["attestation", "revoked"], data_format = "single-value")] +#[contractevent(topics = ["attestation", "revoked"])] pub struct AttestationRevokedEvent { pub attestation_id: u64, pub revoked_by: Address, @@ -28,138 +20,114 @@ pub struct QuidReputationContract; #[contractimpl] impl QuidReputationContract { - /// Bootstrap admin for the contract - pub fn bootstrap_admin(env: Env, admin: Address) -> Result<(), QuidError> { - // Only allow setting admin if not already set - if env.storage().persistent().has(&DataKey::Admin) { - return Err(QuidError::NotAuthorized); - } + // ------------------------------------------------------------------------- + // Admin bootstrap + // ------------------------------------------------------------------------- - env.storage().persistent().set(&DataKey::Admin, &admin); + /// Initialize the contract with an admin address. May only be called once. + pub fn initialize(env: Env, admin: Address) -> Result<(), ReputationError> { + admin.require_auth(); - env.storage() - .persistent() - .extend_ttl(&DataKey::Admin, 5184000, 5184000); + if env.storage().instance().has(&DataKey::Admin) { + return Err(ReputationError::InvalidInput); + } + env.storage().instance().set(&DataKey::Admin, &admin); Ok(()) } - /// Get the current admin - pub fn get_admin(env: Env) -> Result { + /// Get the admin address. + pub fn get_admin(env: Env) -> Result { env.storage() - .persistent() + .instance() .get(&DataKey::Admin) - .ok_or(QuidError::AdminNotSet) + .ok_or(ReputationError::NotAuthorized) } - /// Issue an attestation for a subject + // ------------------------------------------------------------------------- + // Attestations + // ------------------------------------------------------------------------- + + /// Issue a new attestation. pub fn issue_attestation( env: Env, issuer: Address, subject: Address, - kind: String, - label: String, - metadata_cid: Option, - expires_at: Option, - ) -> Result { - // Require issuer auth + attestation_type: String, + data_cid: String, + ) -> Result { issuer.require_auth(); - // Validate label is not empty - if label.is_empty() { - return Err(QuidError::InvalidLabel); - } - - // Validate expiry time if provided - if let Some(expiry) = expires_at { - let now = env.ledger().timestamp(); - if expiry <= now { - return Err(QuidError::InvalidExpiryTime); - } - } - - // Get the next attestation id let attestation_id = Self::get_next_attestation_id(&env); - let issued_at = env.ledger().timestamp(); let attestation = Attestation { id: attestation_id, - issuer: issuer.clone(), - subject: subject.clone(), - kind, - label, - metadata_cid, + issuer, + subject, + attestation_type, + data_cid, issued_at, - expires_at, revoked: false, }; - // Store the attestation - env.storage() .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); env.storage().persistent().extend_ttl( &DataKey::Attestation(attestation_id), - 5184000, - 5184000, + PROFILE_TTL_LEDGERS, + PROFILE_TTL_LEDGERS, ); - // Publish AttestationIssuedEvent - AttestationIssuedEvent { - attestation_id, - issuer, - subject, - } - .publish(&env); - Ok(attestation_id) } - /// Get an attestation by id - pub fn get_attestation(env: Env, attestation_id: u64) -> Result { + /// Get an attestation by ID. + pub fn get_attestation(env: Env, attestation_id: u64) -> Result { env.storage() .persistent() .get(&DataKey::Attestation(attestation_id)) - .ok_or(QuidError::AttestationNotFound) + .ok_or(ReputationError::AttestationNotFound) } - /// Revoke an attestation - pub fn revoke_attestation(env: Env, attestation_id: u64) -> Result<(), QuidError> { - let mut attestation = Self::get_attestation(env.clone(), attestation_id)?; + /// Revoke an attestation (issuer or admin only). + pub fn revoke_attestation( + env: Env, + caller: Address, + attestation_id: u64, + ) -> Result<(), ReputationError> { + caller.require_auth(); - // Require issuer auth - attestation.issuer.require_auth(); + let mut attestation = Self::get_attestation(env.clone(), attestation_id)?; - // Check if already revoked if attestation.revoked { - return Err(QuidError::AlreadyRevoked); + return Err(ReputationError::AlreadyRevoked); } - // Mark as revoked - attestation.revoked = true; + let admin = Self::get_admin(env.clone())?; + if caller != attestation.issuer && caller != admin { + return Err(ReputationError::NotAuthorized); + } - // Store updated attestation + attestation.revoked = true; env.storage() .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); env.storage().persistent().extend_ttl( &DataKey::Attestation(attestation_id), - 5184000, - 5184000, + PROFILE_TTL_LEDGERS, + PROFILE_TTL_LEDGERS, ); - // Publish AttestationRevokedEvent AttestationRevokedEvent { attestation_id, revoked_by: caller, } .publish(&env); - AttestationRevokedEvent { attestation_id }.publish(&env); Ok(()) } @@ -178,7 +146,7 @@ impl QuidReputationContract { .has(&DataKey::Attestation(attestation_id)) } - /// Get a profile by subject address + /// Get a profile by subject address. pub fn get_profile(env: Env, subject: Address) -> Result { env.storage() .persistent() @@ -186,7 +154,7 @@ impl QuidReputationContract { .ok_or(ReputationError::ProfileNotFound) } - /// Create or update a profile + /// Create or update a profile. pub fn set_profile(env: Env, profile: Profile) -> Result<(), ReputationError> { profile.subject.require_auth(); @@ -194,36 +162,75 @@ impl QuidReputationContract { .persistent() .set(&DataKey::Profile(profile.subject.clone()), &profile); - env.storage() - .persistent() - .extend_ttl(&DataKey::Profile(profile.subject), 5184000, 5184000); + env.storage().persistent().extend_ttl( + &DataKey::Profile(profile.subject), + PROFILE_TTL_LEDGERS, + PROFILE_TTL_LEDGERS, + ); Ok(()) } - /// Check if a profile exists + /// Check if a profile exists. pub fn profile_exists(env: Env, subject: Address) -> bool { env.storage().persistent().has(&DataKey::Profile(subject)) } - // Private helper function to get the next attestation ID fn get_next_attestation_id(env: &Env) -> u64 { - let current: u64 = env + let mut count: u64 = env .storage() - .persistent() + .instance() .get(&DataKey::AttestationCount) .unwrap_or(0); + count += 1; + env.storage() + .instance() + .set(&DataKey::AttestationCount, &count); + count + } +} - let next_id = current + 1; +// ------------------------------------------------------------------------- +// Internal helpers +// ------------------------------------------------------------------------- +#[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() - .set(&DataKey::AttestationCount, &next_id); + .extend_ttl(&key, PROFILE_TTL_LEDGERS, PROFILE_TTL_LEDGERS); + } + pub(crate) fn load_or_default(env: &Env, subject: Address) -> Profile { env.storage() .persistent() - .extend_ttl(&DataKey::AttestationCount, 5184000, 5184000); - - next_id + .get(&DataKey::Profile(subject.clone())) + .unwrap_or(Profile { + subject, + score: 0, + missions_completed: 0, + missions_created: 0, + }) } } + +mod test; diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 50f4a78..e83146c 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -8,274 +8,190 @@ use soroban_sdk::{ fn setup_test_env() -> (Env, Address, Address) { let env = Env::default(); - let admin = Address::random(&env); + env.mock_all_auths(); - let result = QuidReputationContract::bootstrap_admin(env.clone(), admin.clone()); + let contract_id = env.register(QuidReputationContract, ()); + let admin = Address::generate(&env); - assert!(result.is_ok()); + let client = QuidReputationContractClient::new(&env, &contract_id); + client.initialize(&admin); - let retrieved_admin = QuidReputationContract::get_admin(env).unwrap(); - assert_eq!(retrieved_admin, admin); + (env, contract_id, admin) } #[test] -fn test_bootstrap_admin() { +fn test_initialize() { let env = Env::default(); - let admin = Address::random(&env); + env.mock_all_auths(); + + let contract_id = env.register(QuidReputationContract, ()); + let client = QuidReputationContractClient::new(&env, &contract_id); - let result = QuidReputationContract::bootstrap_admin(env.clone(), admin.clone()); + let admin = Address::generate(&env); - assert!(result.is_ok()); + client.initialize(&admin); - let retrieved_admin = QuidReputationContract::get_admin(env).unwrap(); - assert_eq!(retrieved_admin, admin); + let stored_admin = client.get_admin(); + assert_eq!(stored_admin, admin); } #[test] -fn test_bootstrap_admin_twice_fails() { - let env = Env::default(); - let admin1 = Address::random(&env); - let admin2 = Address::random(&env); +fn test_issue_attestation() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); - QuidReputationContract::bootstrap_admin(env.clone(), admin1).unwrap(); - let result = QuidReputationContract::bootstrap_admin(env, admin2); + let issuer = Address::generate(&env); + let subject = Address::generate(&env); - assert!(result.is_err()); -} + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); -#[test] -fn test_issue_attestation() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); - - let attestation_id = QuidReputationContract::issue_attestation( - env.clone(), - issuer.clone(), - subject.clone(), - String::from_slice(&env, "contributor"), - String::from_slice(&env, "Active contributor"), - Some(String::from_slice(&env, "QmExample123")), - None, - ) - .unwrap(); + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); assert_eq!(attestation_id, 1); - let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); + let attestation = client.get_attestation(&attestation_id); assert_eq!(attestation.issuer, issuer); assert_eq!(attestation.subject, subject); - assert_eq!(attestation.revoked, false); + assert_eq!(attestation.attestation_type, attestation_type); + assert_eq!(attestation.data_cid, data_cid); + assert!(!attestation.revoked); } #[test] -fn test_issue_attestation_with_expiry() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); - - let now = env.ledger().timestamp(); - let expiry = now + 86400; // 1 day from now - - let attestation_id = QuidReputationContract::issue_attestation( - env.clone(), - issuer.clone(), - subject.clone(), - String::from_slice(&env, "expert"), - String::from_slice(&env, "Expert reviewer"), - None, - Some(expiry), - ) - .unwrap(); - - let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); - assert_eq!(attestation.expires_at, Some(expiry)); -} +fn test_revoke_attestation_by_issuer() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); -#[test] -fn test_revoke_attestation() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); - - let attestation_id = QuidReputationContract::issue_attestation( - env.clone(), - issuer.clone(), - subject.clone(), - String::from_slice(&env, "contributor"), - String::from_slice(&env, "Active contributor"), - None, - None, - ) - .unwrap(); - - let result = QuidReputationContract::revoke_attestation(env.clone(), attestation_id); - assert!(result.is_ok()); - - let attestation = QuidReputationContract::get_attestation(env, attestation_id).unwrap(); - assert_eq!(attestation.revoked, true); -} + let issuer = Address::generate(&env); + let subject = Address::generate(&env); -#[test] -fn test_empty_label_fails() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); - - let result = QuidReputationContract::issue_attestation( - env, - issuer, - subject, - String::from_slice(&env, "contributor"), - String::from_slice(&env, ""), - None, - None, - ); - - assert!(result.is_err()); + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + client.revoke_attestation(&issuer, &attestation_id); + + let attestation = client.get_attestation(&attestation_id); + assert!(attestation.revoked); } #[test] -fn test_invalid_expiry_time_fails() { - let env = Env::default(); - let issuer = Address::random(&env); - let subject = Address::random(&env); +fn test_revoke_attestation_by_admin() { + let (env, contract_id, admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let subject = Address::generate(&env); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - assert!(client.attestation_exists(&attestation_id)); - let now = env.ledger().timestamp(); - let past_expiry = now - 1000; // In the past - - let result = QuidReputationContract::issue_attestation( - env, - issuer, - subject, - String::from_slice(&env, "contributor"), - String::from_slice(&env, "Active contributor"), - None, - Some(past_expiry), - ); - - assert!(result.is_err()); + client.revoke_attestation(&admin, &attestation_id); + + let attestation = client.get_attestation(&attestation_id); + assert!(attestation.revoked); } -<<<<<<<<< Temporary merge branch 1 #[test] -fn test_create_and_get_profile() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); +#[should_panic(expected = "Error(Contract, #1)")] +fn test_revoke_attestation_unauthorized() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + let issuer = Address::generate(&env); let subject = Address::generate(&env); - let updated_at = env.ledger().timestamp(); + let unauthorized = Address::generate(&env); - let profile = Profile { - subject: subject.clone(), - successful_missions: 5, - rejected_submissions: 2, - reviewer_score: 100, - founder_score: 50, - total_earnings: 1000, - updated_at, - }; + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); - client.set_profile(&profile); + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - let retrieved_profile = client.get_profile(&subject); - assert_eq!(retrieved_profile.subject, subject); - assert_eq!(retrieved_profile.successful_missions, 5); - assert_eq!(retrieved_profile.rejected_submissions, 2); - assert_eq!(retrieved_profile.reviewer_score, 100); - assert_eq!(retrieved_profile.founder_score, 50); - assert_eq!(retrieved_profile.total_earnings, 1000); - assert_eq!(retrieved_profile.updated_at, updated_at); + client.revoke_attestation(&unauthorized, &attestation_id); } #[test] -fn test_update_profile() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); +#[should_panic(expected = "Error(Contract, #3)")] +fn test_revoke_already_revoked_attestation() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + let issuer = Address::generate(&env); let subject = Address::generate(&env); - let profile = Profile { - subject: subject.clone(), - successful_missions: 5, - rejected_submissions: 2, - reviewer_score: 100, - founder_score: 50, - total_earnings: 1000, - updated_at: env.ledger().timestamp(), - }; - - client.set_profile(&profile); - - // Update the profile - let updated_profile = Profile { - subject: subject.clone(), - successful_missions: 10, - rejected_submissions: 3, - reviewer_score: 150, - founder_score: 75, - total_earnings: 2000, - updated_at: env.ledger().timestamp(), - }; + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); - client.set_profile(&updated_profile); + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - let retrieved_profile = client.get_profile(&subject); - assert_eq!(retrieved_profile.successful_missions, 10); - assert_eq!(retrieved_profile.rejected_submissions, 3); - assert_eq!(retrieved_profile.reviewer_score, 150); - assert_eq!(retrieved_profile.founder_score, 75); - assert_eq!(retrieved_profile.total_earnings, 2000); + client.revoke_attestation(&issuer, &attestation_id); + client.revoke_attestation(&issuer, &attestation_id); } #[test] -fn test_profile_exists() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); +fn test_attestation_count() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + let issuer = Address::generate(&env); let subject = Address::generate(&env); - assert!(!client.profile_exists(&subject)); + assert_eq!(client.get_attestation_count(), 0); - let profile = Profile { - subject: subject.clone(), - successful_missions: 0, - rejected_submissions: 0, - reviewer_score: 0, - founder_score: 0, - total_earnings: 0, - updated_at: env.ledger().timestamp(), - }; + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); - client.set_profile(&profile); + client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + assert_eq!(client.get_attestation_count(), 1); - assert!(client.profile_exists(&subject)); + client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + assert_eq!(client.get_attestation_count(), 2); } #[test] -#[should_panic(expected = "Error(Contract, #5)")] -fn test_get_profile_not_found() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); +fn test_attestation_exists() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + let issuer = Address::generate(&env); let subject = Address::generate(&env); - client.get_profile(&subject); -========= -// ------------------------------------------------------------------------- -// Profile helper tests -// ------------------------------------------------------------------------- + + assert!(!client.attestation_exists(&1)); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + assert!(client.attestation_exists(&attestation_id)); +} #[test] -fn test_get_profile_not_found() { +fn test_revoke_attestation_publishes_event() { let (env, contract_id, _admin) = setup_test_env(); let client = QuidReputationContractClient::new(&env, &contract_id); + let issuer = Address::generate(&env); let subject = Address::generate(&env); - let result = client.try_get_profile(&subject); - assert_eq!(result, Err(Ok(ReputationError::ProfileNotFound))); + + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + + client.revoke_attestation(&issuer, &attestation_id); + + let events = env.events().all(); + let event = events.last().unwrap(); + + assert_eq!(event.0, contract_id); + + let topics = event.1.clone(); + assert_eq!(topics.len(), 2); } #[test] @@ -355,7 +271,6 @@ fn test_update_profile() { client.set_profile(&profile); - // Update the profile let updated_profile = Profile { subject: subject.clone(), score: 225, @@ -401,31 +316,3 @@ fn test_get_profile_not_found() { let subject = Address::generate(&env); client.get_profile(&subject); } - -#[test] -fn test_revoke_attestation_publishes_event() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - - let issuer = Address::generate(&env); - let subject = Address::generate(&env); - - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - - // Revoke the attestation - client.revoke_attestation(&issuer, &attestation_id); - - // Verify the event was published - let events = env.events().all(); - let event = events.last().unwrap(); - - // Check that the event contains the attestation_id and revoked_by - assert_eq!(event.0, contract_id); - - // The event topics should contain "attestation" and "revoked" - let topics = event.1.clone(); - assert_eq!(topics.len(), 2); -} diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs index 2e2a8dd..7f2f3a3 100644 --- a/quid-contract/contracts/quid-reputation/src/types.rs +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -1,26 +1,3 @@ -use soroban_sdk::{contracttype, Address}; - -/// On-chain reputation profile for a single user address. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReputationProfile { - /// The wallet this profile belongs to. - pub owner: Address, - /// Number of successfully completed missions / approved submissions. - pub success_count: u32, - /// Number of rejected submissions. - pub rejection_count: u32, - /// Ledger timestamp of the last mutation. - pub last_updated: u64, -} - -/// Storage keys used by the reputation contract. -#[contracttype] -pub enum DataKey { - /// Admin address that is allowed to mutate profiles. - Admin, - /// Per-user reputation profile, keyed by wallet address. - Profile(Address), use soroban_sdk::{contracttype, Address, String}; #[contracttype] @@ -29,11 +6,9 @@ pub struct Attestation { pub id: u64, pub issuer: Address, pub subject: Address, - pub kind: String, - pub label: String, - pub metadata_cid: Option, + pub attestation_type: String, + pub data_cid: String, pub issued_at: u64, - pub expires_at: Option, pub revoked: bool, } @@ -58,7 +33,6 @@ pub enum DataKey { Profile(Address), Attestation(u64), AttestationCount, - Admin, } #[contracttype] From c74105e9db8070ccd78f7376ee901894921c5324 Mon Sep 17 00:00:00 2001 From: Birdmannn Date: Sat, 6 Jun 2026 09:32:41 +0100 Subject: [PATCH 47/49] ci --- .../quid-milestone-escrow/src/lib.rs | 100 +++---- .../contracts/quid-reputation/src/error.rs | 13 +- .../contracts/quid-reputation/src/lib.rs | 86 +----- .../contracts/quid-reputation/src/test.rs | 256 ++++++++---------- .../contracts/quid-reputation/src/types.rs | 20 +- 5 files changed, 165 insertions(+), 310 deletions(-) diff --git a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs index 8ea8fb1..05e676c 100644 --- a/quid-contract/contracts/quid-milestone-escrow/src/lib.rs +++ b/quid-contract/contracts/quid-milestone-escrow/src/lib.rs @@ -1,9 +1,8 @@ #![no_std] - 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}; @@ -48,6 +47,43 @@ pub struct QuidMilestoneEscrowContract; #[contractimpl] impl QuidMilestoneEscrowContract { + // ── Status helpers ──────────────────────────────────────────────────── + + pub fn get_program_status(env: Env) -> ProgramStatus { + env.storage() + .instance() + .get(&DataKey::ProgramStatus) + .unwrap_or_default() + } + + pub fn set_program_status(env: Env, status: ProgramStatus) { + env.storage() + .instance() + .set(&DataKey::ProgramStatus, &status); + } + + pub fn get_milestone_status(env: Env) -> MilestoneStatus { + env.storage() + .instance() + .get(&DataKey::MilestoneStatus) + .unwrap_or_default() + } + + pub fn set_milestone_status(env: Env, status: MilestoneStatus) { + env.storage() + .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, @@ -62,14 +98,12 @@ impl QuidMilestoneEscrowContract { if total_amount <= 0 { return Err(MilestoneEscrowError::InvalidAmount); } - - // Transfer funds from sponsor into the contract + token::Client::new(&env, &token).transfer( &sponsor, env.current_contract_address(), &total_amount, ); - // let mut count: u64 = env .storage() @@ -82,14 +116,6 @@ impl QuidMilestoneEscrowContract { let program_id = count; let created_at = env.ledger().timestamp(); - token::Client::new(&env, &token).transfer( - &sponsor, - env.current_contract_address(), - &total_amount, - ); - - let program_id = Self::get_next_program_id(&env); - let status = ProgramStatus::Active; let program = Program { id: program_id, sponsor: sponsor.clone(), @@ -101,8 +127,8 @@ impl QuidMilestoneEscrowContract { released_amount: 0, milestone_count: 0, metadata_cid, - created_at: env.ledger().timestamp(), - status, + created_at, + status: ProgramStatus::Active, }; env.storage() @@ -316,50 +342,6 @@ impl QuidMilestoneEscrowContract { Ok(()) } - - pub fn get_program_status(env: Env) -> ProgramStatus { - env.storage() - .persistent() - .get(&DataKey::ProgramStatus) - .unwrap_or_default() - } - - pub fn set_program_status(env: Env, status: ProgramStatus) { - env.storage() - .persistent() - .set(&DataKey::ProgramStatus, &status); - } - - pub fn get_milestone_status(env: Env) -> MilestoneStatus { - env.storage() - .persistent() - .get(&DataKey::MilestoneStatus) - .unwrap_or_default() - } - - pub fn set_milestone_status(env: Env, status: MilestoneStatus) { - env.storage() - .persistent() - .set(&DataKey::MilestoneStatus, &status); - } - - pub fn get_program_count(env: Env) -> u64 { - env.storage() - .instance() - .get(&DataKey::ProgramCount) - .unwrap_or(0) - } - - fn get_next_program_id(env: &Env) -> u64 { - let mut count: u64 = env - .storage() - .instance() - .get(&DataKey::ProgramCount) - .unwrap_or(0); - count += 1; - env.storage().instance().set(&DataKey::ProgramCount, &count); - count - } } mod test; diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index 3db3a3e..fe504e3 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -4,13 +4,8 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ReputationError { NotAuthorized = 1, - ProfileNotFound = 2, - AdminAlreadySet = 3, - InvalidScore = 4, - AttestationNotFound = 5, - InvalidExpiryTime = 6, - AlreadyRevoked = 7, - AdminNotSet = 8, - InvalidLabel = 9, - InvalidRewardAmount = 10, + InvalidInput = 2, + AlreadyRevoked = 3, + AttestationNotFound = 4, + ProfileNotFound = 5, } diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index f2c650e..76a7b5e 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -24,7 +24,6 @@ impl QuidReputationContract { // Admin bootstrap // ------------------------------------------------------------------------- - /// Initialize the contract with an admin address. May only be called once. pub fn initialize(env: Env, admin: Address) -> Result<(), ReputationError> { admin.require_auth(); @@ -36,7 +35,6 @@ impl QuidReputationContract { Ok(()) } - /// Get the admin address. pub fn get_admin(env: Env) -> Result { env.storage() .instance() @@ -48,29 +46,15 @@ impl QuidReputationContract { // Attestations // ------------------------------------------------------------------------- - /// Issue a new attestation. pub fn issue_attestation( env: Env, issuer: Address, subject: Address, - kind: String, - label: String, - metadata_cid: Option, - expires_at: Option, - ) -> Result { + attestation_type: String, + data_cid: String, + ) -> Result { issuer.require_auth(); - if label.is_empty() { - return Err(QuidError::InvalidLabel); - } - - if let Some(expiry) = expires_at { - let now = env.ledger().timestamp(); - if expiry <= now { - return Err(QuidError::InvalidExpiryTime); - } - } - let attestation_id = Self::get_next_attestation_id(&env); let issued_at = env.ledger().timestamp(); @@ -94,17 +78,9 @@ impl QuidReputationContract { PROFILE_TTL_LEDGERS, ); - AttestationIssuedEvent { - attestation_id, - issuer, - subject, - } - .publish(&env); - Ok(attestation_id) } - /// Get an attestation by ID. pub fn get_attestation(env: Env, attestation_id: u64) -> Result { env.storage() .persistent() @@ -112,7 +88,6 @@ impl QuidReputationContract { .ok_or(ReputationError::AttestationNotFound) } - /// Revoke an attestation (issuer or admin only). pub fn revoke_attestation( env: Env, caller: Address, @@ -122,8 +97,6 @@ impl QuidReputationContract { let mut attestation = Self::get_attestation(env.clone(), attestation_id)?; - attestation.issuer.require_auth(); - if attestation.revoked { return Err(ReputationError::AlreadyRevoked); } @@ -134,7 +107,6 @@ impl QuidReputationContract { } attestation.revoked = true; - env.storage() .persistent() .set(&DataKey::Attestation(attestation_id), &attestation); @@ -154,7 +126,6 @@ impl QuidReputationContract { Ok(()) } - /// Get the total number of attestations. pub fn get_attestation_count(env: Env) -> u64 { env.storage() .instance() @@ -162,14 +133,16 @@ impl QuidReputationContract { .unwrap_or(0) } - /// Check if an attestation exists. pub fn attestation_exists(env: Env, attestation_id: u64) -> bool { env.storage() .persistent() .has(&DataKey::Attestation(attestation_id)) } - /// Get a profile by subject address. + // ------------------------------------------------------------------------- + // Profiles + // ------------------------------------------------------------------------- + pub fn get_profile(env: Env, subject: Address) -> Result { env.storage() .persistent() @@ -177,7 +150,6 @@ impl QuidReputationContract { .ok_or(ReputationError::ProfileNotFound) } - /// Create or update a profile. pub fn set_profile(env: Env, profile: Profile) -> Result<(), ReputationError> { profile.subject.require_auth(); @@ -194,7 +166,6 @@ impl QuidReputationContract { Ok(()) } - /// Check if a profile exists. pub fn profile_exists(env: Env, subject: Address) -> bool { env.storage().persistent().has(&DataKey::Profile(subject)) } @@ -214,7 +185,7 @@ impl QuidReputationContract { } // ------------------------------------------------------------------------- -// Internal helpers +// Internal helpers used by tests // ------------------------------------------------------------------------- #[allow(dead_code)] @@ -256,45 +227,4 @@ impl QuidReputationContract { } } -#[allow(dead_code)] -impl QuidReputationContract { - pub(crate) fn require_admin(env: &Env, caller: &Address) -> Result<(), QuidError> { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(QuidError::AdminNotSet)?; - - admin.require_auth(); - - if *caller != admin { - return Err(QuidError::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, - successful_missions: 0, - missions_created: 0, - total_earnings: 0, - updated_at: env.ledger().timestamp(), - }) - } -} - mod test; diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index ba9dbd3..d780681 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,64 +1,59 @@ #![cfg(test)] -use super::*; -use soroban_sdk::{testutils::Address as _, Address, Env, String}; +use crate::{types::Profile, QuidReputationContract, QuidReputationContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, String, +}; -fn setup_test_env() -> (Env, Address, QuidReputationContractClient<'static>) { +fn setup_test_env() -> (Env, Address, Address) { 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.bootstrap_admin(&admin); - - (env, admin, client) -} - -fn setup_test_env() -> (Env, Address, Address) { - 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.bootstrap_admin(&admin); + let client = QuidReputationContractClient::new(&env, &contract_id); + client.initialize(&admin); - let retrieved_admin = client.get_admin(); - assert_eq!(retrieved_admin, admin); + (env, contract_id, admin) } +// ------------------------------------------------------------------------- +// Admin bootstrap tests +// ------------------------------------------------------------------------- + #[test] fn test_initialize() { let env = Env::default(); env.mock_all_auths(); - let admin1 = Address::generate(&env); - let admin2 = Address::generate(&env); + let contract_id = env.register(QuidReputationContract, ()); let client = QuidReputationContractClient::new(&env, &contract_id); - client.bootstrap_admin(&admin1); - let result = client.try_bootstrap_admin(&admin2); + let admin = Address::generate(&env); + client.initialize(&admin); let stored_admin = client.get_admin(); assert_eq!(stored_admin, admin); } +// ------------------------------------------------------------------------- +// Attestation tests +// ------------------------------------------------------------------------- + #[test] fn test_issue_attestation() { - let (env, _admin, client) = setup_test_env(); + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); + 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, "Active contributor"), - &Some(String::from_str(&env, "QmExample123")), - &None, - ); + let attestation_type = String::from_str(&env, "skill"); + let data_cid = String::from_str(&env, "QmTest123"); + + let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); assert_eq!(attestation_id, 1); @@ -204,160 +199,131 @@ fn test_revoke_attestation_publishes_event() { let topics = event.1.clone(); assert_eq!(topics.len(), 2); - assert!(!attestation.revoked); } -#[test] -fn test_issue_attestation_with_expiry() { - let (env, _admin, client) = setup_test_env(); - let issuer = Address::generate(&env); - let subject = Address::generate(&env); - - let now = env.ledger().timestamp(); - let expiry = now + 86400; // 1 day from now - - let attestation_id = client.issue_attestation( - &issuer, - &subject, - &String::from_str(&env, "expert"), - &String::from_str(&env, "Expert reviewer"), - &None, - &Some(expiry), - ); - - let attestation = client.get_attestation(&attestation_id); - assert_eq!(attestation.expires_at, Some(expiry)); -} +// ------------------------------------------------------------------------- +// Profile tests +// ------------------------------------------------------------------------- #[test] -fn test_revoke_attestation() { - let (env, _admin, client) = setup_test_env(); - let issuer = Address::generate(&env); - let subject = Address::generate(&env); +fn test_store_and_get_profile() { + let (env, contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &contract_id); - let attestation_id = client.issue_attestation( - &issuer, - &subject, - &String::from_str(&env, "contributor"), - &String::from_str(&env, "Active contributor"), - &None, - &None, - ); + let subject = Address::generate(&env); - client.revoke_attestation(&attestation_id); + env.as_contract(&contract_id, || { + let profile = Profile { + subject: subject.clone(), + score: 42, + missions_completed: 3, + missions_created: 1, + }; + QuidReputationContract::store_profile(&env, &profile); + }); - let attestation = client.get_attestation(&attestation_id); - assert!(attestation.revoked); + let fetched = client.get_profile(&subject); + assert_eq!(fetched.score, 42); + assert_eq!(fetched.missions_completed, 3); + assert_eq!(fetched.missions_created, 1); } #[test] -fn test_empty_label_fails() { - let (env, _admin, client) = setup_test_env(); - let issuer = Address::generate(&env); - let subject = Address::generate(&env); +fn test_load_or_default_returns_zeroed_profile() { + let (env, contract_id, _admin) = setup_test_env(); - let result = client.try_issue_attestation( - &issuer, - &subject, - &String::from_str(&env, "contributor"), - &String::from_str(&env, ""), - &None, - &None, - ); + let subject = Address::generate(&env); - assert!(result.is_err()); + env.as_contract(&contract_id, || { + let profile = QuidReputationContract::load_or_default(&env, subject.clone()); + assert_eq!(profile.subject, subject); + assert_eq!(profile.score, 0); + assert_eq!(profile.missions_completed, 0); + assert_eq!(profile.missions_created, 0); + }); } #[test] -fn test_invalid_expiry_time_fails() { - let (env, _admin, client) = setup_test_env(); - let issuer = Address::generate(&env); +fn test_create_and_get_profile() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); + let subject = Address::generate(&env); - let now = env.ledger().timestamp(); - let past_expiry = now; // Expiry <= now should fail + let profile = Profile { + subject: subject.clone(), + score: 150, + missions_completed: 5, + missions_created: 2, + }; - let result = client.try_issue_attestation( - &issuer, - &subject, - &String::from_str(&env, "contributor"), - &String::from_str(&env, "Active contributor"), - &None, - &Some(past_expiry), - ); + client.set_profile(&profile); - assert!(result.is_err()); + let retrieved_profile = client.get_profile(&subject); + assert_eq!(retrieved_profile.subject, subject); + assert_eq!(retrieved_profile.score, 150); + assert_eq!(retrieved_profile.missions_completed, 5); + assert_eq!(retrieved_profile.missions_created, 2); } -// ------------------------------------------------------------------------- -// Profile helper tests -// ------------------------------------------------------------------------- - #[test] -fn test_get_profile_not_found() { - let (env, _admin, client) = setup_test_env(); +fn test_update_profile() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); let subject = Address::generate(&env); - let result = client.try_get_profile(&subject); - assert_eq!(result, Err(Ok(QuidError::ProfileNotFound))); -} -#[test] -fn test_store_and_get_profile() { - let (env, _admin, client) = setup_test_env(); - let subject = Address::generate(&env); - - client.increment_success(&subject, &0); // ensure profile exists + let profile = Profile { + subject: subject.clone(), + score: 100, + missions_completed: 5, + missions_created: 2, + }; - let fetched = client.get_profile(&subject); - assert_eq!(fetched.score, 0); - assert_eq!(fetched.successful_missions, 1); - assert_eq!(fetched.missions_created, 0); - assert_eq!(fetched.total_earnings, 0); -} + client.set_profile(&profile); -#[test] -fn test_load_or_default_returns_zeroed_profile() { - let (env, _admin, client) = setup_test_env(); + let updated_profile = Profile { + subject: subject.clone(), + score: 225, + missions_completed: 10, + missions_created: 3, + }; - let subject = Address::generate(&env); + client.set_profile(&updated_profile); - // If profile doesn't exist, it should return error. - let result = client.try_get_profile(&subject); - assert_eq!(result, Err(Ok(QuidError::ProfileNotFound))); + let retrieved_profile = client.get_profile(&subject); + assert_eq!(retrieved_profile.score, 225); + assert_eq!(retrieved_profile.missions_completed, 10); + assert_eq!(retrieved_profile.missions_created, 3); } -// ------------------------------------------------------------------------- -// increment_success tests -// ------------------------------------------------------------------------- - #[test] -fn test_increment_success() { - let (env, _admin, client) = setup_test_env(); +fn test_profile_exists() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); let subject = Address::generate(&env); - // First increment - client.increment_success(&subject, &100); + assert!(!client.profile_exists(&subject)); - let fetched = client.get_profile(&subject); - assert_eq!(fetched.successful_missions, 1); - assert_eq!(fetched.total_earnings, 100); + let profile = Profile { + subject: subject.clone(), + score: 0, + missions_completed: 0, + missions_created: 0, + }; - // Second increment - client.increment_success(&subject, &50); + client.set_profile(&profile); - let fetched2 = client.get_profile(&subject); - assert_eq!(fetched2.successful_missions, 2); - assert_eq!(fetched2.total_earnings, 150); + assert!(client.profile_exists(&subject)); } #[test] -fn test_increment_success_invalid_reward() { - let (env, _admin, client) = setup_test_env(); +#[should_panic(expected = "Error(Contract, #5)")] +fn test_get_profile_not_found() { + let (env, _contract_id, _admin) = setup_test_env(); + let client = QuidReputationContractClient::new(&env, &_contract_id); let subject = Address::generate(&env); - - let res = client.try_increment_success(&subject, &-10); - assert_eq!(res, Err(Ok(QuidError::InvalidRewardAmount))); + client.get_profile(&subject); } diff --git a/quid-contract/contracts/quid-reputation/src/types.rs b/quid-contract/contracts/quid-reputation/src/types.rs index b88f082..d7deea2 100644 --- a/quid-contract/contracts/quid-reputation/src/types.rs +++ b/quid-contract/contracts/quid-reputation/src/types.rs @@ -16,18 +16,10 @@ pub struct Attestation { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Profile { - /// The address this profile belongs to. pub subject: Address, - /// Cumulative reputation score (starts at 0). pub score: i64, - /// Total number of successful missions. - pub successful_missions: u32, - /// Total number of missions created (for creators). + pub missions_completed: u32, pub missions_created: u32, - /// Total earnings from completed missions. - pub total_earnings: i128, - /// Last updated timestamp. - pub updated_at: u64, } #[contracttype] @@ -37,14 +29,4 @@ pub enum DataKey { Profile(Address), Attestation(u64), AttestationCount, - Admin, - /// Per-subject reputation profile. - Profile(Address), -} - -#[contracttype] -pub enum AttestationKind { - Contributor, - Expert, - Reviewer, } From 4723c1dabee9a8a290ca417743f8bd0ea6f287b4 Mon Sep 17 00:00:00 2001 From: chisomvictorcv-sketch Date: Sat, 6 Jun 2026 06:46:25 -0700 Subject: [PATCH 48/49] feat(contracts): implement upsert profile entrypoint closes #193 --- .../contracts/quid-reputation/src/error.rs | 11 +- .../contracts/quid-reputation/src/lib.rs | 56 ++- .../contracts/quid-reputation/src/test.rs | 322 ++++-------------- 3 files changed, 110 insertions(+), 279 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index fe504e3..fcfdf51 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -1,11 +1,12 @@ -use soroban_sdk::contracterror; +use soroban_sdk::contracterror; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ReputationError { NotAuthorized = 1, - InvalidInput = 2, - AlreadyRevoked = 3, + ProfileNotFound = 2, + InvalidInput = 3, AttestationNotFound = 4, - ProfileNotFound = 5, -} + 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 index 76a7b5e..e7419c2 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; mod error; @@ -20,10 +20,6 @@ pub struct QuidReputationContract; #[contractimpl] impl QuidReputationContract { - // ------------------------------------------------------------------------- - // Admin bootstrap - // ------------------------------------------------------------------------- - pub fn initialize(env: Env, admin: Address) -> Result<(), ReputationError> { admin.require_auth(); @@ -42,10 +38,6 @@ impl QuidReputationContract { .ok_or(ReputationError::NotAuthorized) } - // ------------------------------------------------------------------------- - // Attestations - // ------------------------------------------------------------------------- - pub fn issue_attestation( env: Env, issuer: Address, @@ -139,10 +131,6 @@ impl QuidReputationContract { .has(&DataKey::Attestation(attestation_id)) } - // ------------------------------------------------------------------------- - // Profiles - // ------------------------------------------------------------------------- - pub fn get_profile(env: Env, subject: Address) -> Result { env.storage() .persistent() @@ -170,6 +158,42 @@ impl QuidReputationContract { 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() @@ -184,10 +208,6 @@ impl QuidReputationContract { } } -// ------------------------------------------------------------------------- -// Internal helpers used by tests -// ------------------------------------------------------------------------- - #[allow(dead_code)] impl QuidReputationContract { pub(crate) fn require_admin(env: &Env, caller: &Address) -> Result<(), ReputationError> { @@ -227,4 +247,4 @@ impl QuidReputationContract { } } -mod test; +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 index d780681..04e379d 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,101 +1,82 @@ -#![cfg(test)] +#![cfg(test)] -use crate::{types::Profile, QuidReputationContract, QuidReputationContractClient}; -use soroban_sdk::{ - testutils::{Address as _, Events}, - Address, Env, String, -}; +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; -fn setup_test_env() -> (Env, Address, Address) { +fn setup_test_env() -> (Env, Address, QuidReputationContractClient<'static>) { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(QuidReputationContract, ()); let admin = Address::generate(&env); - + let contract_id = env.register(QuidReputationContract, ()); let client = QuidReputationContractClient::new(&env, &contract_id); + client.initialize(&admin); - (env, contract_id, admin) + (env, admin, client) } -// ------------------------------------------------------------------------- -// Admin bootstrap tests -// ------------------------------------------------------------------------- - #[test] -fn test_initialize() { +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); - let admin = Address::generate(&env); client.initialize(&admin); - let stored_admin = client.get_admin(); - assert_eq!(stored_admin, admin); + let retrieved_admin = client.get_admin(); + assert_eq!(retrieved_admin, admin); } -// ------------------------------------------------------------------------- -// Attestation tests -// ------------------------------------------------------------------------- - #[test] -fn test_issue_attestation() { - let (env, contract_id, _admin) = setup_test_env(); +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); - let issuer = Address::generate(&env); - let subject = Address::generate(&env); - - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - - assert_eq!(attestation_id, 1); + client.initialize(&admin); + let result = client.try_initialize(&admin); - let attestation = client.get_attestation(&attestation_id); - assert_eq!(attestation.issuer, issuer); - assert_eq!(attestation.subject, subject); - assert_eq!(attestation.attestation_type, attestation_type); - assert_eq!(attestation.data_cid, data_cid); - assert!(!attestation.revoked); + assert!(result.is_err()); } #[test] -fn test_revoke_attestation_by_issuer() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - +fn test_issue_attestation() { + let (env, _admin, client) = setup_test_env(); let issuer = Address::generate(&env); let subject = Address::generate(&env); - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + let attestation_id = client.issue_attestation( + &issuer, + &subject, + &String::from_str(&env, "contributor"), + &String::from_str(&env, "QmExample123"), + ); - client.revoke_attestation(&issuer, &attestation_id); + assert_eq!(attestation_id, 1); let attestation = client.get_attestation(&attestation_id); - assert!(attestation.revoked); + assert_eq!(attestation.issuer, issuer); + assert_eq!(attestation.subject, subject); + assert!(!attestation.revoked); } #[test] -fn test_revoke_attestation_by_admin() { - let (env, contract_id, admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - +fn test_revoke_attestation() { + let (env, admin, client) = setup_test_env(); let issuer = Address::generate(&env); let subject = Address::generate(&env); - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + let attestation_id = client.issue_attestation( + &issuer, + &subject, + &String::from_str(&env, "contributor"), + &String::from_str(&env, "QmExample123"), + ); client.revoke_attestation(&admin, &attestation_id); @@ -104,226 +85,55 @@ fn test_revoke_attestation_by_admin() { } #[test] -#[should_panic(expected = "Error(Contract, #1)")] -fn test_revoke_attestation_unauthorized() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - - let issuer = Address::generate(&env); +fn test_get_profile_not_found() { + let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); - let unauthorized = Address::generate(&env); - - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - - client.revoke_attestation(&unauthorized, &attestation_id); + let result = client.try_get_profile(&subject); + assert!(result.is_err()); } #[test] -#[should_panic(expected = "Error(Contract, #3)")] -fn test_revoke_already_revoked_attestation() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - - let issuer = Address::generate(&env); +fn test_upsert_profile_success() { + let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); + client.upsert_profile(&subject, &85, &10, &2, &5000); - client.revoke_attestation(&issuer, &attestation_id); - client.revoke_attestation(&issuer, &attestation_id); + 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_attestation_count() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - - let issuer = Address::generate(&env); +fn test_upsert_profile_updates_existing() { + let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); - assert_eq!(client.get_attestation_count(), 0); + client.upsert_profile(&subject, &50, &5, &1, &1000); + client.upsert_profile(&subject, &95, &15, &3, &7500); - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - assert_eq!(client.get_attestation_count(), 1); - - client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - assert_eq!(client.get_attestation_count(), 2); + let profile = client.get_profile(&subject); + assert_eq!(profile.score, 95); + assert_eq!(profile.missions_completed, 15); } #[test] -fn test_attestation_exists() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - - let issuer = Address::generate(&env); +fn test_upsert_profile_rejects_negative_earnings() { + let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); - assert!(!client.attestation_exists(&1)); - - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - - assert!(client.attestation_exists(&attestation_id)); + let result = client.try_upsert_profile(&subject, &50, &5, &1, &(-1)); + assert!(result.is_err()); } #[test] -fn test_revoke_attestation_publishes_event() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - - let issuer = Address::generate(&env); +fn test_upsert_profile_emits_event() { + let (env, _admin, client) = setup_test_env(); let subject = Address::generate(&env); - let attestation_type = String::from_str(&env, "skill"); - let data_cid = String::from_str(&env, "QmTest123"); - - let attestation_id = client.issue_attestation(&issuer, &subject, &attestation_type, &data_cid); - - client.revoke_attestation(&issuer, &attestation_id); + client.upsert_profile(&subject, &85, &10, &2, &5000); let events = env.events().all(); - let event = events.last().unwrap(); - - assert_eq!(event.0, contract_id); - - let topics = event.1.clone(); - assert_eq!(topics.len(), 2); -} - -// ------------------------------------------------------------------------- -// Profile tests -// ------------------------------------------------------------------------- - -#[test] -fn test_store_and_get_profile() { - let (env, contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &contract_id); - - let subject = Address::generate(&env); - - env.as_contract(&contract_id, || { - let profile = Profile { - subject: subject.clone(), - score: 42, - missions_completed: 3, - missions_created: 1, - }; - QuidReputationContract::store_profile(&env, &profile); - }); - - let fetched = client.get_profile(&subject); - assert_eq!(fetched.score, 42); - assert_eq!(fetched.missions_completed, 3); - assert_eq!(fetched.missions_created, 1); -} - -#[test] -fn test_load_or_default_returns_zeroed_profile() { - let (env, contract_id, _admin) = setup_test_env(); - - let subject = Address::generate(&env); - - env.as_contract(&contract_id, || { - let profile = QuidReputationContract::load_or_default(&env, subject.clone()); - assert_eq!(profile.subject, subject); - assert_eq!(profile.score, 0); - assert_eq!(profile.missions_completed, 0); - assert_eq!(profile.missions_created, 0); - }); -} - -#[test] -fn test_create_and_get_profile() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); - - let subject = Address::generate(&env); - - let profile = Profile { - subject: subject.clone(), - score: 150, - missions_completed: 5, - missions_created: 2, - }; - - client.set_profile(&profile); - - let retrieved_profile = client.get_profile(&subject); - assert_eq!(retrieved_profile.subject, subject); - assert_eq!(retrieved_profile.score, 150); - assert_eq!(retrieved_profile.missions_completed, 5); - assert_eq!(retrieved_profile.missions_created, 2); -} - -#[test] -fn test_update_profile() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); - - let subject = Address::generate(&env); - - let profile = Profile { - subject: subject.clone(), - score: 100, - missions_completed: 5, - missions_created: 2, - }; - - client.set_profile(&profile); - - let updated_profile = Profile { - subject: subject.clone(), - score: 225, - missions_completed: 10, - missions_created: 3, - }; - - client.set_profile(&updated_profile); - - let retrieved_profile = client.get_profile(&subject); - assert_eq!(retrieved_profile.score, 225); - assert_eq!(retrieved_profile.missions_completed, 10); - assert_eq!(retrieved_profile.missions_created, 3); -} - -#[test] -fn test_profile_exists() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); - - let subject = Address::generate(&env); - - assert!(!client.profile_exists(&subject)); - - let profile = Profile { - subject: subject.clone(), - score: 0, - missions_completed: 0, - missions_created: 0, - }; - - client.set_profile(&profile); - - assert!(client.profile_exists(&subject)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #5)")] -fn test_get_profile_not_found() { - let (env, _contract_id, _admin) = setup_test_env(); - let client = QuidReputationContractClient::new(&env, &_contract_id); - - let subject = Address::generate(&env); - client.get_profile(&subject); -} + assert!(!events.is_empty()); +} \ No newline at end of file From 4ee78e00eafaede6c120b875cdbfa908bead3cdd Mon Sep 17 00:00:00 2001 From: chisomvictorcv-sketch Date: Sat, 6 Jun 2026 07:09:07 -0700 Subject: [PATCH 49/49] fix: remove trailing newlines for cargo fmt compliance --- quid-contract/contracts/quid-reputation/src/error.rs | 2 +- quid-contract/contracts/quid-reputation/src/lib.rs | 2 +- quid-contract/contracts/quid-reputation/src/test.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/quid-contract/contracts/quid-reputation/src/error.rs b/quid-contract/contracts/quid-reputation/src/error.rs index fcfdf51..5d725c6 100644 --- a/quid-contract/contracts/quid-reputation/src/error.rs +++ b/quid-contract/contracts/quid-reputation/src/error.rs @@ -1,4 +1,4 @@ -use soroban_sdk::contracterror; +use soroban_sdk::contracterror; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] diff --git a/quid-contract/contracts/quid-reputation/src/lib.rs b/quid-contract/contracts/quid-reputation/src/lib.rs index e7419c2..3f4b706 100644 --- a/quid-contract/contracts/quid-reputation/src/lib.rs +++ b/quid-contract/contracts/quid-reputation/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; mod error; diff --git a/quid-contract/contracts/quid-reputation/src/test.rs b/quid-contract/contracts/quid-reputation/src/test.rs index 04e379d..87cd6b0 100644 --- a/quid-contract/contracts/quid-reputation/src/test.rs +++ b/quid-contract/contracts/quid-reputation/src/test.rs @@ -1,4 +1,4 @@ -#![cfg(test)] +#![cfg(test)] use super::*; use soroban_sdk::{testutils::Address as _, Address, Env, String};