diff --git a/Cargo.toml b/Cargo.toml index 1581ae0..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"] +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/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, +} 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, +} 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, +} 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, +}