Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"contracts/parameters-contract",
"contracts/vendor-registry-contract",
"contracts/liquidity-pool-contract",
"contracts/vouching-contract",
]
resolver = "2"

Expand Down
16 changes: 11 additions & 5 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`?

Expand Down
42 changes: 42 additions & 0 deletions contracts/reputation-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions contracts/reputation-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)")]
Expand Down
13 changes: 13 additions & 0 deletions contracts/vouching-contract/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
16 changes: 16 additions & 0 deletions contracts/vouching-contract/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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,
}
20 changes: 20 additions & 0 deletions contracts/vouching-contract/src/events.rs
Original file line number Diff line number Diff line change
@@ -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);
}
180 changes: 180 additions & 0 deletions contracts/vouching-contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<VouchRecord> {
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<Address, VouchingError> {
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<Val> = (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;
Loading
Loading