From 9c1f842f6a7e717bb549b7fe46527eeabeddcfc7 Mon Sep 17 00:00:00 2001 From: olivia-brien Date: Sat, 17 Oct 2026 16:20:03 +0000 Subject: [PATCH 1/4] feat(#884): implement on-chain creator mentorship program Add a standalone mentorship contract where creators register as mentors (expertise, revenue share, mentee capacity) and mentees request time-boxed programs. Mentors accept/decline, log sessions, and receive a locked-in share of mentee earnings during the term. Programs can be completed or cancelled, completed programs can be rated, and admins can suspend mentors. All transitions emit events under the `mentor` topic. Closes #884 Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- contracts/mentorship/Cargo.toml | 16 + contracts/mentorship/src/errors.rs | 53 +++ contracts/mentorship/src/lib.rs | 566 +++++++++++++++++++++++++++++ contracts/mentorship/src/test.rs | 526 +++++++++++++++++++++++++++ contracts/mentorship/src/types.rs | 63 ++++ 6 files changed, 1225 insertions(+), 1 deletion(-) create mode 100644 contracts/mentorship/Cargo.toml create mode 100644 contracts/mentorship/src/errors.rs create mode 100644 contracts/mentorship/src/lib.rs create mode 100644 contracts/mentorship/src/test.rs create mode 100644 contracts/mentorship/src/types.rs diff --git a/Cargo.toml b/Cargo.toml index 1581ae0..e15b464 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry"] +members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry", "contracts/mentorship"] resolver = "2" [workspace.dependencies] diff --git a/contracts/mentorship/Cargo.toml b/contracts/mentorship/Cargo.toml new file mode 100644 index 0000000..fd56343 --- /dev/null +++ b/contracts/mentorship/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mentorship" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/mentorship/src/errors.rs b/contracts/mentorship/src/errors.rs new file mode 100644 index 0000000..dffc002 --- /dev/null +++ b/contracts/mentorship/src/errors.rs @@ -0,0 +1,53 @@ +//! Error variants for the mentorship contract. +//! +//! Discriminants are stable — never reorder, only append. + +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// Contract has already been initialized. + AlreadyInitialized = 1, + /// Contract has not been initialized yet. + NotInitialized = 2, + /// Caller is not the contract admin. + NotAdmin = 3, + /// The address is already registered as a mentor. + MentorAlreadyRegistered = 4, + /// No mentor profile exists for the address. + MentorNotFound = 5, + /// The mentor is inactive or suspended and cannot take new mentees. + MentorUnavailable = 6, + /// Revenue share exceeds `MAX_REVENUE_SHARE_BPS`. + InvalidRevenueShare = 7, + /// Mentee capacity must be between 1 and `MAX_MENTEES_PER_MENTOR`. + InvalidCapacity = 8, + /// The mentor has no free mentee slots. + MentorAtCapacity = 9, + /// A creator cannot mentor themselves. + SelfMentorship = 10, + /// An open (requested or active) program already exists for this pair. + DuplicateProgram = 11, + /// Program ID was not found in storage. + ProgramNotFound = 12, + /// The program is in a state that does not allow this operation. + InvalidProgramState = 13, + /// Caller is not a participant allowed to perform this operation. + Unauthorized = 14, + /// Duration must be between `MIN_DURATION_LEDGERS` and `MAX_DURATION_LEDGERS`. + InvalidDuration = 15, + /// Amount must be strictly positive. + InvalidAmount = 16, + /// The program term has ended; earnings can no longer be shared. + ProgramExpired = 17, + /// The mentor tried to complete the program before its term ended. + ProgramNotMatured = 18, + /// Rating must be between 1 and 5. + InvalidRating = 19, + /// The mentee has already rated this program. + AlreadyRated = 20, + /// Arithmetic overflow. + Overflow = 21, +} diff --git a/contracts/mentorship/src/lib.rs b/contracts/mentorship/src/lib.rs new file mode 100644 index 0000000..82e0e33 --- /dev/null +++ b/contracts/mentorship/src/lib.rs @@ -0,0 +1,566 @@ +//! StellarSplit Mentorship — on-chain creator mentorship program. +//! +//! Experienced creators register as mentors with an area of expertise, a +//! mentee capacity and a revenue share. Newer creators request mentorship; +//! once a mentor accepts, the program runs for a fixed number of ledgers. +//! During the term the mentee can route a share of their earnings to the +//! mentor, the mentor logs sessions, and on completion the mentee can rate +//! the mentor. Every state transition emits an event under the `mentor` +//! topic so indexers can follow the program lifecycle. + +#![no_std] + +mod errors; +mod types; + +pub use errors::Error; +pub use types::{MentorProfile, MentorshipProgram, ProgramStatus}; + +use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BPS_DENOMINATOR: i128 = 10_000; +/// Mentors may take at most 30% of a mentee's earnings. +pub const MAX_REVENUE_SHARE_BPS: u32 = 3_000; +pub const MAX_MENTEES_PER_MENTOR: u32 = 50; +/// Roughly one day at 5s ledgers. +pub const MIN_DURATION_LEDGERS: u32 = 17_280; +/// Roughly one year at 5s ledgers. +pub const MAX_DURATION_LEDGERS: u32 = 6_307_200; +const MAX_RATING: u32 = 5; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn admin_key() -> Symbol { + symbol_short!("admin") +} + +fn counter_key() -> Symbol { + symbol_short!("prog_ctr") +} + +fn mentor_key(mentor: &Address) -> (Symbol, Address) { + (symbol_short!("mentor"), mentor.clone()) +} + +fn program_key(id: u64) -> (Symbol, u64) { + (symbol_short!("program"), id) +} + +fn mentor_programs_key(mentor: &Address) -> (Symbol, Address) { + (symbol_short!("mtr_prgs"), mentor.clone()) +} + +fn mentee_programs_key(mentee: &Address) -> (Symbol, Address) { + (symbol_short!("mte_prgs"), mentee.clone()) +} + +/// Tracks the open (requested/active) program for a mentor/mentee pair. +fn open_pair_key(mentor: &Address, mentee: &Address) -> (Symbol, Address, Address) { + (symbol_short!("open_pair"), mentor.clone(), mentee.clone()) +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +fn emit_mentor_registered(env: &Env, mentor: &Address, expertise: &Symbol, share_bps: u32, max_mentees: u32) { + env.events().publish( + (symbol_short!("mentor"), symbol_short!("reg"), mentor.clone()), + (expertise.clone(), share_bps, max_mentees), + ); +} + +fn emit_mentor_updated(env: &Env, mentor: &Address, share_bps: u32, max_mentees: u32, available: bool) { + env.events().publish( + (symbol_short!("mentor"), symbol_short!("updated"), mentor.clone()), + (share_bps, max_mentees, available), + ); +} + +fn emit_mentor_suspended(env: &Env, mentor: &Address, suspended: bool) { + env.events().publish( + (symbol_short!("mentor"), symbol_short!("suspend"), mentor.clone()), + suspended, + ); +} + +fn emit_program_status(env: &Env, action: Symbol, program: &MentorshipProgram) { + env.events().publish( + (symbol_short!("mentor"), action, program.id), + (program.mentor.clone(), program.mentee.clone(), program.status), + ); +} + +fn emit_session_logged(env: &Env, id: u64, sessions: u32) { + env.events().publish( + (symbol_short!("mentor"), symbol_short!("session"), id), + sessions, + ); +} + +fn emit_earnings_shared(env: &Env, id: u64, token: &Address, gross: i128, share: i128) { + env.events().publish( + (symbol_short!("mentor"), symbol_short!("shared"), id), + (token.clone(), gross, share), + ); +} + +fn emit_mentor_rated(env: &Env, id: u64, mentor: &Address, rating: u32) { + env.events().publish( + (symbol_short!("mentor"), symbol_short!("rated"), id), + (mentor.clone(), rating), + ); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> { + let stored: Address = env + .storage() + .instance() + .get(&admin_key()) + .ok_or(Error::NotInitialized)?; + if stored != *admin { + return Err(Error::NotAdmin); + } + admin.require_auth(); + Ok(()) +} + +fn load_mentor(env: &Env, mentor: &Address) -> Result { + env.storage() + .persistent() + .get(&mentor_key(mentor)) + .ok_or(Error::MentorNotFound) +} + +fn save_mentor(env: &Env, profile: &MentorProfile) { + env.storage() + .persistent() + .set(&mentor_key(&profile.mentor), profile); +} + +fn load_program(env: &Env, id: u64) -> Result { + env.storage() + .persistent() + .get(&program_key(id)) + .ok_or(Error::ProgramNotFound) +} + +fn save_program(env: &Env, program: &MentorshipProgram) { + env.storage() + .persistent() + .set(&program_key(program.id), program); +} + +fn push_id(env: &Env, key: &(Symbol, Address), id: u64) { + let mut ids: Vec = env + .storage() + .persistent() + .get(key) + .unwrap_or_else(|| Vec::new(env)); + ids.push_back(id); + env.storage().persistent().set(key, &ids); +} + +fn validate_terms(share_bps: u32, max_mentees: u32) -> Result<(), Error> { + if share_bps > MAX_REVENUE_SHARE_BPS { + return Err(Error::InvalidRevenueShare); + } + if max_mentees == 0 || max_mentees > MAX_MENTEES_PER_MENTOR { + return Err(Error::InvalidCapacity); + } + Ok(()) +} + +/// Closes an active program: frees the mentor slot and clears the pair lock. +fn release_slot(env: &Env, program: &MentorshipProgram, was_active: bool) -> Result<(), Error> { + if was_active { + let mut profile = load_mentor(env, &program.mentor)?; + profile.active_mentees = profile.active_mentees.saturating_sub(1); + save_mentor(env, &profile); + } + env.storage() + .persistent() + .remove(&open_pair_key(&program.mentor, &program.mentee)); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct MentorshipContract; + +#[contractimpl] +impl MentorshipContract { + /// Initialise the contract with an admin who can suspend mentors. + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&admin_key()) { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&admin_key(), &admin); + env.storage().instance().set(&counter_key(), &0u64); + Ok(()) + } + + /// Register the caller as a mentor. + /// + /// * `revenue_share_bps` — share of mentee earnings (≤ `MAX_REVENUE_SHARE_BPS`). + /// * `max_mentees` — concurrent mentee capacity (1..=`MAX_MENTEES_PER_MENTOR`). + pub fn register_mentor( + env: Env, + mentor: Address, + expertise: Symbol, + revenue_share_bps: u32, + max_mentees: u32, + ) -> Result<(), Error> { + mentor.require_auth(); + if !env.storage().instance().has(&admin_key()) { + return Err(Error::NotInitialized); + } + if env.storage().persistent().has(&mentor_key(&mentor)) { + return Err(Error::MentorAlreadyRegistered); + } + validate_terms(revenue_share_bps, max_mentees)?; + + let profile = MentorProfile { + mentor: mentor.clone(), + expertise: expertise.clone(), + revenue_share_bps, + max_mentees, + active_mentees: 0, + completed_programs: 0, + rating_sum: 0, + rating_count: 0, + available: true, + suspended: false, + registered_at: env.ledger().timestamp(), + }; + save_mentor(&env, &profile); + emit_mentor_registered(&env, &mentor, &expertise, revenue_share_bps, max_mentees); + Ok(()) + } + + /// Update a mentor's terms and availability. Terms of programs that are + /// already active are not affected. + pub fn update_mentor( + env: Env, + mentor: Address, + revenue_share_bps: u32, + max_mentees: u32, + available: bool, + ) -> Result<(), Error> { + mentor.require_auth(); + let mut profile = load_mentor(&env, &mentor)?; + validate_terms(revenue_share_bps, max_mentees)?; + if max_mentees < profile.active_mentees { + return Err(Error::InvalidCapacity); + } + profile.revenue_share_bps = revenue_share_bps; + profile.max_mentees = max_mentees; + profile.available = available; + save_mentor(&env, &profile); + emit_mentor_updated(&env, &mentor, revenue_share_bps, max_mentees, available); + Ok(()) + } + + /// Admin-only: suspend or reinstate a mentor. Suspended mentors cannot + /// accept new requests; existing programs continue. + pub fn set_mentor_suspended( + env: Env, + admin: Address, + mentor: Address, + suspended: bool, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + let mut profile = load_mentor(&env, &mentor)?; + profile.suspended = suspended; + save_mentor(&env, &profile); + emit_mentor_suspended(&env, &mentor, suspended); + Ok(()) + } + + /// Mentee requests a mentorship program of `duration_ledgers` with `mentor`. + /// Returns the new program ID. + pub fn request_mentorship( + env: Env, + mentee: Address, + mentor: Address, + duration_ledgers: u32, + ) -> Result { + mentee.require_auth(); + if mentee == mentor { + return Err(Error::SelfMentorship); + } + if !(MIN_DURATION_LEDGERS..=MAX_DURATION_LEDGERS).contains(&duration_ledgers) { + return Err(Error::InvalidDuration); + } + let profile = load_mentor(&env, &mentor)?; + if !profile.available || profile.suspended { + return Err(Error::MentorUnavailable); + } + let pair_key = open_pair_key(&mentor, &mentee); + if env.storage().persistent().has(&pair_key) { + return Err(Error::DuplicateProgram); + } + + let id: u64 = env + .storage() + .instance() + .get(&counter_key()) + .ok_or(Error::NotInitialized)?; + let next = id.checked_add(1).ok_or(Error::Overflow)?; + env.storage().instance().set(&counter_key(), &next); + + let program = MentorshipProgram { + id, + mentor: mentor.clone(), + mentee: mentee.clone(), + status: ProgramStatus::Requested, + revenue_share_bps: profile.revenue_share_bps, + duration_ledgers, + requested_at: env.ledger().timestamp(), + start_ledger: 0, + end_ledger: 0, + sessions: 0, + total_shared: 0, + rated: false, + }; + save_program(&env, &program); + env.storage().persistent().set(&pair_key, &id); + push_id(&env, &mentor_programs_key(&mentor), id); + push_id(&env, &mentee_programs_key(&mentee), id); + + emit_program_status(&env, symbol_short!("requested"), &program); + Ok(id) + } + + /// Mentor accepts a pending request. Locks in the mentor's current + /// revenue share and starts the program term. + pub fn accept_mentorship(env: Env, mentor: Address, program_id: u64) -> Result<(), Error> { + mentor.require_auth(); + let mut program = load_program(&env, program_id)?; + if program.mentor != mentor { + return Err(Error::Unauthorized); + } + if program.status != ProgramStatus::Requested { + return Err(Error::InvalidProgramState); + } + let mut profile = load_mentor(&env, &mentor)?; + if profile.suspended { + return Err(Error::MentorUnavailable); + } + if profile.active_mentees >= profile.max_mentees { + return Err(Error::MentorAtCapacity); + } + + let start = env.ledger().sequence(); + program.status = ProgramStatus::Active; + program.revenue_share_bps = profile.revenue_share_bps; + program.start_ledger = start; + program.end_ledger = start + .checked_add(program.duration_ledgers) + .ok_or(Error::Overflow)?; + profile.active_mentees += 1; + + save_mentor(&env, &profile); + save_program(&env, &program); + emit_program_status(&env, symbol_short!("accepted"), &program); + Ok(()) + } + + /// Mentor declines a pending request. + pub fn decline_mentorship(env: Env, mentor: Address, program_id: u64) -> Result<(), Error> { + mentor.require_auth(); + let mut program = load_program(&env, program_id)?; + if program.mentor != mentor { + return Err(Error::Unauthorized); + } + if program.status != ProgramStatus::Requested { + return Err(Error::InvalidProgramState); + } + program.status = ProgramStatus::Declined; + release_slot(&env, &program, false)?; + save_program(&env, &program); + emit_program_status(&env, symbol_short!("declined"), &program); + Ok(()) + } + + /// Mentor records a completed mentoring session on an active program. + pub fn log_session(env: Env, mentor: Address, program_id: u64) -> Result { + mentor.require_auth(); + let mut program = load_program(&env, program_id)?; + if program.mentor != mentor { + return Err(Error::Unauthorized); + } + if program.status != ProgramStatus::Active { + return Err(Error::InvalidProgramState); + } + program.sessions = program.sessions.checked_add(1).ok_or(Error::Overflow)?; + save_program(&env, &program); + emit_session_logged(&env, program_id, program.sessions); + Ok(program.sessions) + } + + /// Mentee routes the mentor's revenue share of `gross_amount` earnings. + /// + /// Transfers `gross_amount * revenue_share_bps / 10_000` of `token` from + /// the mentee to the mentor and returns the amount transferred. + pub fn share_earnings( + env: Env, + mentee: Address, + program_id: u64, + token: Address, + gross_amount: i128, + ) -> Result { + mentee.require_auth(); + if gross_amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut program = load_program(&env, program_id)?; + if program.mentee != mentee { + return Err(Error::Unauthorized); + } + if program.status != ProgramStatus::Active { + return Err(Error::InvalidProgramState); + } + if env.ledger().sequence() > program.end_ledger { + return Err(Error::ProgramExpired); + } + + let share = gross_amount + .checked_mul(program.revenue_share_bps as i128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR; + if share > 0 { + token::Client::new(&env, &token).transfer(&mentee, &program.mentor, &share); + } + program.total_shared = program + .total_shared + .checked_add(share) + .ok_or(Error::Overflow)?; + save_program(&env, &program); + emit_earnings_shared(&env, program_id, &token, gross_amount, share); + Ok(share) + } + + /// Complete an active program. + /// + /// The mentee may complete at any time; the mentor only once the term + /// has ended. + pub fn complete_program(env: Env, caller: Address, program_id: u64) -> Result<(), Error> { + caller.require_auth(); + let mut program = load_program(&env, program_id)?; + if program.status != ProgramStatus::Active { + return Err(Error::InvalidProgramState); + } + if caller == program.mentor { + if env.ledger().sequence() < program.end_ledger { + return Err(Error::ProgramNotMatured); + } + } else if caller != program.mentee { + return Err(Error::Unauthorized); + } + + program.status = ProgramStatus::Completed; + release_slot(&env, &program, true)?; + let mut profile = load_mentor(&env, &program.mentor)?; + profile.completed_programs = profile.completed_programs.saturating_add(1); + save_mentor(&env, &profile); + save_program(&env, &program); + emit_program_status(&env, symbol_short!("completed"), &program); + Ok(()) + } + + /// Cancel a requested or active program. Either participant may cancel. + pub fn cancel_program(env: Env, caller: Address, program_id: u64) -> Result<(), Error> { + caller.require_auth(); + let mut program = load_program(&env, program_id)?; + if caller != program.mentor && caller != program.mentee { + return Err(Error::Unauthorized); + } + let was_active = match program.status { + ProgramStatus::Requested => false, + ProgramStatus::Active => true, + _ => return Err(Error::InvalidProgramState), + }; + program.status = ProgramStatus::Cancelled; + release_slot(&env, &program, was_active)?; + save_program(&env, &program); + emit_program_status(&env, symbol_short!("cancelled"), &program); + Ok(()) + } + + /// Mentee rates the mentor (1..=5) once a program has completed. + pub fn rate_mentor(env: Env, mentee: Address, program_id: u64, rating: u32) -> Result<(), Error> { + mentee.require_auth(); + if rating == 0 || rating > MAX_RATING { + return Err(Error::InvalidRating); + } + let mut program = load_program(&env, program_id)?; + if program.mentee != mentee { + return Err(Error::Unauthorized); + } + if program.status != ProgramStatus::Completed { + return Err(Error::InvalidProgramState); + } + if program.rated { + return Err(Error::AlreadyRated); + } + let mut profile = load_mentor(&env, &program.mentor)?; + profile.rating_sum = profile.rating_sum.checked_add(rating).ok_or(Error::Overflow)?; + profile.rating_count = profile.rating_count.checked_add(1).ok_or(Error::Overflow)?; + program.rated = true; + save_mentor(&env, &profile); + save_program(&env, &program); + emit_mentor_rated(&env, program_id, &program.mentor, rating); + Ok(()) + } + + // ----------------------------------------------------------------------- + // Views + // ----------------------------------------------------------------------- + + pub fn get_mentor(env: Env, mentor: Address) -> Option { + env.storage().persistent().get(&mentor_key(&mentor)) + } + + pub fn get_program(env: Env, program_id: u64) -> Option { + env.storage().persistent().get(&program_key(program_id)) + } + + pub fn get_mentor_programs(env: Env, mentor: Address) -> Vec { + env.storage() + .persistent() + .get(&mentor_programs_key(&mentor)) + .unwrap_or_else(|| Vec::new(&env)) + } + + pub fn get_mentee_programs(env: Env, mentee: Address) -> Vec { + env.storage() + .persistent() + .get(&mentee_programs_key(&mentee)) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Average rating scaled by 100 (e.g. 450 == 4.50). Returns 0 when unrated. + pub fn get_mentor_rating(env: Env, mentor: Address) -> u32 { + match env.storage().persistent().get::<_, MentorProfile>(&mentor_key(&mentor)) { + Some(p) if p.rating_count > 0 => p.rating_sum * 100 / p.rating_count, + _ => 0, + } + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/mentorship/src/test.rs b/contracts/mentorship/src/test.rs new file mode 100644 index 0000000..d46ed83 --- /dev/null +++ b/contracts/mentorship/src/test.rs @@ -0,0 +1,526 @@ +#![cfg(test)] + +extern crate std; + +use crate::{ + Error, MentorshipContract, MentorshipContractClient, ProgramStatus, MAX_REVENUE_SHARE_BPS, + MIN_DURATION_LEDGERS, +}; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger}; +use soroban_sdk::{symbol_short, token, Address, Env, Symbol, TryFromVal, Val}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn setup() -> (Env, MentorshipContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(MentorshipContract, ()); + let client = MentorshipContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + (env, client, admin) +} + +fn create_token(env: &Env, holder: &Address, amount: i128) -> Address { + let admin = Address::generate(env); + let token_id = env.register_stellar_asset_contract_v2(admin).address(); + token::StellarAssetClient::new(env, &token_id).mint(holder, &amount); + token_id +} + +fn register_default_mentor(env: &Env, client: &MentorshipContractClient) -> Address { + let mentor = Address::generate(env); + client.register_mentor(&mentor, &symbol_short!("design"), &1_000, &2); + mentor +} + +fn active_program(env: &Env, client: &MentorshipContractClient) -> (Address, Address, u64) { + let mentor = register_default_mentor(env, client); + let mentee = Address::generate(env); + let id = client.request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS); + client.accept_mentorship(&mentor, &id); + (mentor, mentee, id) +} + +/// Returns true if an event with topics `(mentor, action, ..)` was emitted. +fn has_event(env: &Env, action: Symbol) -> bool { + for event in env.events().all().events().iter() { + let soroban_sdk::xdr::ContractEventBody::V0(body) = &event.body; + let topics: &[soroban_sdk::xdr::ScVal] = body.topics.as_ref(); + if topics.len() < 2 { + continue; + } + let t0 = Val::try_from_val(env, &topics[0]).ok(); + let t1 = Val::try_from_val(env, &topics[1]).ok(); + if let (Some(t0), Some(t1)) = (t0, t1) { + let s0 = Symbol::try_from_val(env, &t0).ok(); + let s1 = Symbol::try_from_val(env, &t1).ok(); + if s0 == Some(symbol_short!("mentor")) && s1 == Some(action.clone()) { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// initialize +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_twice_fails() { + let (_env, client, admin) = setup(); + assert_eq!(client.try_initialize(&admin), Err(Ok(Error::AlreadyInitialized))); +} + +#[test] +fn test_register_before_initialize_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = MentorshipContractClient::new(&env, &env.register(MentorshipContract, ())); + let mentor = Address::generate(&env); + assert_eq!( + client.try_register_mentor(&mentor, &symbol_short!("music"), &500, &1), + Err(Ok(Error::NotInitialized)) + ); +} + +// --------------------------------------------------------------------------- +// Mentor registration +// --------------------------------------------------------------------------- + +#[test] +fn test_register_mentor_success() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + assert!(has_event(&env, symbol_short!("reg"))); + + let profile = client.get_mentor(&mentor).unwrap(); + assert_eq!(profile.expertise, symbol_short!("design")); + assert_eq!(profile.revenue_share_bps, 1_000); + assert_eq!(profile.max_mentees, 2); + assert_eq!(profile.active_mentees, 0); + assert!(profile.available); + assert!(!profile.suspended); +} + +#[test] +fn test_register_mentor_twice_fails() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + assert_eq!( + client.try_register_mentor(&mentor, &symbol_short!("design"), &1_000, &2), + Err(Ok(Error::MentorAlreadyRegistered)) + ); +} + +#[test] +fn test_register_mentor_share_too_high_fails() { + let (env, client, _admin) = setup(); + let mentor = Address::generate(&env); + assert_eq!( + client.try_register_mentor(&mentor, &symbol_short!("dev"), &(MAX_REVENUE_SHARE_BPS + 1), &1), + Err(Ok(Error::InvalidRevenueShare)) + ); +} + +#[test] +fn test_register_mentor_zero_capacity_fails() { + let (env, client, _admin) = setup(); + let mentor = Address::generate(&env); + assert_eq!( + client.try_register_mentor(&mentor, &symbol_short!("dev"), &100, &0), + Err(Ok(Error::InvalidCapacity)) + ); +} + +#[test] +fn test_update_mentor_changes_terms() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + client.update_mentor(&mentor, &2_000, &5, &false); + assert!(has_event(&env, symbol_short!("updated"))); + + let profile = client.get_mentor(&mentor).unwrap(); + assert_eq!(profile.revenue_share_bps, 2_000); + assert_eq!(profile.max_mentees, 5); + assert!(!profile.available); +} + +#[test] +fn test_update_mentor_capacity_below_active_fails() { + let (env, client, _admin) = setup(); + let (mentor, _mentee, _id) = active_program(&env, &client); + let mentee2 = Address::generate(&env); + let id2 = client.request_mentorship(&mentee2, &mentor, &MIN_DURATION_LEDGERS); + client.accept_mentorship(&mentor, &id2); + assert_eq!( + client.try_update_mentor(&mentor, &1_000, &1, &true), + Err(Ok(Error::InvalidCapacity)) + ); +} + +#[test] +fn test_admin_suspends_mentor() { + let (env, client, admin) = setup(); + let mentor = register_default_mentor(&env, &client); + client.set_mentor_suspended(&admin, &mentor, &true); + assert!(has_event(&env, symbol_short!("suspend"))); + assert!(client.get_mentor(&mentor).unwrap().suspended); + + let mentee = Address::generate(&env); + assert_eq!( + client.try_request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS), + Err(Ok(Error::MentorUnavailable)) + ); +} + +#[test] +fn test_non_admin_cannot_suspend() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + let rando = Address::generate(&env); + assert_eq!( + client.try_set_mentor_suspended(&rando, &mentor, &true), + Err(Ok(Error::NotAdmin)) + ); +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +#[test] +fn test_request_mentorship_success() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + let mentee = Address::generate(&env); + let id = client.request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS); + assert_eq!(id, 0); + assert!(has_event(&env, symbol_short!("requested"))); + + let program = client.get_program(&id).unwrap(); + assert_eq!(program.status, ProgramStatus::Requested); + assert_eq!(program.mentor, mentor); + assert_eq!(program.mentee, mentee); + assert_eq!(client.get_mentor_programs(&mentor).len(), 1); + assert_eq!(client.get_mentee_programs(&mentee).len(), 1); +} + +#[test] +fn test_request_self_mentorship_fails() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + assert_eq!( + client.try_request_mentorship(&mentor, &mentor, &MIN_DURATION_LEDGERS), + Err(Ok(Error::SelfMentorship)) + ); +} + +#[test] +fn test_request_unknown_mentor_fails() { + let (env, client, _admin) = setup(); + let mentee = Address::generate(&env); + let nobody = Address::generate(&env); + assert_eq!( + client.try_request_mentorship(&mentee, &nobody, &MIN_DURATION_LEDGERS), + Err(Ok(Error::MentorNotFound)) + ); +} + +#[test] +fn test_request_invalid_duration_fails() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + let mentee = Address::generate(&env); + assert_eq!( + client.try_request_mentorship(&mentee, &mentor, &(MIN_DURATION_LEDGERS - 1)), + Err(Ok(Error::InvalidDuration)) + ); +} + +#[test] +fn test_duplicate_open_request_fails() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + let mentee = Address::generate(&env); + client.request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS); + assert_eq!( + client.try_request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS), + Err(Ok(Error::DuplicateProgram)) + ); +} + +#[test] +fn test_request_unavailable_mentor_fails() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + client.update_mentor(&mentor, &1_000, &2, &false); + let mentee = Address::generate(&env); + assert_eq!( + client.try_request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS), + Err(Ok(Error::MentorUnavailable)) + ); +} + +// --------------------------------------------------------------------------- +// Accept / decline +// --------------------------------------------------------------------------- + +#[test] +fn test_accept_starts_program() { + let (env, client, _admin) = setup(); + let (mentor, _mentee, id) = active_program(&env, &client); + assert!(has_event(&env, symbol_short!("accepted"))); + + let program = client.get_program(&id).unwrap(); + assert_eq!(program.status, ProgramStatus::Active); + assert_eq!(program.end_ledger, program.start_ledger + MIN_DURATION_LEDGERS); + assert_eq!(program.revenue_share_bps, 1_000); + assert_eq!(client.get_mentor(&mentor).unwrap().active_mentees, 1); +} + +#[test] +fn test_accept_by_other_address_fails() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + let mentee = Address::generate(&env); + let id = client.request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS); + let rando = Address::generate(&env); + assert_eq!(client.try_accept_mentorship(&rando, &id), Err(Ok(Error::Unauthorized))); +} + +#[test] +fn test_accept_twice_fails() { + let (env, client, _admin) = setup(); + let (mentor, _mentee, id) = active_program(&env, &client); + assert_eq!( + client.try_accept_mentorship(&mentor, &id), + Err(Ok(Error::InvalidProgramState)) + ); +} + +#[test] +fn test_accept_at_capacity_fails() { + let (env, client, _admin) = setup(); + let mentor = Address::generate(&env); + client.register_mentor(&mentor, &symbol_short!("art"), &500, &1); + let mentee_a = Address::generate(&env); + let mentee_b = Address::generate(&env); + let a = client.request_mentorship(&mentee_a, &mentor, &MIN_DURATION_LEDGERS); + let b = client.request_mentorship(&mentee_b, &mentor, &MIN_DURATION_LEDGERS); + client.accept_mentorship(&mentor, &a); + assert_eq!( + client.try_accept_mentorship(&mentor, &b), + Err(Ok(Error::MentorAtCapacity)) + ); +} + +#[test] +fn test_decline_frees_pair_for_new_request() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + let mentee = Address::generate(&env); + let id = client.request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS); + client.decline_mentorship(&mentor, &id); + assert!(has_event(&env, symbol_short!("declined"))); + assert_eq!(client.get_program(&id).unwrap().status, ProgramStatus::Declined); + + let id2 = client.request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS); + assert_eq!(id2, id + 1); +} + +// --------------------------------------------------------------------------- +// Sessions & revenue share +// --------------------------------------------------------------------------- + +#[test] +fn test_log_session_increments() { + let (env, client, _admin) = setup(); + let (mentor, _mentee, id) = active_program(&env, &client); + assert_eq!(client.log_session(&mentor, &id), 1); + assert_eq!(client.log_session(&mentor, &id), 2); + assert!(has_event(&env, symbol_short!("session"))); +} + +#[test] +fn test_log_session_on_requested_fails() { + let (env, client, _admin) = setup(); + let mentor = register_default_mentor(&env, &client); + let mentee = Address::generate(&env); + let id = client.request_mentorship(&mentee, &mentor, &MIN_DURATION_LEDGERS); + assert_eq!( + client.try_log_session(&mentor, &id), + Err(Ok(Error::InvalidProgramState)) + ); +} + +#[test] +fn test_share_earnings_transfers_share() { + let (env, client, _admin) = setup(); + let (mentor, mentee, id) = active_program(&env, &client); + let token_id = create_token(&env, &mentee, 10_000); + + let share = client.share_earnings(&mentee, &id, &token_id, &5_000); + assert_eq!(share, 500); + assert!(has_event(&env, symbol_short!("shared"))); + + let tok = token::Client::new(&env, &token_id); + assert_eq!(tok.balance(&mentor), 500); + assert_eq!(tok.balance(&mentee), 9_500); + assert_eq!(client.get_program(&id).unwrap().total_shared, 500); +} + +#[test] +fn test_share_earnings_zero_amount_fails() { + let (env, client, _admin) = setup(); + let (_mentor, mentee, id) = active_program(&env, &client); + let token_id = create_token(&env, &mentee, 100); + assert_eq!( + client.try_share_earnings(&mentee, &id, &token_id, &0), + Err(Ok(Error::InvalidAmount)) + ); +} + +#[test] +fn test_share_earnings_after_term_fails() { + let (env, client, _admin) = setup(); + let (_mentor, mentee, id) = active_program(&env, &client); + let token_id = create_token(&env, &mentee, 10_000); + let end = client.get_program(&id).unwrap().end_ledger; + env.ledger().with_mut(|l| l.sequence_number = end + 1); + assert_eq!( + client.try_share_earnings(&mentee, &id, &token_id, &1_000), + Err(Ok(Error::ProgramExpired)) + ); +} + +#[test] +fn test_share_earnings_by_non_mentee_fails() { + let (env, client, _admin) = setup(); + let (mentor, _mentee, id) = active_program(&env, &client); + let token_id = create_token(&env, &mentor, 10_000); + assert_eq!( + client.try_share_earnings(&mentor, &id, &token_id, &1_000), + Err(Ok(Error::Unauthorized)) + ); +} + +// --------------------------------------------------------------------------- +// Completion, cancellation, rating +// --------------------------------------------------------------------------- + +#[test] +fn test_mentee_completes_program() { + let (env, client, _admin) = setup(); + let (mentor, mentee, id) = active_program(&env, &client); + client.complete_program(&mentee, &id); + assert!(has_event(&env, symbol_short!("completed"))); + + assert_eq!(client.get_program(&id).unwrap().status, ProgramStatus::Completed); + let profile = client.get_mentor(&mentor).unwrap(); + assert_eq!(profile.active_mentees, 0); + assert_eq!(profile.completed_programs, 1); +} + +#[test] +fn test_mentor_cannot_complete_before_term() { + let (env, client, _admin) = setup(); + let (mentor, _mentee, id) = active_program(&env, &client); + assert_eq!( + client.try_complete_program(&mentor, &id), + Err(Ok(Error::ProgramNotMatured)) + ); +} + +#[test] +fn test_mentor_completes_after_term() { + let (env, client, _admin) = setup(); + let (mentor, _mentee, id) = active_program(&env, &client); + let end = client.get_program(&id).unwrap().end_ledger; + env.ledger().with_mut(|l| l.sequence_number = end); + client.complete_program(&mentor, &id); + assert_eq!(client.get_program(&id).unwrap().status, ProgramStatus::Completed); +} + +#[test] +fn test_cancel_active_program_frees_slot() { + let (env, client, _admin) = setup(); + let (mentor, mentee, id) = active_program(&env, &client); + client.cancel_program(&mentee, &id); + assert!(has_event(&env, symbol_short!("cancelled"))); + assert_eq!(client.get_program(&id).unwrap().status, ProgramStatus::Cancelled); + assert_eq!(client.get_mentor(&mentor).unwrap().active_mentees, 0); +} + +#[test] +fn test_cancel_by_outsider_fails() { + let (env, client, _admin) = setup(); + let (_mentor, _mentee, id) = active_program(&env, &client); + let rando = Address::generate(&env); + assert_eq!(client.try_cancel_program(&rando, &id), Err(Ok(Error::Unauthorized))); +} + +#[test] +fn test_cancel_completed_program_fails() { + let (env, client, _admin) = setup(); + let (_mentor, mentee, id) = active_program(&env, &client); + client.complete_program(&mentee, &id); + assert_eq!( + client.try_cancel_program(&mentee, &id), + Err(Ok(Error::InvalidProgramState)) + ); +} + +#[test] +fn test_rate_mentor_updates_average() { + let (env, client, _admin) = setup(); + let (mentor, mentee, id) = active_program(&env, &client); + client.complete_program(&mentee, &id); + client.rate_mentor(&mentee, &id, &4); + assert!(has_event(&env, symbol_short!("rated"))); + + let mentee2 = Address::generate(&env); + let id2 = client.request_mentorship(&mentee2, &mentor, &MIN_DURATION_LEDGERS); + client.accept_mentorship(&mentor, &id2); + client.complete_program(&mentee2, &id2); + client.rate_mentor(&mentee2, &id2, &5); + + assert_eq!(client.get_mentor_rating(&mentor), 450); +} + +#[test] +fn test_rate_twice_fails() { + let (env, client, _admin) = setup(); + let (_mentor, mentee, id) = active_program(&env, &client); + client.complete_program(&mentee, &id); + client.rate_mentor(&mentee, &id, &5); + assert_eq!(client.try_rate_mentor(&mentee, &id, &5), Err(Ok(Error::AlreadyRated))); +} + +#[test] +fn test_rate_before_completion_fails() { + let (env, client, _admin) = setup(); + let (_mentor, mentee, id) = active_program(&env, &client); + assert_eq!( + client.try_rate_mentor(&mentee, &id, &5), + Err(Ok(Error::InvalidProgramState)) + ); +} + +#[test] +fn test_rate_out_of_range_fails() { + let (env, client, _admin) = setup(); + let (_mentor, mentee, id) = active_program(&env, &client); + client.complete_program(&mentee, &id); + assert_eq!(client.try_rate_mentor(&mentee, &id, &6), Err(Ok(Error::InvalidRating))); + assert_eq!(client.try_rate_mentor(&mentee, &id, &0), Err(Ok(Error::InvalidRating))); +} + +#[test] +fn test_unknown_program_returns_none() { + let (_env, client, _admin) = setup(); + assert!(client.get_program(&42).is_none()); +} diff --git a/contracts/mentorship/src/types.rs b/contracts/mentorship/src/types.rs new file mode 100644 index 0000000..e8b6c34 --- /dev/null +++ b/contracts/mentorship/src/types.rs @@ -0,0 +1,63 @@ +//! Shared types for the mentorship contract. + +use soroban_sdk::{contracttype, Address, Symbol}; + +/// Lifecycle of a mentorship program. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProgramStatus { + /// Mentee has asked; waiting for the mentor to accept or decline. + Requested, + /// Mentor accepted; the program term is running. + Active, + /// Program finished successfully. + Completed, + /// Program was cancelled by a participant before completion. + Cancelled, + /// Mentor declined the request. + Declined, +} + +/// On-chain profile of a creator offering mentorship. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MentorProfile { + pub mentor: Address, + /// Area of expertise (e.g. `design`, `music`, `dev`). + pub expertise: Symbol, + /// Share of mentee earnings paid to the mentor while a program is active. + pub revenue_share_bps: u32, + /// Maximum number of concurrently active mentees. + pub max_mentees: u32, + pub active_mentees: u32, + pub completed_programs: u32, + pub rating_sum: u32, + pub rating_count: u32, + /// Mentor-controlled availability toggle. + pub available: bool, + /// Admin-controlled suspension flag. + pub suspended: bool, + pub registered_at: u64, +} + +/// A single mentor ↔ mentee engagement. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MentorshipProgram { + pub id: u64, + pub mentor: Address, + pub mentee: Address, + pub status: ProgramStatus, + /// Revenue share locked in from the mentor profile at acceptance time. + pub revenue_share_bps: u32, + pub duration_ledgers: u32, + pub requested_at: u64, + /// Ledger sequence at which the program became active (0 until accepted). + pub start_ledger: u32, + /// Ledger sequence after which the term has ended (0 until accepted). + pub end_ledger: u32, + pub sessions: u32, + /// Cumulative earnings share transferred to the mentor. + pub total_shared: i128, + pub rated: bool, +} From 61e385ac75789a0f2d638972a137e3a2a45d1cfc Mon Sep 17 00:00:00 2001 From: olivia-brien Date: Sat, 17 Oct 2026 16:22:00 +0000 Subject: [PATCH 2/4] feat(#885): add recipient skill verification contracts Add a standalone skill-verification contract. Recipients submit skill claims with an evidence hash; admin-appointed, skill-scoped verifiers approve them at a proficiency level (1-5) with an expiry, or reject them with a reason hash. Attestations can be revoked by the issuing verifier or admin. Views (is_skill_verified, get_skill_level, meets_requirement, all_meet_requirement) let the split contract gate recipient lists. All state changes emit events under the `skill` topic. Closes #885 Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- contracts/skill-verification/Cargo.toml | 16 + contracts/skill-verification/src/errors.rs | 41 ++ contracts/skill-verification/src/lib.rs | 431 +++++++++++++++++++++ contracts/skill-verification/src/test.rs | 389 +++++++++++++++++++ contracts/skill-verification/src/types.rs | 47 +++ 6 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 contracts/skill-verification/Cargo.toml create mode 100644 contracts/skill-verification/src/errors.rs create mode 100644 contracts/skill-verification/src/lib.rs create mode 100644 contracts/skill-verification/src/test.rs create mode 100644 contracts/skill-verification/src/types.rs diff --git a/Cargo.toml b/Cargo.toml index e15b464..749e36b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry", "contracts/mentorship"] +members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry", "contracts/mentorship", "contracts/skill-verification"] resolver = "2" [workspace.dependencies] diff --git a/contracts/skill-verification/Cargo.toml b/contracts/skill-verification/Cargo.toml new file mode 100644 index 0000000..6620fbc --- /dev/null +++ b/contracts/skill-verification/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "skill-verification" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/skill-verification/src/errors.rs b/contracts/skill-verification/src/errors.rs new file mode 100644 index 0000000..517ca79 --- /dev/null +++ b/contracts/skill-verification/src/errors.rs @@ -0,0 +1,41 @@ +//! Error variants for the skill-verification contract. +//! +//! Discriminants are stable — never reorder, only append. + +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// Contract has already been initialized. + AlreadyInitialized = 1, + /// Contract has not been initialized yet. + NotInitialized = 2, + /// Caller is not the contract admin. + NotAdmin = 3, + /// Caller is not authorised to verify this skill. + NotVerifier = 4, + /// The recipient already has a pending claim for this skill. + ClaimAlreadyPending = 5, + /// Claim ID was not found in storage. + ClaimNotFound = 6, + /// The claim has already been reviewed. + ClaimNotPending = 7, + /// Verifiers cannot verify their own claims. + SelfVerification = 8, + /// Level must be between 1 and `MAX_SKILL_LEVEL`. + InvalidLevel = 9, + /// Validity must be between `MIN_VALIDITY_LEDGERS` and `MAX_VALIDITY_LEDGERS`. + InvalidValidity = 10, + /// No attestation exists for this recipient and skill. + AttestationNotFound = 11, + /// The attestation has already been revoked. + AlreadyRevoked = 12, + /// The recipient list passed to a batch check is empty or too long. + InvalidRecipientList = 13, + /// Arithmetic overflow. + Overflow = 14, + /// Caller does not own the claim. + Unauthorized = 15, +} diff --git a/contracts/skill-verification/src/lib.rs b/contracts/skill-verification/src/lib.rs new file mode 100644 index 0000000..e09c12a --- /dev/null +++ b/contracts/skill-verification/src/lib.rs @@ -0,0 +1,431 @@ +//! StellarSplit Skill Verification — on-chain attestations of recipient skills. +//! +//! Split recipients submit skill claims backed by an evidence hash. Verifiers +//! appointed by the admin for a specific skill approve claims at a +//! proficiency level with an expiry, or reject them. Approved attestations +//! can be revoked by the issuing verifier or the admin. +//! +//! Other contracts (e.g. the split contract) can gate payouts with +//! [`SkillVerificationContract::meets_requirement`] or check a whole +//! recipient list with [`SkillVerificationContract::all_meet_requirement`]. +//! Every state change emits an event under the `skill` topic. + +#![no_std] + +mod errors; +mod types; + +pub use errors::Error; +pub use types::{ClaimStatus, SkillAttestation, SkillClaim}; + +use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Symbol, Vec}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +pub const MAX_SKILL_LEVEL: u32 = 5; +/// Roughly one week at 5s ledgers. +pub const MIN_VALIDITY_LEDGERS: u32 = 120_960; +/// Roughly two years at 5s ledgers. +pub const MAX_VALIDITY_LEDGERS: u32 = 12_614_400; +/// Upper bound for batch recipient checks. +pub const MAX_BATCH_RECIPIENTS: u32 = 50; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn admin_key() -> Symbol { + symbol_short!("admin") +} + +fn counter_key() -> Symbol { + symbol_short!("claim_ctr") +} + +fn verifier_key(verifier: &Address, skill: &Symbol) -> (Symbol, Address, Symbol) { + (symbol_short!("verifier"), verifier.clone(), skill.clone()) +} + +fn claim_key(id: u64) -> (Symbol, u64) { + (symbol_short!("claim"), id) +} + +fn pending_claim_key(recipient: &Address, skill: &Symbol) -> (Symbol, Address, Symbol) { + (symbol_short!("pend_clm"), recipient.clone(), skill.clone()) +} + +fn attestation_key(recipient: &Address, skill: &Symbol) -> (Symbol, Address, Symbol) { + (symbol_short!("attest"), recipient.clone(), skill.clone()) +} + +fn recipient_skills_key(recipient: &Address) -> (Symbol, Address) { + (symbol_short!("rcp_skls"), recipient.clone()) +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +fn emit_verifier_set(env: &Env, verifier: &Address, skill: &Symbol, authorized: bool) { + env.events().publish( + (symbol_short!("skill"), symbol_short!("verifier"), verifier.clone()), + (skill.clone(), authorized), + ); +} + +fn emit_claim_submitted(env: &Env, claim: &SkillClaim) { + env.events().publish( + (symbol_short!("skill"), symbol_short!("claimed"), claim.id), + (claim.recipient.clone(), claim.skill.clone(), claim.evidence_hash.clone()), + ); +} + +fn emit_claim_withdrawn(env: &Env, claim: &SkillClaim) { + env.events().publish( + (symbol_short!("skill"), symbol_short!("withdrawn"), claim.id), + (claim.recipient.clone(), claim.skill.clone()), + ); +} + +fn emit_skill_verified(env: &Env, att: &SkillAttestation) { + env.events().publish( + (symbol_short!("skill"), symbol_short!("verified"), att.recipient.clone()), + ( + att.skill.clone(), + att.verifier.clone(), + att.level, + att.expires_at_ledger, + att.claim_id, + ), + ); +} + +fn emit_claim_rejected(env: &Env, claim: &SkillClaim, verifier: &Address, reason_hash: &BytesN<32>) { + env.events().publish( + (symbol_short!("skill"), symbol_short!("rejected"), claim.id), + (claim.recipient.clone(), verifier.clone(), reason_hash.clone()), + ); +} + +fn emit_skill_revoked(env: &Env, recipient: &Address, skill: &Symbol, by: &Address) { + env.events().publish( + (symbol_short!("skill"), symbol_short!("revoked"), recipient.clone()), + (skill.clone(), by.clone()), + ); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&admin_key()) + .ok_or(Error::NotInitialized) +} + +fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> { + if get_admin(env)? != *admin { + return Err(Error::NotAdmin); + } + admin.require_auth(); + Ok(()) +} + +fn is_verifier_for(env: &Env, verifier: &Address, skill: &Symbol) -> bool { + env.storage() + .persistent() + .get(&verifier_key(verifier, skill)) + .unwrap_or(false) +} + +fn load_claim(env: &Env, id: u64) -> Result { + env.storage() + .persistent() + .get(&claim_key(id)) + .ok_or(Error::ClaimNotFound) +} + +fn load_pending_claim(env: &Env, verifier: &Address, claim_id: u64) -> Result { + let claim = load_claim(env, claim_id)?; + if claim.status != ClaimStatus::Pending { + return Err(Error::ClaimNotPending); + } + if !is_verifier_for(env, verifier, &claim.skill) { + return Err(Error::NotVerifier); + } + if claim.recipient == *verifier { + return Err(Error::SelfVerification); + } + Ok(claim) +} + +fn close_claim(env: &Env, claim: &SkillClaim) { + env.storage().persistent().set(&claim_key(claim.id), claim); + env.storage() + .persistent() + .remove(&pending_claim_key(&claim.recipient, &claim.skill)); +} + +/// Returns the attestation only when it is neither revoked nor expired. +fn valid_attestation(env: &Env, recipient: &Address, skill: &Symbol) -> Option { + let att: SkillAttestation = env + .storage() + .persistent() + .get(&attestation_key(recipient, skill))?; + if att.revoked || env.ledger().sequence() > att.expires_at_ledger { + return None; + } + Some(att) +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct SkillVerificationContract; + +#[contractimpl] +impl SkillVerificationContract { + /// Initialise the contract with an admin who appoints verifiers. + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&admin_key()) { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&admin_key(), &admin); + env.storage().instance().set(&counter_key(), &0u64); + Ok(()) + } + + /// Admin-only: authorise or de-authorise `verifier` for `skill`. + pub fn set_verifier( + env: Env, + admin: Address, + verifier: Address, + skill: Symbol, + authorized: bool, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + let key = verifier_key(&verifier, &skill); + if authorized { + env.storage().persistent().set(&key, &true); + } else { + env.storage().persistent().remove(&key); + } + emit_verifier_set(&env, &verifier, &skill, authorized); + Ok(()) + } + + /// Recipient submits a claim for `skill` with a hash of supporting evidence. + /// Only one pending claim per (recipient, skill) is allowed. Returns the claim ID. + pub fn submit_claim( + env: Env, + recipient: Address, + skill: Symbol, + evidence_hash: BytesN<32>, + ) -> Result { + recipient.require_auth(); + let pending_key = pending_claim_key(&recipient, &skill); + if env.storage().persistent().has(&pending_key) { + return Err(Error::ClaimAlreadyPending); + } + let id: u64 = env + .storage() + .instance() + .get(&counter_key()) + .ok_or(Error::NotInitialized)?; + let next = id.checked_add(1).ok_or(Error::Overflow)?; + env.storage().instance().set(&counter_key(), &next); + + let claim = SkillClaim { + id, + recipient: recipient.clone(), + skill, + evidence_hash, + status: ClaimStatus::Pending, + submitted_at: env.ledger().timestamp(), + reviewer: None, + reason_hash: None, + }; + env.storage().persistent().set(&claim_key(id), &claim); + env.storage().persistent().set(&pending_key, &id); + emit_claim_submitted(&env, &claim); + Ok(id) + } + + /// Recipient withdraws their own pending claim. + pub fn withdraw_claim(env: Env, recipient: Address, claim_id: u64) -> Result<(), Error> { + recipient.require_auth(); + let mut claim = load_claim(&env, claim_id)?; + if claim.recipient != recipient { + return Err(Error::Unauthorized); + } + if claim.status != ClaimStatus::Pending { + return Err(Error::ClaimNotPending); + } + claim.status = ClaimStatus::Withdrawn; + close_claim(&env, &claim); + emit_claim_withdrawn(&env, &claim); + Ok(()) + } + + /// Verifier approves a pending claim at `level`, valid for + /// `validity_ledgers` from now. Replaces any earlier attestation for the + /// same skill. + pub fn approve_claim( + env: Env, + verifier: Address, + claim_id: u64, + level: u32, + validity_ledgers: u32, + ) -> Result<(), Error> { + verifier.require_auth(); + if level == 0 || level > MAX_SKILL_LEVEL { + return Err(Error::InvalidLevel); + } + if !(MIN_VALIDITY_LEDGERS..=MAX_VALIDITY_LEDGERS).contains(&validity_ledgers) { + return Err(Error::InvalidValidity); + } + let mut claim = load_pending_claim(&env, &verifier, claim_id)?; + + let expires_at_ledger = env + .ledger() + .sequence() + .checked_add(validity_ledgers) + .ok_or(Error::Overflow)?; + let att = SkillAttestation { + recipient: claim.recipient.clone(), + skill: claim.skill.clone(), + verifier: verifier.clone(), + level, + evidence_hash: claim.evidence_hash.clone(), + claim_id, + verified_at: env.ledger().timestamp(), + expires_at_ledger, + revoked: false, + }; + let att_key = attestation_key(&claim.recipient, &claim.skill); + if !env.storage().persistent().has(&att_key) { + let skills_key = recipient_skills_key(&claim.recipient); + let mut skills: Vec = env + .storage() + .persistent() + .get(&skills_key) + .unwrap_or_else(|| Vec::new(&env)); + skills.push_back(claim.skill.clone()); + env.storage().persistent().set(&skills_key, &skills); + } + env.storage().persistent().set(&att_key, &att); + + claim.status = ClaimStatus::Approved; + claim.reviewer = Some(verifier); + close_claim(&env, &claim); + emit_skill_verified(&env, &att); + Ok(()) + } + + /// Verifier rejects a pending claim, recording a hash of the reason. + pub fn reject_claim( + env: Env, + verifier: Address, + claim_id: u64, + reason_hash: BytesN<32>, + ) -> Result<(), Error> { + verifier.require_auth(); + let mut claim = load_pending_claim(&env, &verifier, claim_id)?; + claim.status = ClaimStatus::Rejected; + claim.reviewer = Some(verifier.clone()); + claim.reason_hash = Some(reason_hash.clone()); + close_claim(&env, &claim); + emit_claim_rejected(&env, &claim, &verifier, &reason_hash); + Ok(()) + } + + /// Revoke a recipient's attestation. Callable by the issuing verifier or the admin. + pub fn revoke(env: Env, caller: Address, recipient: Address, skill: Symbol) -> Result<(), Error> { + caller.require_auth(); + let key = attestation_key(&recipient, &skill); + let mut att: SkillAttestation = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::AttestationNotFound)?; + if caller != att.verifier && caller != get_admin(&env)? { + return Err(Error::NotVerifier); + } + if att.revoked { + return Err(Error::AlreadyRevoked); + } + att.revoked = true; + env.storage().persistent().set(&key, &att); + emit_skill_revoked(&env, &recipient, &skill, &caller); + Ok(()) + } + + // ----------------------------------------------------------------------- + // Views + // ----------------------------------------------------------------------- + + pub fn is_verifier(env: Env, verifier: Address, skill: Symbol) -> bool { + is_verifier_for(&env, &verifier, &skill) + } + + pub fn get_claim(env: Env, claim_id: u64) -> Option { + env.storage().persistent().get(&claim_key(claim_id)) + } + + /// Raw attestation, including revoked or expired ones. + pub fn get_attestation(env: Env, recipient: Address, skill: Symbol) -> Option { + env.storage().persistent().get(&attestation_key(&recipient, &skill)) + } + + /// Every skill the recipient has ever been verified for (valid or not). + pub fn get_recipient_skills(env: Env, recipient: Address) -> Vec { + env.storage() + .persistent() + .get(&recipient_skills_key(&recipient)) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// True when the recipient holds a valid (unrevoked, unexpired) attestation. + pub fn is_skill_verified(env: Env, recipient: Address, skill: Symbol) -> bool { + valid_attestation(&env, &recipient, &skill).is_some() + } + + /// Current valid level for the skill, or 0 if not verified. + pub fn get_skill_level(env: Env, recipient: Address, skill: Symbol) -> u32 { + valid_attestation(&env, &recipient, &skill).map_or(0, |a| a.level) + } + + /// True when the recipient holds `skill` at `min_level` or above. + pub fn meets_requirement(env: Env, recipient: Address, skill: Symbol, min_level: u32) -> bool { + valid_attestation(&env, &recipient, &skill).is_some_and(|a| a.level >= min_level) + } + + /// True when every recipient holds `skill` at `min_level` or above. + /// Intended for gating split recipient lists before invoice creation. + pub fn all_meet_requirement( + env: Env, + recipients: Vec
, + skill: Symbol, + min_level: u32, + ) -> Result { + if recipients.is_empty() || recipients.len() > MAX_BATCH_RECIPIENTS { + return Err(Error::InvalidRecipientList); + } + for recipient in recipients.iter() { + let ok = valid_attestation(&env, &recipient, &skill).is_some_and(|a| a.level >= min_level); + if !ok { + return Ok(false); + } + } + Ok(true) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/skill-verification/src/test.rs b/contracts/skill-verification/src/test.rs new file mode 100644 index 0000000..e267a5e --- /dev/null +++ b/contracts/skill-verification/src/test.rs @@ -0,0 +1,389 @@ +#![cfg(test)] + +extern crate std; + +use crate::{ + ClaimStatus, Error, SkillVerificationContract, SkillVerificationContractClient, + MAX_BATCH_RECIPIENTS, MIN_VALIDITY_LEDGERS, +}; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger}; +use soroban_sdk::{symbol_short, vec, Address, BytesN, Env, Symbol, TryFromVal, Val, Vec}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +struct Setup { + env: Env, + client: SkillVerificationContractClient<'static>, + admin: Address, + verifier: Address, +} + +fn skill() -> Symbol { + symbol_short!("rust") +} + +fn evidence(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[7u8; 32]) +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(SkillVerificationContract, ()); + let client = SkillVerificationContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let verifier = Address::generate(&env); + client.initialize(&admin); + client.set_verifier(&admin, &verifier, &skill(), &true); + Setup { env, client, admin, verifier } +} + +/// Submits and approves a claim for `skill()` at `level`; returns the recipient. +fn verified_recipient(s: &Setup, level: u32) -> Address { + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + s.client.approve_claim(&s.verifier, &id, &level, &MIN_VALIDITY_LEDGERS); + recipient +} + +/// Returns true if an event with topics `(skill, action, ..)` was emitted. +fn has_event(env: &Env, action: Symbol) -> bool { + for event in env.events().all().events().iter() { + let soroban_sdk::xdr::ContractEventBody::V0(body) = &event.body; + let topics: &[soroban_sdk::xdr::ScVal] = body.topics.as_ref(); + if topics.len() < 2 { + continue; + } + let t0 = Val::try_from_val(env, &topics[0]).ok(); + let t1 = Val::try_from_val(env, &topics[1]).ok(); + if let (Some(t0), Some(t1)) = (t0, t1) { + let s0 = Symbol::try_from_val(env, &t0).ok(); + let s1 = Symbol::try_from_val(env, &t1).ok(); + if s0 == Some(symbol_short!("skill")) && s1 == Some(action.clone()) { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Admin & verifiers +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_twice_fails() { + let s = setup(); + assert_eq!(s.client.try_initialize(&s.admin), Err(Ok(Error::AlreadyInitialized))); +} + +#[test] +fn test_set_verifier_emits_event() { + let s = setup(); + let v = Address::generate(&s.env); + s.client.set_verifier(&s.admin, &v, &symbol_short!("audio"), &true); + assert!(has_event(&s.env, symbol_short!("verifier"))); + assert!(s.client.is_verifier(&v, &symbol_short!("audio"))); + assert!(!s.client.is_verifier(&v, &skill())); +} + +#[test] +fn test_remove_verifier() { + let s = setup(); + s.client.set_verifier(&s.admin, &s.verifier, &skill(), &false); + assert!(!s.client.is_verifier(&s.verifier, &skill())); +} + +#[test] +fn test_non_admin_cannot_set_verifier() { + let s = setup(); + let rando = Address::generate(&s.env); + assert_eq!( + s.client.try_set_verifier(&rando, &rando, &skill(), &true), + Err(Ok(Error::NotAdmin)) + ); +} + +// --------------------------------------------------------------------------- +// Claims +// --------------------------------------------------------------------------- + +#[test] +fn test_submit_claim_success() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + assert_eq!(id, 0); + assert!(has_event(&s.env, symbol_short!("claimed"))); + + let claim = s.client.get_claim(&id).unwrap(); + assert_eq!(claim.status, ClaimStatus::Pending); + assert_eq!(claim.recipient, recipient); + assert_eq!(claim.evidence_hash, evidence(&s.env)); +} + +#[test] +fn test_duplicate_pending_claim_fails() { + let s = setup(); + let recipient = Address::generate(&s.env); + s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + assert_eq!( + s.client.try_submit_claim(&recipient, &skill(), &evidence(&s.env)), + Err(Ok(Error::ClaimAlreadyPending)) + ); +} + +#[test] +fn test_withdraw_claim_allows_resubmission() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + s.client.withdraw_claim(&recipient, &id); + assert!(has_event(&s.env, symbol_short!("withdrawn"))); + assert_eq!(s.client.get_claim(&id).unwrap().status, ClaimStatus::Withdrawn); + + let id2 = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + assert_eq!(id2, id + 1); +} + +#[test] +fn test_withdraw_others_claim_fails() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + let rando = Address::generate(&s.env); + assert_eq!(s.client.try_withdraw_claim(&rando, &id), Err(Ok(Error::Unauthorized))); +} + +// --------------------------------------------------------------------------- +// Approval / rejection +// --------------------------------------------------------------------------- + +#[test] +fn test_approve_claim_creates_attestation() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + s.client.approve_claim(&s.verifier, &id, &3, &MIN_VALIDITY_LEDGERS); + assert!(has_event(&s.env, symbol_short!("verified"))); + + let claim = s.client.get_claim(&id).unwrap(); + assert_eq!(claim.status, ClaimStatus::Approved); + assert_eq!(claim.reviewer, Some(s.verifier.clone())); + + let att = s.client.get_attestation(&recipient, &skill()).unwrap(); + assert_eq!(att.level, 3); + assert_eq!(att.verifier, s.verifier); + assert_eq!(att.claim_id, id); + assert!(s.client.is_skill_verified(&recipient, &skill())); + assert_eq!(s.client.get_skill_level(&recipient, &skill()), 3); + assert_eq!(s.client.get_recipient_skills(&recipient), vec![&s.env, skill()]); +} + +#[test] +fn test_approve_by_unauthorized_verifier_fails() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + let rando = Address::generate(&s.env); + assert_eq!( + s.client.try_approve_claim(&rando, &id, &3, &MIN_VALIDITY_LEDGERS), + Err(Ok(Error::NotVerifier)) + ); +} + +#[test] +fn test_verifier_scoped_to_skill() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &symbol_short!("audio"), &evidence(&s.env)); + assert_eq!( + s.client.try_approve_claim(&s.verifier, &id, &3, &MIN_VALIDITY_LEDGERS), + Err(Ok(Error::NotVerifier)) + ); +} + +#[test] +fn test_self_verification_fails() { + let s = setup(); + let id = s.client.submit_claim(&s.verifier, &skill(), &evidence(&s.env)); + assert_eq!( + s.client.try_approve_claim(&s.verifier, &id, &3, &MIN_VALIDITY_LEDGERS), + Err(Ok(Error::SelfVerification)) + ); +} + +#[test] +fn test_approve_invalid_level_fails() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + assert_eq!( + s.client.try_approve_claim(&s.verifier, &id, &0, &MIN_VALIDITY_LEDGERS), + Err(Ok(Error::InvalidLevel)) + ); + assert_eq!( + s.client.try_approve_claim(&s.verifier, &id, &6, &MIN_VALIDITY_LEDGERS), + Err(Ok(Error::InvalidLevel)) + ); +} + +#[test] +fn test_approve_invalid_validity_fails() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + assert_eq!( + s.client.try_approve_claim(&s.verifier, &id, &3, &(MIN_VALIDITY_LEDGERS - 1)), + Err(Ok(Error::InvalidValidity)) + ); +} + +#[test] +fn test_approve_twice_fails() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + s.client.approve_claim(&s.verifier, &id, &3, &MIN_VALIDITY_LEDGERS); + assert_eq!( + s.client.try_approve_claim(&s.verifier, &id, &3, &MIN_VALIDITY_LEDGERS), + Err(Ok(Error::ClaimNotPending)) + ); +} + +#[test] +fn test_reject_claim() { + let s = setup(); + let recipient = Address::generate(&s.env); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + let reason = BytesN::from_array(&s.env, &[9u8; 32]); + s.client.reject_claim(&s.verifier, &id, &reason); + assert!(has_event(&s.env, symbol_short!("rejected"))); + + let claim = s.client.get_claim(&id).unwrap(); + assert_eq!(claim.status, ClaimStatus::Rejected); + assert_eq!(claim.reason_hash, Some(reason)); + assert!(!s.client.is_skill_verified(&recipient, &skill())); +} + +#[test] +fn test_reverification_upgrades_level_without_duplicate_skill_entry() { + let s = setup(); + let recipient = verified_recipient(&s, 2); + let id = s.client.submit_claim(&recipient, &skill(), &evidence(&s.env)); + s.client.approve_claim(&s.verifier, &id, &5, &MIN_VALIDITY_LEDGERS); + assert_eq!(s.client.get_skill_level(&recipient, &skill()), 5); + assert_eq!(s.client.get_recipient_skills(&recipient).len(), 1); +} + +// --------------------------------------------------------------------------- +// Expiry & revocation +// --------------------------------------------------------------------------- + +#[test] +fn test_attestation_expires() { + let s = setup(); + let recipient = verified_recipient(&s, 3); + let expires = s.client.get_attestation(&recipient, &skill()).unwrap().expires_at_ledger; + + s.env.ledger().with_mut(|l| l.sequence_number = expires); + assert!(s.client.is_skill_verified(&recipient, &skill())); + + s.env.ledger().with_mut(|l| l.sequence_number = expires + 1); + assert!(!s.client.is_skill_verified(&recipient, &skill())); + assert_eq!(s.client.get_skill_level(&recipient, &skill()), 0); +} + +#[test] +fn test_verifier_revokes_attestation() { + let s = setup(); + let recipient = verified_recipient(&s, 3); + s.client.revoke(&s.verifier, &recipient, &skill()); + assert!(has_event(&s.env, symbol_short!("revoked"))); + assert!(!s.client.is_skill_verified(&recipient, &skill())); + assert!(s.client.get_attestation(&recipient, &skill()).unwrap().revoked); +} + +#[test] +fn test_admin_revokes_attestation() { + let s = setup(); + let recipient = verified_recipient(&s, 3); + s.client.revoke(&s.admin, &recipient, &skill()); + assert!(!s.client.is_skill_verified(&recipient, &skill())); +} + +#[test] +fn test_outsider_cannot_revoke() { + let s = setup(); + let recipient = verified_recipient(&s, 3); + let rando = Address::generate(&s.env); + assert_eq!( + s.client.try_revoke(&rando, &recipient, &skill()), + Err(Ok(Error::NotVerifier)) + ); +} + +#[test] +fn test_revoke_twice_fails() { + let s = setup(); + let recipient = verified_recipient(&s, 3); + s.client.revoke(&s.verifier, &recipient, &skill()); + assert_eq!( + s.client.try_revoke(&s.verifier, &recipient, &skill()), + Err(Ok(Error::AlreadyRevoked)) + ); +} + +#[test] +fn test_revoke_missing_attestation_fails() { + let s = setup(); + let recipient = Address::generate(&s.env); + assert_eq!( + s.client.try_revoke(&s.admin, &recipient, &skill()), + Err(Ok(Error::AttestationNotFound)) + ); +} + +// --------------------------------------------------------------------------- +// Requirement checks +// --------------------------------------------------------------------------- + +#[test] +fn test_meets_requirement_by_level() { + let s = setup(); + let recipient = verified_recipient(&s, 3); + assert!(s.client.meets_requirement(&recipient, &skill(), &1)); + assert!(s.client.meets_requirement(&recipient, &skill(), &3)); + assert!(!s.client.meets_requirement(&recipient, &skill(), &4)); + assert!(!s.client.meets_requirement(&recipient, &symbol_short!("audio"), &1)); +} + +#[test] +fn test_all_meet_requirement() { + let s = setup(); + let a = verified_recipient(&s, 4); + let b = verified_recipient(&s, 2); + assert!(s.client.all_meet_requirement(&vec![&s.env, a.clone(), b.clone()], &skill(), &2)); + assert!(!s.client.all_meet_requirement(&vec![&s.env, a, b], &skill(), &3)); +} + +#[test] +fn test_all_meet_requirement_rejects_bad_lists() { + let s = setup(); + let empty: Vec
= Vec::new(&s.env); + assert_eq!( + s.client.try_all_meet_requirement(&empty, &skill(), &1), + Err(Ok(Error::InvalidRecipientList)) + ); + + let mut too_many: Vec
= Vec::new(&s.env); + for _ in 0..=MAX_BATCH_RECIPIENTS { + too_many.push_back(Address::generate(&s.env)); + } + assert_eq!( + s.client.try_all_meet_requirement(&too_many, &skill(), &1), + Err(Ok(Error::InvalidRecipientList)) + ); +} diff --git a/contracts/skill-verification/src/types.rs b/contracts/skill-verification/src/types.rs new file mode 100644 index 0000000..df965ba --- /dev/null +++ b/contracts/skill-verification/src/types.rs @@ -0,0 +1,47 @@ +//! Shared types for the skill-verification contract. + +use soroban_sdk::{contracttype, Address, BytesN, Symbol}; + +/// Review state of a skill claim. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClaimStatus { + Pending, + Approved, + Rejected, + Withdrawn, +} + +/// A recipient's request to have a skill verified. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillClaim { + pub id: u64, + pub recipient: Address, + pub skill: Symbol, + /// Hash of off-chain evidence (portfolio, certificate, etc.). + pub evidence_hash: BytesN<32>, + pub status: ClaimStatus, + pub submitted_at: u64, + /// Verifier that reviewed the claim, once reviewed. + pub reviewer: Option
, + /// Hash of the reviewer's rejection reason, if rejected. + pub reason_hash: Option>, +} + +/// A verifier's attestation that a recipient holds a skill at a given level. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillAttestation { + pub recipient: Address, + pub skill: Symbol, + pub verifier: Address, + /// Proficiency level, 1 (basic) to `MAX_SKILL_LEVEL` (expert). + pub level: u32, + pub evidence_hash: BytesN<32>, + pub claim_id: u64, + pub verified_at: u64, + /// Ledger sequence after which the attestation is no longer valid. + pub expires_at_ledger: u32, + pub revoked: bool, +} From d692d10481910d4a32394b0127f0d1aff5f8dae2 Mon Sep 17 00:00:00 2001 From: olivia-brien Date: Sat, 17 Oct 2026 16:24:03 +0000 Subject: [PATCH 3/4] feat(#886): implement invoice charity donation split Add a standalone charity-split contract. The admin maintains a registry of vetted charities; creators open invoices with a recipient split, a chosen active charity and a donation share (1-5000 bps). When fully funded, the invoice auto-releases: each recipient gets their gross amount minus the donation share, and the charity receives the deductions plus rounding dust. Payers can send extra donations straight to the charity, creators can cancel open invoices, and payers then claim refunds. Per-charity and per-donor totals are tracked; preview_split shows the outcome ahead of release. All state changes emit `charity` events. Closes #886 Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- contracts/charity-split/Cargo.toml | 16 + contracts/charity-split/src/errors.rs | 47 +++ contracts/charity-split/src/lib.rs | 453 ++++++++++++++++++++++++++ contracts/charity-split/src/test.rs | 445 +++++++++++++++++++++++++ contracts/charity-split/src/types.rs | 57 ++++ 6 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 contracts/charity-split/Cargo.toml create mode 100644 contracts/charity-split/src/errors.rs create mode 100644 contracts/charity-split/src/lib.rs create mode 100644 contracts/charity-split/src/test.rs create mode 100644 contracts/charity-split/src/types.rs diff --git a/Cargo.toml b/Cargo.toml index 749e36b..116f2d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry", "contracts/mentorship", "contracts/skill-verification"] +members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry", "contracts/mentorship", "contracts/skill-verification", "contracts/charity-split"] resolver = "2" [workspace.dependencies] diff --git a/contracts/charity-split/Cargo.toml b/contracts/charity-split/Cargo.toml new file mode 100644 index 0000000..d7e55f1 --- /dev/null +++ b/contracts/charity-split/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "charity-split" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/charity-split/src/errors.rs b/contracts/charity-split/src/errors.rs new file mode 100644 index 0000000..4ed4549 --- /dev/null +++ b/contracts/charity-split/src/errors.rs @@ -0,0 +1,47 @@ +//! Error variants for the charity-split contract. +//! +//! Discriminants are stable — never reorder, only append. + +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// Contract has already been initialized. + AlreadyInitialized = 1, + /// Contract has not been initialized yet. + NotInitialized = 2, + /// Caller is not the contract admin. + NotAdmin = 3, + /// The charity is already registered. + CharityAlreadyRegistered = 4, + /// No charity is registered at this address. + CharityNotFound = 5, + /// The charity has been deactivated by the admin. + CharityInactive = 6, + /// Donation share must be between `MIN_DONATION_BPS` and `MAX_DONATION_BPS`. + InvalidDonationBps = 7, + /// Recipient and amount lists are empty, mismatched or too long. + InvalidRecipients = 8, + /// Amount must be strictly positive. + InvalidAmount = 9, + /// Recipient amounts do not sum to the invoice total. + TotalMismatch = 10, + /// Invoice ID was not found in storage. + InvoiceNotFound = 11, + /// The invoice is not open for this operation. + InvoiceNotOpen = 12, + /// The payment would exceed the invoice total. + OverFunded = 13, + /// Caller is not the invoice creator. + NotCreator = 14, + /// The invoice is not cancelled, so refunds are unavailable. + NotCancelled = 15, + /// The payer has nothing to refund. + NothingToRefund = 16, + /// The charity cannot also be a split recipient. + CharityIsRecipient = 17, + /// Arithmetic overflow. + Overflow = 18, +} diff --git a/contracts/charity-split/src/lib.rs b/contracts/charity-split/src/lib.rs new file mode 100644 index 0000000..ae13b60 --- /dev/null +++ b/contracts/charity-split/src/lib.rs @@ -0,0 +1,453 @@ +//! StellarSplit Charity Split — invoices that donate a share of every payout. +//! +//! The admin maintains a registry of vetted charities. A creator opens an +//! invoice with a recipient split and picks an active charity plus a +//! donation share in basis points. Payers fund the invoice in the invoice +//! token; once fully funded it releases automatically: each recipient gets +//! their gross amount minus the donation share, and the charity receives +//! the sum of the deductions (including rounding dust). Payers can also +//! send optional extra donations straight to the charity. +//! +//! Creators may cancel an open invoice, after which payers reclaim their +//! contributions. Every state change emits an event under the `charity` +//! topic, and per-charity / per-donor totals are tracked on-chain. + +#![no_std] + +mod errors; +mod types; + +pub use errors::Error; +pub use types::{Charity, CharityInvoice, CharityInvoiceStatus, DonationSplit}; + +use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BPS_DENOMINATOR: i128 = 10_000; +/// Smallest donation share an invoice may configure (0.01%). +pub const MIN_DONATION_BPS: u32 = 1; +/// Largest donation share an invoice may configure (50%). +pub const MAX_DONATION_BPS: u32 = 5_000; +pub const MAX_RECIPIENTS: u32 = 20; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn admin_key() -> Symbol { + symbol_short!("admin") +} + +fn counter_key() -> Symbol { + symbol_short!("inv_ctr") +} + +fn charity_key(charity: &Address) -> (Symbol, Address) { + (symbol_short!("charity"), charity.clone()) +} + +fn invoice_key(id: u64) -> (Symbol, u64) { + (symbol_short!("invoice"), id) +} + +fn contribution_key(id: u64, payer: &Address) -> (Symbol, u64, Address) { + (symbol_short!("contrib"), id, payer.clone()) +} + +fn charity_total_key(charity: &Address, token: &Address) -> (Symbol, Address, Address) { + (symbol_short!("chr_tot"), charity.clone(), token.clone()) +} + +fn donor_total_key(donor: &Address, token: &Address) -> (Symbol, Address, Address) { + (symbol_short!("dnr_tot"), donor.clone(), token.clone()) +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +fn emit_charity_registered(env: &Env, charity: &Address, name: &Symbol) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("reg"), charity.clone()), + name.clone(), + ); +} + +fn emit_charity_status(env: &Env, charity: &Address, active: bool) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("status"), charity.clone()), + active, + ); +} + +fn emit_invoice_created(env: &Env, inv: &CharityInvoice) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("created"), inv.id), + (inv.creator.clone(), inv.total, inv.charity.clone(), inv.donation_bps), + ); +} + +fn emit_paid(env: &Env, id: u64, payer: &Address, amount: i128, funded: i128) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("paid"), id), + (payer.clone(), amount, funded), + ); +} + +fn emit_released(env: &Env, id: u64, total: i128, donation: i128) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("released"), id), + (total, donation), + ); +} + +fn emit_donated(env: &Env, id: u64, charity: &Address, donor: &Address, amount: i128) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("donated"), id), + (charity.clone(), donor.clone(), amount), + ); +} + +fn emit_cancelled(env: &Env, id: u64, funded: i128) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("cancelled"), id), + funded, + ); +} + +fn emit_refunded(env: &Env, id: u64, payer: &Address, amount: i128) { + env.events().publish( + (symbol_short!("charity"), symbol_short!("refunded"), id), + (payer.clone(), amount), + ); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> { + let stored: Address = env + .storage() + .instance() + .get(&admin_key()) + .ok_or(Error::NotInitialized)?; + if stored != *admin { + return Err(Error::NotAdmin); + } + admin.require_auth(); + Ok(()) +} + +fn load_charity(env: &Env, charity: &Address) -> Result { + env.storage() + .persistent() + .get(&charity_key(charity)) + .ok_or(Error::CharityNotFound) +} + +fn load_invoice(env: &Env, id: u64) -> Result { + env.storage() + .persistent() + .get(&invoice_key(id)) + .ok_or(Error::InvoiceNotFound) +} + +fn save_invoice(env: &Env, inv: &CharityInvoice) { + env.storage().persistent().set(&invoice_key(inv.id), inv); +} + +fn add_to_total(env: &Env, key: &K, amount: i128) -> Result<(), Error> +where + K: soroban_sdk::IntoVal, +{ + let current: i128 = env.storage().persistent().get(key).unwrap_or(0); + let next = current.checked_add(amount).ok_or(Error::Overflow)?; + env.storage().persistent().set(key, &next); + Ok(()) +} + +/// Deducts `donation_bps` from each recipient amount (rounding down) and +/// assigns the remainder of the total to the charity. +fn compute_split(env: &Env, inv: &CharityInvoice) -> Result { + let mut payouts = Vec::new(env); + let mut paid_out: i128 = 0; + for amount in inv.amounts.iter() { + let cut = amount + .checked_mul(inv.donation_bps as i128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR; + let net = amount - cut; + paid_out = paid_out.checked_add(net).ok_or(Error::Overflow)?; + payouts.push_back(net); + } + Ok(DonationSplit { + payouts, + donation: inv.total - paid_out, + }) +} + +fn release(env: &Env, inv: &mut CharityInvoice) -> Result<(), Error> { + let split = compute_split(env, inv)?; + let client = token::Client::new(env, &inv.token); + let this = env.current_contract_address(); + + for (recipient, payout) in inv.recipients.iter().zip(split.payouts.iter()) { + if payout > 0 { + client.transfer(&this, &recipient, &payout); + } + } + if split.donation > 0 { + client.transfer(&this, &inv.charity, &split.donation); + add_to_total(env, &charity_total_key(&inv.charity, &inv.token), split.donation)?; + } + + let mut charity = load_charity(env, &inv.charity)?; + charity.donation_count = charity.donation_count.saturating_add(1); + env.storage().persistent().set(&charity_key(&inv.charity), &charity); + + inv.donated = split.donation; + inv.status = CharityInvoiceStatus::Released; + emit_released(env, inv.id, inv.total, split.donation); + emit_donated(env, inv.id, &inv.charity, &inv.creator, split.donation); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct CharitySplitContract; + +#[contractimpl] +impl CharitySplitContract { + /// Initialise the contract with an admin who vets charities. + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&admin_key()) { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&admin_key(), &admin); + env.storage().instance().set(&counter_key(), &0u64); + Ok(()) + } + + /// Admin-only: add a charity to the registry. + pub fn register_charity(env: Env, admin: Address, charity: Address, name: Symbol) -> Result<(), Error> { + require_admin(&env, &admin)?; + let key = charity_key(&charity); + if env.storage().persistent().has(&key) { + return Err(Error::CharityAlreadyRegistered); + } + let record = Charity { + address: charity.clone(), + name: name.clone(), + active: true, + registered_at: env.ledger().timestamp(), + donation_count: 0, + }; + env.storage().persistent().set(&key, &record); + emit_charity_registered(&env, &charity, &name); + Ok(()) + } + + /// Admin-only: activate or deactivate a charity. Deactivated charities + /// cannot be chosen for new invoices; existing invoices still release. + pub fn set_charity_active(env: Env, admin: Address, charity: Address, active: bool) -> Result<(), Error> { + require_admin(&env, &admin)?; + let mut record = load_charity(&env, &charity)?; + record.active = active; + env.storage().persistent().set(&charity_key(&charity), &record); + emit_charity_status(&env, &charity, active); + Ok(()) + } + + /// Create a charity invoice. `amounts` are gross per-recipient amounts + /// that must sum to `total`; `donation_bps` of each is donated to + /// `charity` on release. Returns the invoice ID. + pub fn create_invoice( + env: Env, + creator: Address, + token: Address, + recipients: Vec
, + amounts: Vec, + charity: Address, + donation_bps: u32, + ) -> Result { + creator.require_auth(); + if !(MIN_DONATION_BPS..=MAX_DONATION_BPS).contains(&donation_bps) { + return Err(Error::InvalidDonationBps); + } + if recipients.is_empty() || recipients.len() != amounts.len() || recipients.len() > MAX_RECIPIENTS { + return Err(Error::InvalidRecipients); + } + if recipients.contains(&charity) { + return Err(Error::CharityIsRecipient); + } + let record = load_charity(&env, &charity)?; + if !record.active { + return Err(Error::CharityInactive); + } + let mut total: i128 = 0; + for amount in amounts.iter() { + if amount <= 0 { + return Err(Error::InvalidAmount); + } + total = total.checked_add(amount).ok_or(Error::Overflow)?; + } + + let id: u64 = env + .storage() + .instance() + .get(&counter_key()) + .ok_or(Error::NotInitialized)?; + let next = id.checked_add(1).ok_or(Error::Overflow)?; + env.storage().instance().set(&counter_key(), &next); + + let inv = CharityInvoice { + id, + creator, + token, + total, + funded: 0, + recipients, + amounts, + charity, + donation_bps, + donated: 0, + status: CharityInvoiceStatus::Open, + created_at: env.ledger().timestamp(), + }; + save_invoice(&env, &inv); + emit_invoice_created(&env, &inv); + Ok(id) + } + + /// Pay towards an open invoice. Releases automatically when fully funded. + /// Returns the funded amount after this payment. + pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128) -> Result { + payer.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut inv = load_invoice(&env, invoice_id)?; + if inv.status != CharityInvoiceStatus::Open { + return Err(Error::InvoiceNotOpen); + } + let funded = inv.funded.checked_add(amount).ok_or(Error::Overflow)?; + if funded > inv.total { + return Err(Error::OverFunded); + } + + token::Client::new(&env, &inv.token).transfer(&payer, &env.current_contract_address(), &amount); + add_to_total(&env, &contribution_key(invoice_id, &payer), amount)?; + inv.funded = funded; + emit_paid(&env, invoice_id, &payer, amount, funded); + + if inv.funded == inv.total { + release(&env, &mut inv)?; + } + save_invoice(&env, &inv); + Ok(funded) + } + + /// Send an optional extra donation straight to the invoice's charity. + pub fn donate_extra(env: Env, donor: Address, invoice_id: u64, amount: i128) -> Result<(), Error> { + donor.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let inv = load_invoice(&env, invoice_id)?; + if inv.status == CharityInvoiceStatus::Cancelled { + return Err(Error::InvoiceNotOpen); + } + token::Client::new(&env, &inv.token).transfer(&donor, &inv.charity, &amount); + add_to_total(&env, &charity_total_key(&inv.charity, &inv.token), amount)?; + add_to_total(&env, &donor_total_key(&donor, &inv.token), amount)?; + emit_donated(&env, invoice_id, &inv.charity, &donor, amount); + Ok(()) + } + + /// Creator cancels an open invoice; payers can then claim refunds. + pub fn cancel_invoice(env: Env, creator: Address, invoice_id: u64) -> Result<(), Error> { + creator.require_auth(); + let mut inv = load_invoice(&env, invoice_id)?; + if inv.creator != creator { + return Err(Error::NotCreator); + } + if inv.status != CharityInvoiceStatus::Open { + return Err(Error::InvoiceNotOpen); + } + inv.status = CharityInvoiceStatus::Cancelled; + save_invoice(&env, &inv); + emit_cancelled(&env, invoice_id, inv.funded); + Ok(()) + } + + /// Payer reclaims their contribution to a cancelled invoice. + pub fn claim_refund(env: Env, payer: Address, invoice_id: u64) -> Result { + payer.require_auth(); + let mut inv = load_invoice(&env, invoice_id)?; + if inv.status != CharityInvoiceStatus::Cancelled { + return Err(Error::NotCancelled); + } + let key = contribution_key(invoice_id, &payer); + let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if amount <= 0 { + return Err(Error::NothingToRefund); + } + env.storage().persistent().remove(&key); + inv.funded -= amount; + save_invoice(&env, &inv); + token::Client::new(&env, &inv.token).transfer(&env.current_contract_address(), &payer, &amount); + emit_refunded(&env, invoice_id, &payer, amount); + Ok(amount) + } + + // ----------------------------------------------------------------------- + // Views + // ----------------------------------------------------------------------- + + pub fn get_charity(env: Env, charity: Address) -> Option { + env.storage().persistent().get(&charity_key(&charity)) + } + + pub fn get_invoice(env: Env, invoice_id: u64) -> Option { + env.storage().persistent().get(&invoice_key(invoice_id)) + } + + /// Preview the net payouts and donation an invoice will produce on release. + pub fn preview_split(env: Env, invoice_id: u64) -> Result { + let inv = load_invoice(&env, invoice_id)?; + compute_split(&env, &inv) + } + + pub fn get_contribution(env: Env, invoice_id: u64, payer: Address) -> i128 { + env.storage() + .persistent() + .get(&contribution_key(invoice_id, &payer)) + .unwrap_or(0) + } + + /// Total donated to `charity` in `token` (split donations plus extras). + pub fn get_charity_total(env: Env, charity: Address, token: Address) -> i128 { + env.storage() + .persistent() + .get(&charity_total_key(&charity, &token)) + .unwrap_or(0) + } + + /// Total extra donations made by `donor` in `token`. + pub fn get_donor_total(env: Env, donor: Address, token: Address) -> i128 { + env.storage() + .persistent() + .get(&donor_total_key(&donor, &token)) + .unwrap_or(0) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/charity-split/src/test.rs b/contracts/charity-split/src/test.rs new file mode 100644 index 0000000..3234293 --- /dev/null +++ b/contracts/charity-split/src/test.rs @@ -0,0 +1,445 @@ +#![cfg(test)] + +extern crate std; + +use crate::{ + CharityInvoiceStatus, CharitySplitContract, CharitySplitContractClient, Error, MAX_DONATION_BPS, +}; +use soroban_sdk::testutils::{Address as _, Events as _}; +use soroban_sdk::{symbol_short, token, vec, Address, Env, Symbol, TryFromVal, Val, Vec}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +struct Setup { + env: Env, + client: CharitySplitContractClient<'static>, + admin: Address, + charity: Address, + token: Address, + creator: Address, + alice: Address, + bob: Address, + payer: Address, +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CharitySplitContract, ()); + let client = CharitySplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let charity = Address::generate(&env); + client.initialize(&admin); + client.register_charity(&admin, &charity, &symbol_short!("redcross")); + + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(token_admin).address(); + let payer = Address::generate(&env); + token::StellarAssetClient::new(&env, &token).mint(&payer, &1_000_000); + + Setup { + creator: Address::generate(&env), + alice: Address::generate(&env), + bob: Address::generate(&env), + env, + client, + admin, + charity, + token, + payer, + } +} + +/// Invoice of 6_000 + 4_000 to alice and bob, donating `bps` to the charity. +fn create_invoice(s: &Setup, bps: u32) -> u64 { + s.client.create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone(), s.bob.clone()], + &vec![&s.env, 6_000_i128, 4_000_i128], + &s.charity, + &bps, + ) +} + +fn balance(s: &Setup, who: &Address) -> i128 { + token::Client::new(&s.env, &s.token).balance(who) +} + +/// Returns true if an event with topics `(charity, action, ..)` was emitted. +fn has_event(env: &Env, action: Symbol) -> bool { + for event in env.events().all().events().iter() { + let soroban_sdk::xdr::ContractEventBody::V0(body) = &event.body; + let topics: &[soroban_sdk::xdr::ScVal] = body.topics.as_ref(); + if topics.len() < 2 { + continue; + } + let t0 = Val::try_from_val(env, &topics[0]).ok(); + let t1 = Val::try_from_val(env, &topics[1]).ok(); + if let (Some(t0), Some(t1)) = (t0, t1) { + let s0 = Symbol::try_from_val(env, &t0).ok(); + let s1 = Symbol::try_from_val(env, &t1).ok(); + if s0 == Some(symbol_short!("charity")) && s1 == Some(action.clone()) { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Charity registry +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_twice_fails() { + let s = setup(); + assert_eq!(s.client.try_initialize(&s.admin), Err(Ok(Error::AlreadyInitialized))); +} + +#[test] +fn test_register_charity() { + let s = setup(); + let c = Address::generate(&s.env); + s.client.register_charity(&s.admin, &c, &symbol_short!("unicef")); + assert!(has_event(&s.env, symbol_short!("reg"))); + let record = s.client.get_charity(&c).unwrap(); + assert_eq!(record.name, symbol_short!("unicef")); + assert!(record.active); + assert_eq!(record.donation_count, 0); +} + +#[test] +fn test_register_charity_twice_fails() { + let s = setup(); + assert_eq!( + s.client.try_register_charity(&s.admin, &s.charity, &symbol_short!("dup")), + Err(Ok(Error::CharityAlreadyRegistered)) + ); +} + +#[test] +fn test_non_admin_cannot_register_charity() { + let s = setup(); + let c = Address::generate(&s.env); + assert_eq!( + s.client.try_register_charity(&s.creator, &c, &symbol_short!("x")), + Err(Ok(Error::NotAdmin)) + ); +} + +#[test] +fn test_deactivated_charity_rejected_for_new_invoice() { + let s = setup(); + s.client.set_charity_active(&s.admin, &s.charity, &false); + assert!(has_event(&s.env, symbol_short!("status"))); + assert_eq!( + s.client.try_create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone()], + &vec![&s.env, 1_000_i128], + &s.charity, + &500, + ), + Err(Ok(Error::CharityInactive)) + ); +} + +// --------------------------------------------------------------------------- +// Invoice creation +// --------------------------------------------------------------------------- + +#[test] +fn test_create_invoice_success() { + let s = setup(); + let id = create_invoice(&s, 1_000); + assert!(has_event(&s.env, symbol_short!("created"))); + let inv = s.client.get_invoice(&id).unwrap(); + assert_eq!(inv.total, 10_000); + assert_eq!(inv.funded, 0); + assert_eq!(inv.donation_bps, 1_000); + assert_eq!(inv.status, CharityInvoiceStatus::Open); +} + +#[test] +fn test_create_invoice_invalid_bps_fails() { + let s = setup(); + assert_eq!( + s.client.try_create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone()], + &vec![&s.env, 1_000_i128], + &s.charity, + &0, + ), + Err(Ok(Error::InvalidDonationBps)) + ); + assert_eq!( + s.client.try_create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone()], + &vec![&s.env, 1_000_i128], + &s.charity, + &(MAX_DONATION_BPS + 1), + ), + Err(Ok(Error::InvalidDonationBps)) + ); +} + +#[test] +fn test_create_invoice_mismatched_lists_fails() { + let s = setup(); + assert_eq!( + s.client.try_create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone(), s.bob.clone()], + &vec![&s.env, 1_000_i128], + &s.charity, + &500, + ), + Err(Ok(Error::InvalidRecipients)) + ); + let empty_r: Vec
= Vec::new(&s.env); + let empty_a: Vec = Vec::new(&s.env); + assert_eq!( + s.client.try_create_invoice(&s.creator, &s.token, &empty_r, &empty_a, &s.charity, &500), + Err(Ok(Error::InvalidRecipients)) + ); +} + +#[test] +fn test_create_invoice_non_positive_amount_fails() { + let s = setup(); + assert_eq!( + s.client.try_create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone()], + &vec![&s.env, 0_i128], + &s.charity, + &500, + ), + Err(Ok(Error::InvalidAmount)) + ); +} + +#[test] +fn test_charity_as_recipient_fails() { + let s = setup(); + assert_eq!( + s.client.try_create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.charity.clone()], + &vec![&s.env, 1_000_i128], + &s.charity, + &500, + ), + Err(Ok(Error::CharityIsRecipient)) + ); +} + +#[test] +fn test_unknown_charity_fails() { + let s = setup(); + let unknown = Address::generate(&s.env); + assert_eq!( + s.client.try_create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone()], + &vec![&s.env, 1_000_i128], + &unknown, + &500, + ), + Err(Ok(Error::CharityNotFound)) + ); +} + +// --------------------------------------------------------------------------- +// Payments & release +// --------------------------------------------------------------------------- + +#[test] +fn test_preview_split() { + let s = setup(); + let id = create_invoice(&s, 1_000); + let split = s.client.preview_split(&id); + assert_eq!(split.payouts, vec![&s.env, 5_400_i128, 3_600_i128]); + assert_eq!(split.donation, 1_000); +} + +#[test] +fn test_preview_split_rounding_goes_to_charity() { + let s = setup(); + let id = s.client.create_invoice( + &s.creator, + &s.token, + &vec![&s.env, s.alice.clone(), s.bob.clone()], + &vec![&s.env, 333_i128, 333_i128], + &s.charity, + &250, + ); + let split = s.client.preview_split(&id); + // 333 * 2.5% = 8.325 → 8 deducted each; net 325 each. + assert_eq!(split.payouts, vec![&s.env, 325_i128, 325_i128]); + assert_eq!(split.donation, 16); +} + +#[test] +fn test_partial_payment_holds_funds() { + let s = setup(); + let id = create_invoice(&s, 1_000); + let funded = s.client.pay(&s.payer, &id, &4_000); + assert_eq!(funded, 4_000); + assert!(has_event(&s.env, symbol_short!("paid"))); + assert_eq!(s.client.get_invoice(&id).unwrap().status, CharityInvoiceStatus::Open); + assert_eq!(s.client.get_contribution(&id, &s.payer), 4_000); + assert_eq!(balance(&s, &s.alice), 0); +} + +#[test] +fn test_full_payment_releases_with_donation() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.pay(&s.payer, &id, &4_000); + s.client.pay(&s.payer, &id, &6_000); + assert!(has_event(&s.env, symbol_short!("released"))); + assert!(has_event(&s.env, symbol_short!("donated"))); + + assert_eq!(balance(&s, &s.alice), 5_400); + assert_eq!(balance(&s, &s.bob), 3_600); + assert_eq!(balance(&s, &s.charity), 1_000); + + let inv = s.client.get_invoice(&id).unwrap(); + assert_eq!(inv.status, CharityInvoiceStatus::Released); + assert_eq!(inv.donated, 1_000); + assert_eq!(s.client.get_charity_total(&s.charity, &s.token), 1_000); + assert_eq!(s.client.get_charity(&s.charity).unwrap().donation_count, 1); +} + +#[test] +fn test_overpayment_fails() { + let s = setup(); + let id = create_invoice(&s, 1_000); + assert_eq!(s.client.try_pay(&s.payer, &id, &10_001), Err(Ok(Error::OverFunded))); +} + +#[test] +fn test_pay_released_invoice_fails() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.pay(&s.payer, &id, &10_000); + assert_eq!(s.client.try_pay(&s.payer, &id, &1), Err(Ok(Error::InvoiceNotOpen))); +} + +#[test] +fn test_pay_zero_fails() { + let s = setup(); + let id = create_invoice(&s, 1_000); + assert_eq!(s.client.try_pay(&s.payer, &id, &0), Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn test_pay_unknown_invoice_fails() { + let s = setup(); + assert_eq!(s.client.try_pay(&s.payer, &99, &1), Err(Ok(Error::InvoiceNotFound))); +} + +// --------------------------------------------------------------------------- +// Extra donations +// --------------------------------------------------------------------------- + +#[test] +fn test_donate_extra_goes_straight_to_charity() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.donate_extra(&s.payer, &id, &250); + assert!(has_event(&s.env, symbol_short!("donated"))); + assert_eq!(balance(&s, &s.charity), 250); + assert_eq!(s.client.get_donor_total(&s.payer, &s.token), 250); + assert_eq!(s.client.get_charity_total(&s.charity, &s.token), 250); + + s.client.pay(&s.payer, &id, &10_000); + assert_eq!(s.client.get_charity_total(&s.charity, &s.token), 1_250); +} + +#[test] +fn test_donate_extra_on_cancelled_fails() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.cancel_invoice(&s.creator, &id); + assert_eq!( + s.client.try_donate_extra(&s.payer, &id, &100), + Err(Ok(Error::InvoiceNotOpen)) + ); +} + +// --------------------------------------------------------------------------- +// Cancellation & refunds +// --------------------------------------------------------------------------- + +#[test] +fn test_cancel_and_refund() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.pay(&s.payer, &id, &3_000); + let before = balance(&s, &s.payer); + + s.client.cancel_invoice(&s.creator, &id); + assert!(has_event(&s.env, symbol_short!("cancelled"))); + assert_eq!(s.client.get_invoice(&id).unwrap().status, CharityInvoiceStatus::Cancelled); + + let refunded = s.client.claim_refund(&s.payer, &id); + assert!(has_event(&s.env, symbol_short!("refunded"))); + assert_eq!(refunded, 3_000); + assert_eq!(balance(&s, &s.payer), before + 3_000); + assert_eq!(s.client.get_contribution(&id, &s.payer), 0); + assert_eq!(s.client.get_invoice(&id).unwrap().funded, 0); +} + +#[test] +fn test_refund_twice_fails() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.pay(&s.payer, &id, &3_000); + s.client.cancel_invoice(&s.creator, &id); + s.client.claim_refund(&s.payer, &id); + assert_eq!( + s.client.try_claim_refund(&s.payer, &id), + Err(Ok(Error::NothingToRefund)) + ); +} + +#[test] +fn test_refund_open_invoice_fails() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.pay(&s.payer, &id, &3_000); + assert_eq!(s.client.try_claim_refund(&s.payer, &id), Err(Ok(Error::NotCancelled))); +} + +#[test] +fn test_non_creator_cannot_cancel() { + let s = setup(); + let id = create_invoice(&s, 1_000); + assert_eq!(s.client.try_cancel_invoice(&s.payer, &id), Err(Ok(Error::NotCreator))); +} + +#[test] +fn test_cannot_cancel_released_invoice() { + let s = setup(); + let id = create_invoice(&s, 1_000); + s.client.pay(&s.payer, &id, &10_000); + assert_eq!( + s.client.try_cancel_invoice(&s.creator, &id), + Err(Ok(Error::InvoiceNotOpen)) + ); +} diff --git a/contracts/charity-split/src/types.rs b/contracts/charity-split/src/types.rs new file mode 100644 index 0000000..61e23d2 --- /dev/null +++ b/contracts/charity-split/src/types.rs @@ -0,0 +1,57 @@ +//! Shared types for the charity-split contract. + +use soroban_sdk::{contracttype, Address, Symbol, Vec}; + +/// An admin-vetted charity that invoices may donate to. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Charity { + pub address: Address, + pub name: Symbol, + pub active: bool, + pub registered_at: u64, + /// Number of invoices that have released a donation to this charity. + pub donation_count: u32, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CharityInvoiceStatus { + /// Accepting payments. + Open, + /// Fully funded and distributed to recipients and the charity. + Released, + /// Cancelled by the creator; payers may claim refunds. + Cancelled, +} + +/// An invoice whose payout donates a fixed share to a charity. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CharityInvoice { + pub id: u64, + pub creator: Address, + pub token: Address, + pub total: i128, + pub funded: i128, + pub recipients: Vec
, + /// Gross amounts per recipient, before the donation is deducted. + pub amounts: Vec, + pub charity: Address, + /// Share of every recipient's gross amount donated to the charity. + pub donation_bps: u32, + /// Amount sent to the charity at release (0 until released). + pub donated: i128, + pub status: CharityInvoiceStatus, + pub created_at: u64, +} + +/// Result of applying the donation to an invoice's recipient amounts. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DonationSplit { + /// Net payout per recipient, in the same order as `recipients`. + pub payouts: Vec, + /// Amount going to the charity (absorbs rounding remainders). + pub donation: i128, +} From 6212b16d76226e17eb9c882cc5e7370ed0058563 Mon Sep 17 00:00:00 2001 From: olivia-brien Date: Sat, 17 Oct 2026 16:26:23 +0000 Subject: [PATCH 4/4] feat(#887): add creator tax optimization strategies Add a standalone tax-strategy contract that routes creator income at payment time: an optional pre-tax deduction (retirement/business bucket) lowers taxable income, a tax reserve is computed with a flat rate, creator-defined marginal brackets, or admin-published bracket templates, and the remainder goes to the creator. Brackets apply to cumulative income within a configurable tax period, which rolls over and archives the previous period's totals. estimate/estimate_config let creators compare strategies before switching. Funds are never custodied. All state changes emit events under the `tax` topic. Closes #887 Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- contracts/tax-strategy/Cargo.toml | 16 + contracts/tax-strategy/src/errors.rs | 35 +++ contracts/tax-strategy/src/lib.rs | 441 +++++++++++++++++++++++++++ contracts/tax-strategy/src/test.rs | 410 +++++++++++++++++++++++++ contracts/tax-strategy/src/types.rs | 80 +++++ 6 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 contracts/tax-strategy/Cargo.toml create mode 100644 contracts/tax-strategy/src/errors.rs create mode 100644 contracts/tax-strategy/src/lib.rs create mode 100644 contracts/tax-strategy/src/test.rs create mode 100644 contracts/tax-strategy/src/types.rs diff --git a/Cargo.toml b/Cargo.toml index 116f2d4..50aad84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry", "contracts/mentorship", "contracts/skill-verification", "contracts/charity-split"] +members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry", "contracts/mentorship", "contracts/skill-verification", "contracts/charity-split", "contracts/tax-strategy"] resolver = "2" [workspace.dependencies] diff --git a/contracts/tax-strategy/Cargo.toml b/contracts/tax-strategy/Cargo.toml new file mode 100644 index 0000000..142046b --- /dev/null +++ b/contracts/tax-strategy/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tax-strategy" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/tax-strategy/src/errors.rs b/contracts/tax-strategy/src/errors.rs new file mode 100644 index 0000000..3de6f44 --- /dev/null +++ b/contracts/tax-strategy/src/errors.rs @@ -0,0 +1,35 @@ +//! Error variants for the tax-strategy contract. +//! +//! Discriminants are stable — never reorder, only append. + +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// Contract has already been initialized. + AlreadyInitialized = 1, + /// Contract has not been initialized yet. + NotInitialized = 2, + /// Caller is not the contract admin. + NotAdmin = 3, + /// A rate exceeds `MAX_RATE_BPS`. + InvalidRate = 4, + /// Brackets are empty, too many, unsorted or do not start at zero. + InvalidBrackets = 5, + /// Deduction share exceeds `MAX_DEDUCTION_BPS`, or a non-zero share has no wallet. + InvalidDeduction = 6, + /// Period must be between `MIN_PERIOD_LEDGERS` and `MAX_PERIOD_LEDGERS`. + InvalidPeriod = 7, + /// The named template does not exist. + TemplateNotFound = 8, + /// A template strategy was configured without a template name. + MissingTemplate = 9, + /// The creator has no strategy configured. + StrategyNotFound = 10, + /// Amount must be strictly positive. + InvalidAmount = 11, + /// Arithmetic overflow. + Overflow = 12, +} diff --git a/contracts/tax-strategy/src/lib.rs b/contracts/tax-strategy/src/lib.rs new file mode 100644 index 0000000..68cdcdb --- /dev/null +++ b/contracts/tax-strategy/src/lib.rs @@ -0,0 +1,441 @@ +//! StellarSplit Tax Strategy — on-chain creator tax optimisation. +//! +//! Creators configure how incoming payments are split before they land in +//! their wallet: +//! +//! 1. **Deduction** — an optional pre-tax share routed to a deductible +//! bucket (retirement, business expenses, …). It lowers taxable income. +//! 2. **Tax reserve** — computed on the remaining taxable income using one +//! of three strategies: a flat rate, creator-defined marginal brackets, +//! or admin-published bracket templates (e.g. per jurisdiction). +//! Brackets are applied to cumulative income within a tax period, so +//! the reserve follows the marginal rate actually owed. +//! 3. **Net** — the remainder goes to the creator. +//! +//! Funds never sit in this contract: `process_income` pulls the gross +//! amount from the payer and pays all three destinations directly. +//! `estimate` and `estimate_config` let creators compare strategies before +//! committing to one. Every state change emits an event under the `tax` topic. + +#![no_std] + +mod errors; +mod types; + +pub use errors::Error; +pub use types::{IncomeBreakdown, PeriodSummary, StrategyConfig, StrategyKind, TaxBracket, TaxStrategy}; + +use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BPS_DENOMINATOR: i128 = 10_000; +/// Highest reserve rate any bracket or flat strategy may use (60%). +pub const MAX_RATE_BPS: u32 = 6_000; +/// Highest pre-tax deduction share (30%). +pub const MAX_DEDUCTION_BPS: u32 = 3_000; +pub const MAX_BRACKETS: u32 = 10; +/// Roughly one week at 5s ledgers. +pub const MIN_PERIOD_LEDGERS: u32 = 120_960; +/// Roughly two years at 5s ledgers. +pub const MAX_PERIOD_LEDGERS: u32 = 12_614_400; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn admin_key() -> Symbol { + symbol_short!("admin") +} + +fn template_key(name: &Symbol) -> (Symbol, Symbol) { + (symbol_short!("tmpl"), name.clone()) +} + +fn strategy_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("strategy"), creator.clone()) +} + +fn period_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("period"), creator.clone()) +} + +fn last_period_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("last_prd"), creator.clone()) +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +fn emit_template_set(env: &Env, name: &Symbol, brackets: u32) { + env.events().publish( + (symbol_short!("tax"), symbol_short!("tmpl_set"), name.clone()), + brackets, + ); +} + +fn emit_template_removed(env: &Env, name: &Symbol) { + env.events().publish( + (symbol_short!("tax"), symbol_short!("tmpl_del"), name.clone()), + (), + ); +} + +fn emit_strategy_set(env: &Env, creator: &Address, config: &StrategyConfig) { + env.events().publish( + (symbol_short!("tax"), symbol_short!("strategy"), creator.clone()), + (config.kind, config.deduction_bps, config.period_ledgers), + ); +} + +fn emit_strategy_removed(env: &Env, creator: &Address) { + env.events().publish( + (symbol_short!("tax"), symbol_short!("removed"), creator.clone()), + (), + ); +} + +fn emit_income(env: &Env, creator: &Address, token: &Address, b: &IncomeBreakdown) { + env.events().publish( + (symbol_short!("tax"), symbol_short!("income"), creator.clone()), + (token.clone(), b.gross, b.deducted, b.reserved, b.net), + ); +} + +fn emit_period_closed(env: &Env, creator: &Address, p: &PeriodSummary) { + env.events().publish( + (symbol_short!("tax"), symbol_short!("period"), creator.clone()), + (p.start_ledger, p.gross_income, p.deducted, p.reserved, p.net_paid), + ); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> { + let stored: Address = env + .storage() + .instance() + .get(&admin_key()) + .ok_or(Error::NotInitialized)?; + if stored != *admin { + return Err(Error::NotAdmin); + } + admin.require_auth(); + Ok(()) +} + +fn validate_brackets(brackets: &Vec) -> Result<(), Error> { + if brackets.is_empty() || brackets.len() > MAX_BRACKETS { + return Err(Error::InvalidBrackets); + } + let mut prev: Option = None; + for b in brackets.iter() { + if b.rate_bps > MAX_RATE_BPS { + return Err(Error::InvalidRate); + } + match prev { + None if b.threshold != 0 => return Err(Error::InvalidBrackets), + Some(p) if b.threshold <= p => return Err(Error::InvalidBrackets), + _ => {} + } + prev = Some(b.threshold); + } + Ok(()) +} + +fn validate_config(env: &Env, config: &StrategyConfig) -> Result<(), Error> { + match config.kind { + StrategyKind::Flat => { + if config.flat_bps > MAX_RATE_BPS { + return Err(Error::InvalidRate); + } + } + StrategyKind::Progressive => validate_brackets(&config.brackets)?, + StrategyKind::Template => { + let name = config.template.as_ref().ok_or(Error::MissingTemplate)?; + if !env.storage().persistent().has(&template_key(name)) { + return Err(Error::TemplateNotFound); + } + } + } + if config.deduction_bps > MAX_DEDUCTION_BPS + || (config.deduction_bps > 0 && config.deduction_wallet.is_none()) + { + return Err(Error::InvalidDeduction); + } + if !(MIN_PERIOD_LEDGERS..=MAX_PERIOD_LEDGERS).contains(&config.period_ledgers) { + return Err(Error::InvalidPeriod); + } + Ok(()) +} + +/// Marginal tax on income moving from `prior` to `prior + amount`. +fn marginal_tax(brackets: &Vec, prior: i128, amount: i128) -> Result { + let start = prior; + let end = prior.checked_add(amount).ok_or(Error::Overflow)?; + let mut tax: i128 = 0; + let len = brackets.len(); + for i in 0..len { + let b = brackets.get_unchecked(i); + let upper = if i + 1 < len { + brackets.get_unchecked(i + 1).threshold + } else { + i128::MAX + }; + let lo = start.max(b.threshold); + let hi = end.min(upper); + if hi > lo { + let slice_tax = (hi - lo) + .checked_mul(b.rate_bps as i128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR; + tax = tax.checked_add(slice_tax).ok_or(Error::Overflow)?; + } + } + Ok(tax) +} + +fn load_template(env: &Env, name: &Symbol) -> Result, Error> { + env.storage() + .persistent() + .get(&template_key(name)) + .ok_or(Error::TemplateNotFound) +} + +fn compute_breakdown( + env: &Env, + config: &StrategyConfig, + prior_taxable: i128, + gross: i128, +) -> Result { + if gross <= 0 { + return Err(Error::InvalidAmount); + } + let deducted = gross + .checked_mul(config.deduction_bps as i128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR; + let taxable = gross - deducted; + let reserved = match config.kind { + StrategyKind::Flat => { + taxable + .checked_mul(config.flat_bps as i128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR + } + StrategyKind::Progressive => marginal_tax(&config.brackets, prior_taxable, taxable)?, + StrategyKind::Template => { + let name = config.template.as_ref().ok_or(Error::MissingTemplate)?; + marginal_tax(&load_template(env, name)?, prior_taxable, taxable)? + } + }; + let net = taxable - reserved; + let effective_rate_bps = (reserved + .checked_mul(BPS_DENOMINATOR) + .ok_or(Error::Overflow)? + / gross) as u32; + Ok(IncomeBreakdown { + gross, + deducted, + taxable, + reserved, + net, + effective_rate_bps, + }) +} + +fn new_period(start_ledger: u32) -> PeriodSummary { + PeriodSummary { + start_ledger, + ..PeriodSummary::default() + } +} + +/// Loads the creator's current period, rolling it over (and archiving the +/// previous one) when `period_ledgers` have elapsed. +fn current_period(env: &Env, creator: &Address, period_ledgers: u32, roll: bool) -> PeriodSummary { + let now = env.ledger().sequence(); + match env.storage().persistent().get::<_, PeriodSummary>(&period_key(creator)) { + Some(p) if now < p.start_ledger.saturating_add(period_ledgers) => p, + Some(p) => { + if roll { + env.storage().persistent().set(&last_period_key(creator), &p); + emit_period_closed(env, creator, &p); + } + new_period(now) + } + None => new_period(now), + } +} + +fn load_strategy(env: &Env, creator: &Address) -> Result { + env.storage() + .persistent() + .get(&strategy_key(creator)) + .ok_or(Error::StrategyNotFound) +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct TaxStrategyContract; + +#[contractimpl] +impl TaxStrategyContract { + /// Initialise the contract with an admin who publishes bracket templates. + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&admin_key()) { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&admin_key(), &admin); + Ok(()) + } + + /// Admin-only: publish or replace a named bracket template. Creators on + /// `Template` strategies pick up changes on their next payment. + pub fn set_template(env: Env, admin: Address, name: Symbol, brackets: Vec) -> Result<(), Error> { + require_admin(&env, &admin)?; + validate_brackets(&brackets)?; + env.storage().persistent().set(&template_key(&name), &brackets); + emit_template_set(&env, &name, brackets.len()); + Ok(()) + } + + /// Admin-only: delete a template. Creators still pointing at it must + /// switch strategy before receiving further income. + pub fn remove_template(env: Env, admin: Address, name: Symbol) -> Result<(), Error> { + require_admin(&env, &admin)?; + let key = template_key(&name); + if !env.storage().persistent().has(&key) { + return Err(Error::TemplateNotFound); + } + env.storage().persistent().remove(&key); + emit_template_removed(&env, &name); + Ok(()) + } + + /// Creator sets or replaces their tax strategy. Current period progress is kept. + pub fn set_strategy(env: Env, creator: Address, config: StrategyConfig) -> Result<(), Error> { + creator.require_auth(); + if !env.storage().instance().has(&admin_key()) { + return Err(Error::NotInitialized); + } + validate_config(&env, &config)?; + let strategy = TaxStrategy { + creator: creator.clone(), + config: config.clone(), + updated_at: env.ledger().timestamp(), + }; + env.storage().persistent().set(&strategy_key(&creator), &strategy); + emit_strategy_set(&env, &creator, &config); + Ok(()) + } + + /// Creator removes their strategy; income will no longer be routed here. + pub fn remove_strategy(env: Env, creator: Address) -> Result<(), Error> { + creator.require_auth(); + let key = strategy_key(&creator); + if !env.storage().persistent().has(&key) { + return Err(Error::StrategyNotFound); + } + env.storage().persistent().remove(&key); + emit_strategy_removed(&env, &creator); + Ok(()) + } + + /// Route `gross` of `token` from `payer` to `creator` according to the + /// creator's strategy: deduction → deduction wallet, reserve → reserve + /// wallet, remainder → creator. Returns the breakdown. + pub fn process_income( + env: Env, + payer: Address, + creator: Address, + token: Address, + gross: i128, + ) -> Result { + payer.require_auth(); + let strategy = load_strategy(&env, &creator)?; + let config = &strategy.config; + let mut period = current_period(&env, &creator, config.period_ledgers, true); + let b = compute_breakdown(&env, config, period.taxable_income, gross)?; + + let client = token::Client::new(&env, &token); + if b.deducted > 0 { + if let Some(wallet) = &config.deduction_wallet { + client.transfer(&payer, wallet, &b.deducted); + } + } + if b.reserved > 0 { + client.transfer(&payer, &config.reserve_wallet, &b.reserved); + } + if b.net > 0 { + client.transfer(&payer, &creator, &b.net); + } + + period.gross_income = period.gross_income.checked_add(b.gross).ok_or(Error::Overflow)?; + period.taxable_income = period.taxable_income.checked_add(b.taxable).ok_or(Error::Overflow)?; + period.deducted = period.deducted.checked_add(b.deducted).ok_or(Error::Overflow)?; + period.reserved = period.reserved.checked_add(b.reserved).ok_or(Error::Overflow)?; + period.net_paid = period.net_paid.checked_add(b.net).ok_or(Error::Overflow)?; + env.storage().persistent().set(&period_key(&creator), &period); + + emit_income(&env, &creator, &token, &b); + Ok(b) + } + + // ----------------------------------------------------------------------- + // Views + // ----------------------------------------------------------------------- + + /// Breakdown `gross` would produce under the creator's current strategy, + /// taking current period progress into account. + pub fn estimate(env: Env, creator: Address, gross: i128) -> Result { + let strategy = load_strategy(&env, &creator)?; + let period = current_period(&env, &creator, strategy.config.period_ledgers, false); + compute_breakdown(&env, &strategy.config, period.taxable_income, gross) + } + + /// Breakdown `gross` would produce under a candidate `config`, using the + /// creator's current period progress. Lets creators compare strategies. + pub fn estimate_config( + env: Env, + creator: Address, + config: StrategyConfig, + gross: i128, + ) -> Result { + validate_config(&env, &config)?; + let period = current_period(&env, &creator, config.period_ledgers, false); + compute_breakdown(&env, &config, period.taxable_income, gross) + } + + pub fn get_strategy(env: Env, creator: Address) -> Option { + env.storage().persistent().get(&strategy_key(&creator)) + } + + pub fn get_template(env: Env, name: Symbol) -> Option> { + env.storage().persistent().get(&template_key(&name)) + } + + /// Totals for the creator's current period (zeroed if it has elapsed). + pub fn get_period(env: Env, creator: Address) -> PeriodSummary { + let period_ledgers = load_strategy(&env, &creator) + .map(|s| s.config.period_ledgers) + .unwrap_or(MAX_PERIOD_LEDGERS); + current_period(&env, &creator, period_ledgers, false) + } + + /// Totals for the creator's most recently closed period, if any. + pub fn get_last_period(env: Env, creator: Address) -> Option { + env.storage().persistent().get(&last_period_key(&creator)) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/tax-strategy/src/test.rs b/contracts/tax-strategy/src/test.rs new file mode 100644 index 0000000..ee1f7e3 --- /dev/null +++ b/contracts/tax-strategy/src/test.rs @@ -0,0 +1,410 @@ +#![cfg(test)] + +extern crate std; + +use crate::{ + Error, StrategyConfig, StrategyKind, TaxBracket, TaxStrategyContract, TaxStrategyContractClient, + MAX_DEDUCTION_BPS, MAX_RATE_BPS, MIN_PERIOD_LEDGERS, +}; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger}; +use soroban_sdk::{symbol_short, token, vec, Address, Env, Symbol, TryFromVal, Val, Vec}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +struct Setup { + env: Env, + client: TaxStrategyContractClient<'static>, + admin: Address, + creator: Address, + payer: Address, + reserve: Address, + savings: Address, + token: Address, +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(TaxStrategyContract, ()); + let client = TaxStrategyContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(token_admin).address(); + let payer = Address::generate(&env); + token::StellarAssetClient::new(&env, &token).mint(&payer, &1_000_000); + + Setup { + creator: Address::generate(&env), + reserve: Address::generate(&env), + savings: Address::generate(&env), + env, + client, + admin, + payer, + token, + } +} + +/// 0% up to 1_000, 10% from 1_000, 30% from 5_000. +fn brackets(env: &Env) -> Vec { + vec![ + env, + TaxBracket { threshold: 0, rate_bps: 0 }, + TaxBracket { threshold: 1_000, rate_bps: 1_000 }, + TaxBracket { threshold: 5_000, rate_bps: 3_000 }, + ] +} + +fn flat(s: &Setup, flat_bps: u32, deduction_bps: u32) -> StrategyConfig { + StrategyConfig { + kind: StrategyKind::Flat, + flat_bps, + brackets: Vec::new(&s.env), + template: None, + reserve_wallet: s.reserve.clone(), + deduction_bps, + deduction_wallet: if deduction_bps > 0 { Some(s.savings.clone()) } else { None }, + period_ledgers: MIN_PERIOD_LEDGERS, + } +} + +fn progressive(s: &Setup) -> StrategyConfig { + StrategyConfig { + kind: StrategyKind::Progressive, + flat_bps: 0, + brackets: brackets(&s.env), + template: None, + reserve_wallet: s.reserve.clone(), + deduction_bps: 0, + deduction_wallet: None, + period_ledgers: MIN_PERIOD_LEDGERS, + } +} + +fn template(s: &Setup, name: Symbol) -> StrategyConfig { + StrategyConfig { + kind: StrategyKind::Template, + flat_bps: 0, + brackets: Vec::new(&s.env), + template: Some(name), + reserve_wallet: s.reserve.clone(), + deduction_bps: 0, + deduction_wallet: None, + period_ledgers: MIN_PERIOD_LEDGERS, + } +} + +fn balance(s: &Setup, who: &Address) -> i128 { + token::Client::new(&s.env, &s.token).balance(who) +} + +/// Returns true if an event with topics `(tax, action, ..)` was emitted. +fn has_event(env: &Env, action: Symbol) -> bool { + for event in env.events().all().events().iter() { + let soroban_sdk::xdr::ContractEventBody::V0(body) = &event.body; + let topics: &[soroban_sdk::xdr::ScVal] = body.topics.as_ref(); + if topics.len() < 2 { + continue; + } + let t0 = Val::try_from_val(env, &topics[0]).ok(); + let t1 = Val::try_from_val(env, &topics[1]).ok(); + if let (Some(t0), Some(t1)) = (t0, t1) { + let s0 = Symbol::try_from_val(env, &t0).ok(); + let s1 = Symbol::try_from_val(env, &t1).ok(); + if s0 == Some(symbol_short!("tax")) && s1 == Some(action.clone()) { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Admin templates +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_twice_fails() { + let s = setup(); + assert_eq!(s.client.try_initialize(&s.admin), Err(Ok(Error::AlreadyInitialized))); +} + +#[test] +fn test_set_and_get_template() { + let s = setup(); + s.client.set_template(&s.admin, &symbol_short!("ng_2026"), &brackets(&s.env)); + assert!(has_event(&s.env, symbol_short!("tmpl_set"))); + assert_eq!(s.client.get_template(&symbol_short!("ng_2026")), Some(brackets(&s.env))); +} + +#[test] +fn test_non_admin_cannot_set_template() { + let s = setup(); + assert_eq!( + s.client.try_set_template(&s.creator, &symbol_short!("x"), &brackets(&s.env)), + Err(Ok(Error::NotAdmin)) + ); +} + +#[test] +fn test_template_brackets_must_start_at_zero() { + let s = setup(); + let bad = vec![&s.env, TaxBracket { threshold: 10, rate_bps: 100 }]; + assert_eq!( + s.client.try_set_template(&s.admin, &symbol_short!("x"), &bad), + Err(Ok(Error::InvalidBrackets)) + ); +} + +#[test] +fn test_template_brackets_must_ascend() { + let s = setup(); + let bad = vec![ + &s.env, + TaxBracket { threshold: 0, rate_bps: 100 }, + TaxBracket { threshold: 500, rate_bps: 200 }, + TaxBracket { threshold: 500, rate_bps: 300 }, + ]; + assert_eq!( + s.client.try_set_template(&s.admin, &symbol_short!("x"), &bad), + Err(Ok(Error::InvalidBrackets)) + ); +} + +#[test] +fn test_template_rate_too_high_fails() { + let s = setup(); + let bad = vec![&s.env, TaxBracket { threshold: 0, rate_bps: MAX_RATE_BPS + 1 }]; + assert_eq!( + s.client.try_set_template(&s.admin, &symbol_short!("x"), &bad), + Err(Ok(Error::InvalidRate)) + ); +} + +#[test] +fn test_remove_template() { + let s = setup(); + s.client.set_template(&s.admin, &symbol_short!("ng_2026"), &brackets(&s.env)); + s.client.remove_template(&s.admin, &symbol_short!("ng_2026")); + assert!(has_event(&s.env, symbol_short!("tmpl_del"))); + assert!(s.client.get_template(&symbol_short!("ng_2026")).is_none()); + assert_eq!( + s.client.try_remove_template(&s.admin, &symbol_short!("ng_2026")), + Err(Ok(Error::TemplateNotFound)) + ); +} + +// --------------------------------------------------------------------------- +// Strategy configuration +// --------------------------------------------------------------------------- + +#[test] +fn test_set_strategy() { + let s = setup(); + s.client.set_strategy(&s.creator, &flat(&s, 2_000, 0)); + assert!(has_event(&s.env, symbol_short!("strategy"))); + let stored = s.client.get_strategy(&s.creator).unwrap(); + assert_eq!(stored.config.kind, StrategyKind::Flat); + assert_eq!(stored.config.flat_bps, 2_000); +} + +#[test] +fn test_flat_rate_too_high_fails() { + let s = setup(); + assert_eq!( + s.client.try_set_strategy(&s.creator, &flat(&s, MAX_RATE_BPS + 1, 0)), + Err(Ok(Error::InvalidRate)) + ); +} + +#[test] +fn test_deduction_too_high_fails() { + let s = setup(); + assert_eq!( + s.client.try_set_strategy(&s.creator, &flat(&s, 1_000, MAX_DEDUCTION_BPS + 1)), + Err(Ok(Error::InvalidDeduction)) + ); +} + +#[test] +fn test_deduction_without_wallet_fails() { + let s = setup(); + let mut cfg = flat(&s, 1_000, 500); + cfg.deduction_wallet = None; + assert_eq!( + s.client.try_set_strategy(&s.creator, &cfg), + Err(Ok(Error::InvalidDeduction)) + ); +} + +#[test] +fn test_invalid_period_fails() { + let s = setup(); + let mut cfg = flat(&s, 1_000, 0); + cfg.period_ledgers = MIN_PERIOD_LEDGERS - 1; + assert_eq!(s.client.try_set_strategy(&s.creator, &cfg), Err(Ok(Error::InvalidPeriod))); +} + +#[test] +fn test_template_strategy_requires_existing_template() { + let s = setup(); + assert_eq!( + s.client.try_set_strategy(&s.creator, &template(&s, symbol_short!("nope"))), + Err(Ok(Error::TemplateNotFound)) + ); + let mut cfg = template(&s, symbol_short!("nope")); + cfg.template = None; + assert_eq!(s.client.try_set_strategy(&s.creator, &cfg), Err(Ok(Error::MissingTemplate))); +} + +#[test] +fn test_remove_strategy() { + let s = setup(); + s.client.set_strategy(&s.creator, &flat(&s, 1_000, 0)); + s.client.remove_strategy(&s.creator); + assert!(has_event(&s.env, symbol_short!("removed"))); + assert!(s.client.get_strategy(&s.creator).is_none()); + assert_eq!( + s.client.try_process_income(&s.payer, &s.creator, &s.token, &1_000), + Err(Ok(Error::StrategyNotFound)) + ); +} + +// --------------------------------------------------------------------------- +// Income routing +// --------------------------------------------------------------------------- + +#[test] +fn test_flat_strategy_with_deduction() { + let s = setup(); + s.client.set_strategy(&s.creator, &flat(&s, 2_000, 1_000)); + let b = s.client.process_income(&s.payer, &s.creator, &s.token, &2_000); + assert!(has_event(&s.env, symbol_short!("income"))); + + assert_eq!(b.deducted, 200); + assert_eq!(b.taxable, 1_800); + assert_eq!(b.reserved, 360); + assert_eq!(b.net, 1_440); + assert_eq!(b.effective_rate_bps, 1_800); + + assert_eq!(balance(&s, &s.savings), 200); + assert_eq!(balance(&s, &s.reserve), 360); + assert_eq!(balance(&s, &s.creator), 1_440); +} + +#[test] +fn test_deduction_lowers_reserve_versus_no_deduction() { + let s = setup(); + s.client.set_strategy(&s.creator, &flat(&s, 2_000, 0)); + let without = s.client.estimate(&s.creator, &10_000); + let with = s.client.estimate_config(&s.creator, &flat(&s, 2_000, 2_000), &10_000); + assert_eq!(without.reserved, 2_000); + assert_eq!(with.reserved, 1_600); + assert!(with.effective_rate_bps < without.effective_rate_bps); +} + +#[test] +fn test_progressive_brackets_track_period_income() { + let s = setup(); + s.client.set_strategy(&s.creator, &progressive(&s)); + + // 0 → 2_000: 1_000 at 0% + 1_000 at 10%. + let first = s.client.process_income(&s.payer, &s.creator, &s.token, &2_000); + assert_eq!(first.reserved, 100); + + // 2_000 → 6_000: 3_000 at 10% + 1_000 at 30%. + let second = s.client.process_income(&s.payer, &s.creator, &s.token, &4_000); + assert_eq!(second.reserved, 600); + + let period = s.client.get_period(&s.creator); + assert_eq!(period.gross_income, 6_000); + assert_eq!(period.taxable_income, 6_000); + assert_eq!(period.reserved, 700); + assert_eq!(period.net_paid, 5_300); + assert_eq!(balance(&s, &s.reserve), 700); + assert_eq!(balance(&s, &s.creator), 5_300); +} + +#[test] +fn test_template_strategy_follows_template_updates() { + let s = setup(); + s.client.set_template(&s.admin, &symbol_short!("ng_2026"), &brackets(&s.env)); + s.client.set_strategy(&s.creator, &template(&s, symbol_short!("ng_2026"))); + assert_eq!(s.client.estimate(&s.creator, &2_000).reserved, 100); + + let flat_10 = vec![&s.env, TaxBracket { threshold: 0, rate_bps: 1_000 }]; + s.client.set_template(&s.admin, &symbol_short!("ng_2026"), &flat_10); + assert_eq!(s.client.estimate(&s.creator, &2_000).reserved, 200); +} + +#[test] +fn test_removed_template_blocks_income() { + let s = setup(); + s.client.set_template(&s.admin, &symbol_short!("ng_2026"), &brackets(&s.env)); + s.client.set_strategy(&s.creator, &template(&s, symbol_short!("ng_2026"))); + s.client.remove_template(&s.admin, &symbol_short!("ng_2026")); + assert_eq!( + s.client.try_process_income(&s.payer, &s.creator, &s.token, &1_000), + Err(Ok(Error::TemplateNotFound)) + ); +} + +#[test] +fn test_zero_income_fails() { + let s = setup(); + s.client.set_strategy(&s.creator, &flat(&s, 1_000, 0)); + assert_eq!( + s.client.try_process_income(&s.payer, &s.creator, &s.token, &0), + Err(Ok(Error::InvalidAmount)) + ); +} + +#[test] +fn test_period_rollover_resets_brackets() { + let s = setup(); + s.client.set_strategy(&s.creator, &progressive(&s)); + s.client.process_income(&s.payer, &s.creator, &s.token, &6_000); + assert_eq!(s.client.get_period(&s.creator).reserved, 700); + + let start = s.client.get_period(&s.creator).start_ledger; + s.env + .ledger() + .with_mut(|l| l.sequence_number = start + MIN_PERIOD_LEDGERS); + + // Before any new income the view already reports a fresh period. + assert_eq!(s.client.get_period(&s.creator).gross_income, 0); + + // New period: brackets restart from zero. + let b = s.client.process_income(&s.payer, &s.creator, &s.token, &2_000); + assert!(has_event(&s.env, symbol_short!("period"))); + assert_eq!(b.reserved, 100); + + let last = s.client.get_last_period(&s.creator).unwrap(); + assert_eq!(last.gross_income, 6_000); + assert_eq!(last.reserved, 700); + assert_eq!(s.client.get_period(&s.creator).gross_income, 2_000); +} + +#[test] +fn test_estimate_does_not_move_funds_or_state() { + let s = setup(); + s.client.set_strategy(&s.creator, &progressive(&s)); + let est = s.client.estimate(&s.creator, &6_000); + assert_eq!(est.reserved, 700); + assert_eq!(s.client.get_period(&s.creator).gross_income, 0); + assert_eq!(balance(&s, &s.reserve), 0); +} + +#[test] +fn test_compare_flat_vs_progressive() { + let s = setup(); + s.client.set_strategy(&s.creator, &flat(&s, 2_000, 0)); + let flat_est = s.client.estimate(&s.creator, &3_000); + let prog_est = s.client.estimate_config(&s.creator, &progressive(&s), &3_000); + assert_eq!(flat_est.reserved, 600); + assert_eq!(prog_est.reserved, 200); +} diff --git a/contracts/tax-strategy/src/types.rs b/contracts/tax-strategy/src/types.rs new file mode 100644 index 0000000..c6419d4 --- /dev/null +++ b/contracts/tax-strategy/src/types.rs @@ -0,0 +1,80 @@ +//! Shared types for the tax-strategy contract. + +use soroban_sdk::{contracttype, Address, Symbol, Vec}; + +/// How the tax reserve is computed on taxable income. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StrategyKind { + /// A single flat rate on all taxable income. + Flat, + /// Creator-defined marginal brackets on cumulative period income. + Progressive, + /// Marginal brackets from an admin-published template (e.g. a jurisdiction). + Template, +} + +/// A marginal bracket: `rate_bps` applies to period income at or above +/// `threshold`, up to the next bracket's threshold. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaxBracket { + pub threshold: i128, + pub rate_bps: u32, +} + +/// Creator-supplied configuration for `set_strategy`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StrategyConfig { + pub kind: StrategyKind, + /// Rate for `Flat` strategies; ignored otherwise. + pub flat_bps: u32, + /// Brackets for `Progressive` strategies; ignored otherwise. + pub brackets: Vec, + /// Template name for `Template` strategies; ignored otherwise. + pub template: Option, + /// Wallet that receives the tax reserve. + pub reserve_wallet: Address, + /// Pre-tax share routed to a deductible bucket (retirement, business + /// expenses, …) before the tax reserve is computed. + pub deduction_bps: u32, + pub deduction_wallet: Option
, + /// Length of a tax period in ledgers; bracket progress resets each period. + pub period_ledgers: u32, +} + +/// Stored strategy for a creator. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaxStrategy { + pub creator: Address, + pub config: StrategyConfig, + pub updated_at: u64, +} + +/// Running totals for the creator's current tax period. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PeriodSummary { + pub start_ledger: u32, + pub gross_income: i128, + /// Taxable income (gross minus deductions) — drives bracket progress. + pub taxable_income: i128, + pub deducted: i128, + pub reserved: i128, + pub net_paid: i128, +} + +/// How a single income payment is split. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IncomeBreakdown { + pub gross: i128, + pub deducted: i128, + pub taxable: i128, + pub reserved: i128, + pub net: i128, + /// Effective reserve rate on the gross amount, in bps. + pub effective_rate_bps: u32, +}