diff --git a/Cargo.toml b/Cargo.toml index 42f54af..89c91b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "contracts/parameters-contract", "contracts/vendor-registry-contract", "contracts/liquidity-pool-contract", + "contracts/vouching-contract", ] resolver = "2" diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 3d750d4..1bdfc12 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -67,6 +67,14 @@ Update this file after every completed contract change, fix, or architectural de - Added before-initialize regression coverage across all 5 active contracts using generated `try_*` clients - Verified with `cargo check --offline`, `cargo build --offline`, `cargo test --offline`, and `cargo clippy --offline -- -D warnings` — 230 passed, 0 failed, 4 ignored +### Issue #4 — Mentor Vouching Contract +- Added `vouching-contract` workspace member with `vouch`, `revoke_vouch`, `get_vouches`, `set_mentor`, and initialization APIs +- Stored verified mentors and mentor/learner vouch records in persistent storage with TTL extension after every persistent write +- Added learner-to-mentor indexing so `get_vouches(learner)` avoids global scans +- Added `MENTORVOUCHED`, `VOUCHREVOKED`, and `MENTORVERIFIED` event helpers using short Soroban event symbols +- Added reputation `add_boost` and `remove_boost` updater-gated APIs for vouching cross-contract calls +- Added mock reputation cross-contract tests covering mentor verification, vouching, revocation, duplicate rejection, unverified mentor rejection, admin rejection, and event emission + --- ## In Progress @@ -78,17 +86,15 @@ Update this file after every completed contract change, fix, or architectural de ## Next Up (In Order) 1. **Learner grace period** — Make `grace_period_seconds` per-loan (not just global via parameters) -2. **Vouching contract** — New `vouching-contract` crate: `vouch()`, `revoke_vouch()`, `get_vouches()`, `get_vouch_count()` -3. **Reputation rules** — Update `creditline-contract` to call different reputation adjustments for `LoanType::LearnerInstallment` -4. **Testnet deployment** — Deploy all contracts, capture IDs, add to StepFi-API `.env` -5. **End-to-end validation** — Verify loan lifecycle on testnet via Stellar CLI +2. **Reputation rules** — Update `creditline-contract` to call different reputation adjustments for `LoanType::LearnerInstallment` +3. **Testnet deployment** — Deploy all contracts, capture IDs, add to StepFi-API `.env` +4. **End-to-end validation** — Verify loan lifecycle on testnet via Stellar CLI --- ## Open Questions - What token is used for loans — native XLM or a USDC anchor? (Affects token contract address in `initialize()`) -- Should the vouching contract be a standalone crate or logic added to `creditline-contract`? (Leaning toward standalone for modularity) - What is the correct `grace_period_seconds` for learner installment loans? (Longer than standard BNPL — possibly 7-14 days per installment) - Should sponsor pool deposits go through `liquidity-pool-contract` or a new `sponsor-pool-contract`? diff --git a/contracts/reputation-contract/src/lib.rs b/contracts/reputation-contract/src/lib.rs index be77c33..00575c1 100644 --- a/contracts/reputation-contract/src/lib.rs +++ b/contracts/reputation-contract/src/lib.rs @@ -89,6 +89,48 @@ impl ReputationContract { events::emit_score_changed(&env, &user, old_score, new_score, &reason); } + /// Add a mentor vouching boost to a user's reputation score. + /// Requires authorization from an updater. + pub fn add_boost(env: Env, updater: Address, user: Address, amount: u32) { + updater.require_auth(); + access::require_updater(&env, &updater); + + let old_score = storage::read_score(&env, &user) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)); + let new_score = old_score + .checked_add(amount) + .ok_or(ReputationError::Overflow) + .unwrap(); + + if new_score > types::MAX_SCORE { + soroban_sdk::panic_with_error!(&env, ReputationError::Overflow); + } + + storage::write_score(&env, &user, new_score); + + let reason = symbol_short!("boost"); + events::emit_score_changed(&env, &user, old_score, new_score, &reason); + } + + /// Remove a mentor vouching boost from a user's reputation score. + /// Requires authorization from an updater. + pub fn remove_boost(env: Env, updater: Address, user: Address, amount: u32) { + updater.require_auth(); + access::require_updater(&env, &updater); + + let old_score = storage::read_score(&env, &user) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)); + let new_score = match old_score.checked_sub(amount) { + Some(score) => score, + None => soroban_sdk::panic_with_error!(&env, ReputationError::Underflow), + }; + + storage::write_score(&env, &user, new_score); + + let reason = symbol_short!("unboost"); + events::emit_score_changed(&env, &user, old_score, new_score, &reason); + } + /// Set or remove an address as an authorized updater /// Requires authorization from admin pub fn set_updater(env: Env, admin: Address, updater: Address, allowed: bool) { diff --git a/contracts/reputation-contract/src/tests.rs b/contracts/reputation-contract/src/tests.rs index 11d881b..cc8b5ad 100644 --- a/contracts/reputation-contract/src/tests.rs +++ b/contracts/reputation-contract/src/tests.rs @@ -184,6 +184,50 @@ fn it_sets_score() { assert_eq!(client.get_score(&user), 25); } +#[test] +fn it_adds_boost() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_admin(&admin); + + let updater = Address::generate(&env); + client.set_updater(&admin, &updater, &true); + + let user = Address::generate(&env); + + client.set_score(&updater, &user, &40); + client.add_boost(&updater, &user, &10); + + assert_eq!(client.get_score(&user), 50); +} + +#[test] +fn it_removes_boost() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_admin(&admin); + + let updater = Address::generate(&env); + client.set_updater(&admin, &updater, &true); + + let user = Address::generate(&env); + + client.set_score(&updater, &user, &40); + client.remove_boost(&updater, &user, &10); + + assert_eq!(client.get_score(&user), 30); +} + /// Test: Prevents unauthorized updates #[test] #[should_panic(expected = "Error(Contract, #2)")] diff --git a/contracts/vouching-contract/Cargo.toml b/contracts/vouching-contract/Cargo.toml new file mode 100644 index 0000000..1d82e3e --- /dev/null +++ b/contracts/vouching-contract/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "vouching-contract" +version = "1.0.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/vouching-contract/src/errors.rs b/contracts/vouching-contract/src/errors.rs new file mode 100644 index 0000000..5d555e2 --- /dev/null +++ b/contracts/vouching-contract/src/errors.rs @@ -0,0 +1,16 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum VouchingError { + NotInitialized = 1, + AlreadyInitialized = 2, + NotAdmin = 3, + MentorNotVerified = 4, + VouchAlreadyActive = 5, + VouchNotFound = 6, + VouchNotActive = 7, + InvalidBoost = 8, + ReputationCallFailed = 9, +} diff --git a/contracts/vouching-contract/src/events.rs b/contracts/vouching-contract/src/events.rs new file mode 100644 index 0000000..283d014 --- /dev/null +++ b/contracts/vouching-contract/src/events.rs @@ -0,0 +1,20 @@ +use soroban_sdk::{Address, Env, Symbol}; + +pub fn emit_mentor_vouched(env: &Env, mentor: &Address, learner: &Address, boost_amount: u32) { + env.events().publish( + (Symbol::new(env, "MENTORVOUCHED"), mentor, learner), + boost_amount, + ); +} + +pub fn emit_vouch_revoked(env: &Env, mentor: &Address, learner: &Address, boost_amount: u32) { + env.events().publish( + (Symbol::new(env, "VOUCHREVOKED"), mentor, learner), + boost_amount, + ); +} + +pub fn emit_mentor_verified(env: &Env, mentor: &Address, verified: bool) { + env.events() + .publish((Symbol::new(env, "MENTORVERIFIED"), mentor), verified); +} diff --git a/contracts/vouching-contract/src/lib.rs b/contracts/vouching-contract/src/lib.rs new file mode 100644 index 0000000..df9a23c --- /dev/null +++ b/contracts/vouching-contract/src/lib.rs @@ -0,0 +1,180 @@ +#![no_std] + +use soroban_sdk::{ + auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, + contract, contractimpl, panic_with_error, symbol_short, Address, Env, IntoVal, Symbol, Val, + Vec, +}; + +mod errors; +mod events; +mod storage; +mod types; + +pub use errors::VouchingError; +pub use types::{VouchRecord, DEFAULT_VOUCH_BOOST}; + +#[contract] +pub struct VouchingContract; + +#[contractimpl] +impl VouchingContract { + pub fn initialize(env: Env, admin: Address, reputation_contract: Address, vouch_boost: u32) { + admin.require_auth(); + + if storage::has_admin(&env) { + panic_with_error!(&env, VouchingError::AlreadyInitialized); + } + if vouch_boost == 0 { + panic_with_error!(&env, VouchingError::InvalidBoost); + } + + storage::set_admin(&env, &admin); + storage::set_reputation_contract(&env, &reputation_contract); + storage::set_vouch_boost(&env, vouch_boost); + } + + pub fn vouch(env: Env, mentor: Address, learner: Address) { + mentor.require_auth(); + + if !storage::is_mentor(&env, &mentor) { + panic_with_error!(&env, VouchingError::MentorNotVerified); + } + + if let Ok(existing) = storage::get_vouch(&env, &mentor, &learner) { + storage::extend_vouch_ttl(&env, &mentor, &learner); + if existing.active { + panic_with_error!(&env, VouchingError::VouchAlreadyActive); + } + } + + let boost_amount = + storage::get_vouch_boost(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); + let record = VouchRecord { + mentor: mentor.clone(), + learner: learner.clone(), + ts: env.ledger().timestamp(), + boost_amount, + active: true, + }; + + storage::set_vouch(&env, &record); + storage::add_learner_mentor(&env, &learner, &mentor); + Self::add_reputation_boost(&env, &learner, boost_amount); + events::emit_mentor_vouched(&env, &mentor, &learner, boost_amount); + } + + pub fn revoke_vouch(env: Env, mentor: Address, learner: Address) { + mentor.require_auth(); + + let mut record = storage::get_vouch(&env, &mentor, &learner) + .unwrap_or_else(|err| panic_with_error!(&env, err)); + if !record.active { + panic_with_error!(&env, VouchingError::VouchNotActive); + } + + Self::remove_reputation_boost(&env, &learner, record.boost_amount); + record.active = false; + storage::set_vouch(&env, &record); + events::emit_vouch_revoked(&env, &mentor, &learner, record.boost_amount); + } + + pub fn get_vouches(env: Env, learner: Address) -> Vec { + let mentors = storage::get_learner_mentors(&env, &learner); + let mut records = Vec::new(&env); + + for mentor in mentors { + if let Ok(record) = storage::get_vouch(&env, &mentor, &learner) { + records.push_back(record); + } + } + + records + } + + pub fn set_mentor(env: Env, admin: Address, mentor: Address, verified: bool) { + admin.require_auth(); + Self::require_admin(&env, &admin); + + storage::set_mentor(&env, &mentor, verified); + events::emit_mentor_verified(&env, &mentor, verified); + } + + pub fn get_admin(env: Env) -> Result { + storage::get_admin(&env) + } + + pub fn is_mentor(env: Env, mentor: Address) -> bool { + storage::is_mentor(&env, &mentor) + } + + fn add_reputation_boost(env: &Env, learner: &Address, boost_amount: u32) { + let reputation_contract = + storage::get_reputation_contract(env).unwrap_or_else(|err| panic_with_error!(env, err)); + Self::authorize_reputation_call( + env, + &reputation_contract, + symbol_short!("add_boost"), + learner, + boost_amount, + ); + env.try_invoke_contract::<(), soroban_sdk::Error>( + &reputation_contract, + &symbol_short!("add_boost"), + (env.current_contract_address(), learner, boost_amount).into_val(env), + ) + .unwrap_or_else(|_| panic_with_error!(env, VouchingError::ReputationCallFailed)) + .unwrap_or_else(|_| panic_with_error!(env, VouchingError::ReputationCallFailed)); + } + + fn remove_reputation_boost(env: &Env, learner: &Address, boost_amount: u32) { + let reputation_contract = + storage::get_reputation_contract(env).unwrap_or_else(|err| panic_with_error!(env, err)); + let function = Symbol::new(env, "remove_boost"); + Self::authorize_reputation_call( + env, + &reputation_contract, + function.clone(), + learner, + boost_amount, + ); + env.try_invoke_contract::<(), soroban_sdk::Error>( + &reputation_contract, + &function, + (env.current_contract_address(), learner, boost_amount).into_val(env), + ) + .unwrap_or_else(|_| panic_with_error!(env, VouchingError::ReputationCallFailed)) + .unwrap_or_else(|_| panic_with_error!(env, VouchingError::ReputationCallFailed)); + } + + fn authorize_reputation_call( + env: &Env, + reputation_contract: &Address, + function: Symbol, + learner: &Address, + boost_amount: u32, + ) { + let args: Vec = (env.current_contract_address(), learner, boost_amount).into_val(env); + let invocation = SubContractInvocation { + context: ContractContext { + contract: reputation_contract.clone(), + fn_name: function, + args, + }, + sub_invocations: Vec::new(env), + }; + let mut auth_entries = Vec::new(env); + auth_entries.push_back(InvokerContractAuthEntry::Contract(invocation)); + env.authorize_as_current_contract(auth_entries); + } + + fn require_admin(env: &Env, caller: &Address) { + let admin = storage::get_admin(env).unwrap_or_else(|err| panic_with_error!(env, err)); + if admin != *caller { + panic_with_error!(env, VouchingError::NotAdmin); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/contracts/vouching-contract/src/storage.rs b/contracts/vouching-contract/src/storage.rs new file mode 100644 index 0000000..5b41fad --- /dev/null +++ b/contracts/vouching-contract/src/storage.rs @@ -0,0 +1,107 @@ +use soroban_sdk::{Address, Env, Vec}; + +use crate::{ + errors::VouchingError, + types::{DataKey, VouchRecord}, +}; + +pub const PERSISTENT_TTL_THRESHOLD: u32 = 1_036_800; +pub const PERSISTENT_TTL_EXTEND_TO: u32 = 2_073_600; + +pub fn has_admin(env: &Env) -> bool { + env.storage().instance().has(&DataKey::Admin) +} + +pub fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(VouchingError::NotInitialized) +} + +pub fn set_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&DataKey::Admin, admin); +} + +pub fn get_reputation_contract(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::ReputationContract) + .ok_or(VouchingError::NotInitialized) +} + +pub fn set_reputation_contract(env: &Env, reputation_contract: &Address) { + env.storage() + .instance() + .set(&DataKey::ReputationContract, reputation_contract); +} + +pub fn get_vouch_boost(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::VouchBoost) + .ok_or(VouchingError::NotInitialized) +} + +pub fn set_vouch_boost(env: &Env, boost_amount: u32) { + env.storage() + .instance() + .set(&DataKey::VouchBoost, &boost_amount); +} + +pub fn is_mentor(env: &Env, mentor: &Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::Mentor(mentor.clone())) + .unwrap_or(false) +} + +pub fn set_mentor(env: &Env, mentor: &Address, verified: bool) { + let key = DataKey::Mentor(mentor.clone()); + env.storage().persistent().set(&key, &verified); + extend_persistent_ttl(env, &key); +} + +pub fn get_vouch( + env: &Env, + mentor: &Address, + learner: &Address, +) -> Result { + env.storage() + .persistent() + .get(&DataKey::Vouch(mentor.clone(), learner.clone())) + .ok_or(VouchingError::VouchNotFound) +} + +pub fn set_vouch(env: &Env, record: &VouchRecord) { + let key = DataKey::Vouch(record.mentor.clone(), record.learner.clone()); + env.storage().persistent().set(&key, record); + extend_persistent_ttl(env, &key); +} + +pub fn get_learner_mentors(env: &Env, learner: &Address) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::LearnerVouches(learner.clone())) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn add_learner_mentor(env: &Env, learner: &Address, mentor: &Address) { + let mut mentors = get_learner_mentors(env, learner); + if !mentors.contains(mentor) { + mentors.push_back(mentor.clone()); + let key = DataKey::LearnerVouches(learner.clone()); + env.storage().persistent().set(&key, &mentors); + extend_persistent_ttl(env, &key); + } +} + +pub fn extend_vouch_ttl(env: &Env, mentor: &Address, learner: &Address) { + extend_persistent_ttl(env, &DataKey::Vouch(mentor.clone(), learner.clone())); +} + +fn extend_persistent_ttl(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); +} diff --git a/contracts/vouching-contract/src/tests.rs b/contracts/vouching-contract/src/tests.rs new file mode 100644 index 0000000..acd224b --- /dev/null +++ b/contracts/vouching-contract/src/tests.rs @@ -0,0 +1,213 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{Address as _, Events}, + Address, Env, IntoVal, Symbol, Val, Vec, +}; + +use crate::{VouchingContract, VouchingContractClient, DEFAULT_VOUCH_BOOST}; + +#[contract] +pub struct MockReputationContract; + +#[contractimpl] +impl MockReputationContract { + pub fn add_boost(env: Env, updater: Address, learner: Address, amount: u32) { + updater.require_auth(); + let score = Self::get_score(env.clone(), learner.clone()); + let next = score.checked_add(amount).unwrap_or(100).min(100); + env.storage() + .instance() + .set(&(symbol_short!("SCORE"), learner), &next); + } + + pub fn remove_boost(env: Env, updater: Address, learner: Address, amount: u32) { + updater.require_auth(); + let score = Self::get_score(env.clone(), learner.clone()); + let next = score.saturating_sub(amount); + env.storage() + .instance() + .set(&(symbol_short!("SCORE"), learner), &next); + } + + pub fn get_score(env: Env, learner: Address) -> u32 { + env.storage() + .instance() + .get(&(symbol_short!("SCORE"), learner)) + .unwrap_or(0) + } +} + +struct TestCtx { + env: Env, + client: VouchingContractClient<'static>, + reputation: Address, + admin: Address, + mentor: Address, + learner: Address, +} + +impl TestCtx { + fn setup() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let reputation = env.register(MockReputationContract, ()); + let contract_id = env.register(VouchingContract, ()); + let client = VouchingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let mentor = Address::generate(&env); + let learner = Address::generate(&env); + + client.initialize(&admin, &reputation, &DEFAULT_VOUCH_BOOST); + + Self { + env, + client, + reputation, + admin, + mentor, + learner, + } + } + + fn reputation_score(&self) -> u32 { + let reputation_client = MockReputationContractClient::new(&self.env, &self.reputation); + reputation_client.get_score(&self.learner) + } +} + +#[test] +fn test_initialize_sets_admin() { + let ctx = TestCtx::setup(); + + assert_eq!(ctx.client.get_admin(), ctx.admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #8)")] +fn test_initialize_rejects_zero_boost() { + let env = Env::default(); + env.mock_all_auths(); + + let reputation = env.register(MockReputationContract, ()); + let contract_id = env.register(VouchingContract, ()); + let client = VouchingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + client.initialize(&admin, &reputation, &0); +} + +#[test] +fn test_set_mentor_verifies_mentor_and_emits_event() { + let ctx = TestCtx::setup(); + + ctx.client.set_mentor(&ctx.admin, &ctx.mentor, &true); + assert_event(&ctx.env, Symbol::new(&ctx.env, "MENTORVERIFIED")); + + assert!(ctx.client.is_mentor(&ctx.mentor)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_set_mentor_requires_admin() { + let ctx = TestCtx::setup(); + let not_admin = Address::generate(&ctx.env); + + ctx.client.set_mentor(¬_admin, &ctx.mentor, &true); +} + +#[test] +fn test_vouch_writes_record_and_adds_reputation_boost() { + let ctx = TestCtx::setup(); + ctx.client.set_mentor(&ctx.admin, &ctx.mentor, &true); + + ctx.client.vouch(&ctx.mentor, &ctx.learner); + + let vouches = ctx.client.get_vouches(&ctx.learner); + assert_eq!(vouches.len(), 1); + let record = vouches.get_unchecked(0); + assert_eq!(record.mentor, ctx.mentor); + assert_eq!(record.learner, ctx.learner); + assert_eq!(record.boost_amount, DEFAULT_VOUCH_BOOST); + assert!(record.active); + assert_eq!(ctx.reputation_score(), DEFAULT_VOUCH_BOOST); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_vouch_rejects_unverified_mentor() { + let ctx = TestCtx::setup(); + + ctx.client.vouch(&ctx.mentor, &ctx.learner); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_vouch_rejects_duplicate_active_vouch() { + let ctx = TestCtx::setup(); + ctx.client.set_mentor(&ctx.admin, &ctx.mentor, &true); + ctx.client.vouch(&ctx.mentor, &ctx.learner); + + ctx.client.vouch(&ctx.mentor, &ctx.learner); +} + +#[test] +fn test_revoke_vouch_marks_inactive_and_removes_reputation_boost() { + let ctx = TestCtx::setup(); + ctx.client.set_mentor(&ctx.admin, &ctx.mentor, &true); + ctx.client.vouch(&ctx.mentor, &ctx.learner); + + ctx.client.revoke_vouch(&ctx.mentor, &ctx.learner); + + let record = ctx.client.get_vouches(&ctx.learner).get_unchecked(0); + assert!(!record.active); + assert_eq!(ctx.reputation_score(), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_revoke_vouch_rejects_missing_record() { + let ctx = TestCtx::setup(); + + ctx.client.revoke_vouch(&ctx.mentor, &ctx.learner); +} + +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_revoke_vouch_rejects_already_inactive_record() { + let ctx = TestCtx::setup(); + ctx.client.set_mentor(&ctx.admin, &ctx.mentor, &true); + ctx.client.vouch(&ctx.mentor, &ctx.learner); + ctx.client.revoke_vouch(&ctx.mentor, &ctx.learner); + + ctx.client.revoke_vouch(&ctx.mentor, &ctx.learner); +} + +#[test] +fn test_events_emitted_for_vouch_and_revoke() { + let ctx = TestCtx::setup(); + ctx.client.set_mentor(&ctx.admin, &ctx.mentor, &true); + ctx.client.vouch(&ctx.mentor, &ctx.learner); + assert_event(&ctx.env, Symbol::new(&ctx.env, "MENTORVOUCHED")); + ctx.client.revoke_vouch(&ctx.mentor, &ctx.learner); + assert_event(&ctx.env, Symbol::new(&ctx.env, "VOUCHREVOKED")); + + let record = ctx.client.get_vouches(&ctx.learner).get_unchecked(0); + assert!(!record.active); + assert_eq!(ctx.reputation_score(), 0); +} + +fn assert_event(env: &Env, expected: Symbol) { + let events: Vec<(Address, Vec, Val)> = env.events().all(); + for event in events.iter() { + let topics = event.1.clone(); + let topic: Symbol = topics.get_unchecked(0).into_val(env); + if topic == expected { + return; + } + } + + panic!("expected event was not emitted"); +} diff --git a/contracts/vouching-contract/src/types.rs b/contracts/vouching-contract/src/types.rs new file mode 100644 index 0000000..7eda96e --- /dev/null +++ b/contracts/vouching-contract/src/types.rs @@ -0,0 +1,24 @@ +use soroban_sdk::{contracttype, Address}; + +pub const DEFAULT_VOUCH_BOOST: u32 = 10; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VouchRecord { + pub mentor: Address, + pub learner: Address, + pub ts: u64, + pub boost_amount: u32, + pub active: bool, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + ReputationContract, + VouchBoost, + Mentor(Address), + Vouch(Address, Address), + LearnerVouches(Address), +}