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