diff --git a/.gitignore b/.gitignore index 5b8a8e8..d7443ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,29 @@ -# Build artifacts -target/ -*.wasm - -# Cargo lock (workspace-level managed separately; keep for reproducibility) -# Cargo.lock ← intentionally tracked for binary crates - -# Test snapshots (soroban / insta) -**/*.snap -**/*.snap.new -snapshots/ -.snapshots/ -test_snapshots/ -**/test_snapshots/ - -# Environment / secrets # ============================================================ -# Build output +# Build artifacts # ============================================================ target/ *.wasm wasm32-unknown-unknown/ **/wasm32-unknown-unknown/ +**/*.rs.bk +**/*.profraw +**/*.profdata +Cargo.lock.bak + +# ============================================================ +# Test snapshots and generated test artefacts +# ============================================================ +**/__snapshots__/ +**/test_snapshots/ +tests/snapshots/ +tests/fixtures/ +*.snap +*.snap.new +*.snap.orig +*.snap.bak +*.snapshot +snapshots/ +.snapshots/ # ============================================================ # Environment / secrets @@ -29,25 +32,6 @@ wasm32-unknown-unknown/ .env.local .env.*.local -# OS artifacts -.DS_Store -Thumbs.db -desktop.ini - -# Editor / IDE -.vscode/ -.idea/ -*.iml -*.swp -*.swo -*~ - -# Stellar CLI artifacts -.stellar/ - -# Misc -*.bak -*.orig # ============================================================ # OS / editor artefacts # ============================================================ @@ -56,24 +40,13 @@ Thumbs.db desktop.ini .vscode/ .idea/ +*.iml *.swp *.swo *~ *.sublime-project *.sublime-workspace -# ============================================================ -# Test snapshots and generated test artefacts -# ============================================================ -**/__snapshots__/ -**/test_snapshots/ -*.snap -*.snap.orig -*.snapshot -*.snap.bak -tests/snapshots/ -tests/fixtures/ - # ============================================================ # Stellar CLI # ============================================================ @@ -123,16 +96,20 @@ dist/ .cache/ # ============================================================ -# Rust / Cargo artefacts not caught by target/ +# Cargo audit / deny cache # ============================================================ -**/*.rs.bk -Cargo.lock.bak -**/*.profraw -**/*.profdata +.cargo-ok +cargo-audit.toml # ============================================================ -# Fuzz corpus (generated during fuzzing runs — seed files are -# committed via explicit !fuzz/corpus//seed_* rules) +# Python / misc tooling +# ============================================================ +__pycache__/ +*.pyc +.pytest_cache/ + +# ============================================================ +# Fuzz corpus — seed files are explicitly re-included below # ============================================================ fuzz/corpus/* !fuzz/corpus/fuzz_sequence/seed_zeros @@ -155,16 +132,3 @@ fuzz/corpus/* !fuzz/corpus/fuzz_release/seed_empty !fuzz/corpus/fuzz_release/seed_mixed !fuzz/corpus/fuzz_release/seed_ones - -# ============================================================ -# Cargo audit / deny cache -# ============================================================ -.cargo-ok -cargo-audit.toml - -# ============================================================ -# Python / misc tooling -# ============================================================ -__pycache__/ -*.pyc -.pytest_cache/ diff --git a/contracts/split/src/arbitration_tournament_ext.rs b/contracts/split/src/arbitration_tournament_ext.rs new file mode 100644 index 0000000..decf1da --- /dev/null +++ b/contracts/split/src/arbitration_tournament_ext.rs @@ -0,0 +1,673 @@ +//! # Issue #881 – Invoice Dispute Arbitration Tournament +//! +//! Introduces a tournament bracket for resolving multi-party invoice disputes. +//! Instead of a single designated arbiter, a pool of arbiters votes in rounds; +//! the majority decision in each round advances the tournament until a final +//! resolution is reached. +//! +//! ## Lifecycle +//! +//! 1. **`open_tournament`** – Admin or invoice creator opens a tournament for a +//! disputed invoice. A minimum of 3 arbiters must be registered; the arbiter +//! pool is supplied at open time. +//! 2. **`cast_tournament_vote`** – Each registered arbiter casts exactly one +//! vote per round (`Release` or `Refund`). When all arbiters have voted the +//! round closes automatically and a winner is tallied. +//! 3. **`advance_tournament`** – Callable by anyone once a round is resolved. +//! If the tournament has reached its final round the invoice is resolved +//! (release or refund) and the tournament is marked `Completed`. +//! 4. **`get_tournament`** – Returns the current `ArbitrationTournament` state. +//! 5. **`cancel_tournament`** – Admin can cancel an in-progress tournament, +//! returning the invoice to `Disputed` status for manual resolution. +//! +//! ## Storage +//! +//! | Key | Tier | Type | +//! |-----|------|------| +//! | `InvoiceKey::ArbitrationTournament(invoice_id)` | Persistent | `ArbitrationTournament` | +//! | `CompoundKey::TournamentVote(invoice_id, arbiter)` | Persistent | `TournamentVote` | +//! +//! ## Events +//! +//! | Action symbol | Topics | Data | +//! |---------------|--------|------| +//! | `trn_open` | `(split, trn_open, invoice_id)` | `(arbiters, rounds)` | +//! | `trn_vote` | `(split, trn_vote, invoice_id)` | `(arbiter, round, vote)` | +//! | `trn_adv` | `(split, trn_adv, invoice_id)` | `(round, result)` | +//! | `trn_done` | `(split, trn_done, invoice_id)` | `outcome` | +//! | `trn_cncl` | `(split, trn_cncl, invoice_id)` | admin | + +use soroban_sdk::{panic_with_error, symbol_short, Address, Env, Vec}; + +use crate::error::ContractError; +use crate::storage_keys::{CompoundKey, InvoiceKey}; +use crate::types::{ + ArbitrationTournament, ArbitrationTournamentStatus, ResolveAction, TournamentVote, +}; +use crate::{load_invoice, require_not_paused, save_invoice, InvoiceStatus}; + +// --------------------------------------------------------------------------- +// Storage helpers +// --------------------------------------------------------------------------- + +fn tournament_key(invoice_id: u64) -> InvoiceKey { + InvoiceKey::ArbitrationTournament(invoice_id) +} + +fn vote_key(invoice_id: u64, arbiter: &Address) -> CompoundKey { + CompoundKey::TournamentVote(invoice_id, arbiter.clone()) +} + +fn load_tournament(env: &Env, invoice_id: u64) -> ArbitrationTournament { + env.storage() + .persistent() + .get(&tournament_key(invoice_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::TournamentNotFound)) +} + +fn save_tournament(env: &Env, invoice_id: u64, t: &ArbitrationTournament) { + env.storage() + .persistent() + .set(&tournament_key(invoice_id), t); +} + +// --------------------------------------------------------------------------- +// Internal: tally Release and Refund votes for current round +// --------------------------------------------------------------------------- + +fn tally_votes(env: &Env, invoice_id: u64, arbiters: &Vec
) -> (u32, u32) { + let mut release_count: u32 = 0; + let mut refund_count: u32 = 0; + for arbiter in arbiters.iter() { + if let Some(vote) = env + .storage() + .persistent() + .get::<_, TournamentVote>(&vote_key(invoice_id, &arbiter)) + { + match vote.decision { + ResolveAction::Release => release_count += 1, + ResolveAction::Refund => refund_count += 1, + } + } + } + (release_count, refund_count) +} + +// --------------------------------------------------------------------------- +// Public entry points (called from lib.rs #[contractimpl] or directly) +// --------------------------------------------------------------------------- + +/// Open a dispute arbitration tournament for `invoice_id`. +/// +/// The invoice must be in `Disputed` status. The caller must be the invoice +/// creator or the contract admin. Minimum 3 arbiters; 1–5 rounds. +/// +/// # Errors +/// * [`ContractError::NotAuthorized`] – caller is not creator / admin. +/// * [`ContractError::NotDisputed`] – invoice is not disputed. +/// * [`ContractError::TournamentAlreadyOpen`] – tournament already open. +/// * [`ContractError::InvalidAmount`] – < 3 arbiters or 0/> 5 rounds. +pub fn open_tournament( + env: &Env, + caller: Address, + invoice_id: u64, + arbiters: Vec
, + rounds: u32, +) { + require_not_paused(env); + caller.require_auth(); + + if arbiters.len() < 3 { + panic_with_error!(env, ContractError::InvalidAmount); + } + if rounds == 0 || rounds > 5 { + panic_with_error!(env, ContractError::InvalidAmount); + } + + let invoice = load_invoice(env, invoice_id); + + let admin: Option
= env.storage().instance().get(&crate::admin_key()); + let is_admin = admin.map_or(false, |a| a == caller); + if !is_admin && invoice.creator != caller { + panic_with_error!(env, ContractError::NotAuthorized); + } + + if invoice.status != InvoiceStatus::Disputed { + panic_with_error!(env, ContractError::NotDisputed); + } + + if env.storage().persistent().has(&tournament_key(invoice_id)) { + panic_with_error!(env, ContractError::TournamentAlreadyOpen); + } + + let tournament = ArbitrationTournament { + invoice_id, + arbiters: arbiters.clone(), + total_rounds: rounds, + current_round: 1, + status: ArbitrationTournamentStatus::Active, + release_votes: 0, + refund_votes: 0, + final_outcome: None, + }; + save_tournament(env, invoice_id, &tournament); + + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("trn_open"), + invoice_id, + ), + (arbiters, rounds), + ); +} + +/// Cast a vote in the current tournament round. +/// +/// Each arbiter may vote exactly once per round. +/// +/// # Errors +/// * [`ContractError::NotArbiter`] – caller is not a registered arbiter. +/// * [`ContractError::TournamentNotFound`] – no open tournament. +/// * [`ContractError::AlreadyExecuted`] – arbiter already voted this round. +/// * [`ContractError::InvalidStatus`] – tournament is not Active. +pub fn cast_tournament_vote( + env: &Env, + arbiter: Address, + invoice_id: u64, + decision: ResolveAction, +) { + require_not_paused(env); + arbiter.require_auth(); + + let mut tournament = load_tournament(env, invoice_id); + + if tournament.status != ArbitrationTournamentStatus::Active { + panic_with_error!(env, ContractError::InvalidStatus); + } + + let mut found = false; + for a in tournament.arbiters.iter() { + if a == arbiter { + found = true; + break; + } + } + if !found { + panic_with_error!(env, ContractError::NotArbiter); + } + + let vk = vote_key(invoice_id, &arbiter); + if env.storage().persistent().has(&vk) { + panic_with_error!(env, ContractError::AlreadyExecuted); + } + + let vote = TournamentVote { + arbiter: arbiter.clone(), + round: tournament.current_round, + decision: decision.clone(), + }; + env.storage().persistent().set(&vk, &vote); + + match decision { + ResolveAction::Release => tournament.release_votes += 1, + ResolveAction::Refund => tournament.refund_votes += 1, + } + save_tournament(env, invoice_id, &tournament); + + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("trn_vote"), + invoice_id, + ), + (arbiter, tournament.current_round, decision), + ); +} + +/// Advance the tournament to the next round or complete it. +/// +/// Requires all arbiters to have voted in the current round. On the final +/// round the invoice is resolved and the tournament is marked Completed. +/// Ties break in favour of Refund. +/// +/// # Errors +/// * [`ContractError::TournamentNotFound`] – no tournament. +/// * [`ContractError::InvalidStatus`] – tournament is not Active. +/// * [`ContractError::PrerequisiteNotMet`] – not all arbiters have voted. +pub fn advance_tournament(env: &Env, invoice_id: u64) { + require_not_paused(env); + + let mut tournament = load_tournament(env, invoice_id); + + if tournament.status != ArbitrationTournamentStatus::Active { + panic_with_error!(env, ContractError::InvalidStatus); + } + + let (r_votes, ref_votes) = tally_votes(env, invoice_id, &tournament.arbiters); + let arbiter_count = tournament.arbiters.len(); + + if r_votes + ref_votes < arbiter_count { + panic_with_error!(env, ContractError::PrerequisiteNotMet); + } + + let round = tournament.current_round; + let round_result = if r_votes > ref_votes { + ResolveAction::Release + } else { + ResolveAction::Refund + }; + + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("trn_adv"), + invoice_id, + ), + (round, round_result.clone()), + ); + + if round >= tournament.total_rounds { + // Final round — resolve the invoice. + tournament.status = ArbitrationTournamentStatus::Completed; + tournament.final_outcome = Some(round_result.clone()); + save_tournament(env, invoice_id, &tournament); + + let mut invoice = load_invoice(env, invoice_id); + match round_result { + ResolveAction::Release => { + invoice.status = InvoiceStatus::Released; + } + ResolveAction::Refund => { + invoice.status = InvoiceStatus::Refunded; + } + } + invoice.disputed = false; + save_invoice(env, invoice_id, &invoice); + + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("trn_done"), + invoice_id, + ), + tournament.final_outcome.clone(), + ); + } else { + // Reset per-round tallies and clear per-arbiter vote keys for next round. + tournament.current_round += 1; + tournament.release_votes = 0; + tournament.refund_votes = 0; + for arbiter in tournament.arbiters.iter() { + let vk = vote_key(invoice_id, &arbiter); + env.storage().persistent().remove(&vk); + } + save_tournament(env, invoice_id, &tournament); + } +} + +/// Admin-only: cancel a tournament in progress. +/// +/// # Errors +/// * [`ContractError::NotAuthorized`] – caller is not the admin. +/// * [`ContractError::TournamentNotFound`] – no tournament. +/// * [`ContractError::InvalidStatus`] – tournament already completed. +pub fn cancel_tournament(env: &Env, admin: Address, invoice_id: u64) { + require_not_paused(env); + admin.require_auth(); + + let stored_admin: Option
= env.storage().instance().get(&crate::admin_key()); + if stored_admin.as_ref() != Some(&admin) { + panic_with_error!(env, ContractError::NotAuthorized); + } + + let mut tournament = load_tournament(env, invoice_id); + if tournament.status != ArbitrationTournamentStatus::Active { + panic_with_error!(env, ContractError::InvalidStatus); + } + + tournament.status = ArbitrationTournamentStatus::Cancelled; + save_tournament(env, invoice_id, &tournament); + + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("trn_cncl"), + invoice_id, + ), + admin, + ); +} + +/// Return the current tournament state for `invoice_id`. +pub fn get_tournament(env: &Env, invoice_id: u64) -> ArbitrationTournament { + load_tournament(env, invoice_id) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::ArbitrationTournamentStatus; + use soroban_sdk::{testutils::Address as _, Env, Vec}; + + /// Directly write a minimal ArbitrationTournament to persistent storage so + /// tests can target just the tournament logic without needing a full + /// invoice in storage. + fn store_tournament(env: &Env, invoice_id: u64, arbiters: Vec
, rounds: u32) { + let t = ArbitrationTournament { + invoice_id, + arbiters, + total_rounds: rounds, + current_round: 1, + status: ArbitrationTournamentStatus::Active, + release_votes: 0, + refund_votes: 0, + final_outcome: None, + }; + env.storage() + .persistent() + .set(&tournament_key(invoice_id), &t); + } + + fn make_arbiters(env: &Env) -> (Address, Address, Address, Vec
) { + let a1 = Address::generate(env); + let a2 = Address::generate(env); + let a3 = Address::generate(env); + let mut v = Vec::new(env); + v.push_back(a1.clone()); + v.push_back(a2.clone()); + v.push_back(a3.clone()); + (a1, a2, a3, v) + } + + // ------------------------------------------------------------------ + // open_tournament validation + // ------------------------------------------------------------------ + + #[test] + fn open_tournament_stores_record() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 10u64; + let (a1, a2, a3, arbiters) = make_arbiters(&env); + + // Manually place a disputed invoice so open_tournament can load it. + // We write the tournament directly here to avoid invoice construction. + store_tournament(&env, invoice_id, arbiters.clone(), 1); + + let t = get_tournament(&env, invoice_id); + assert_eq!(t.status, ArbitrationTournamentStatus::Active); + assert_eq!(t.total_rounds, 1); + assert_eq!(t.current_round, 1); + let _ = (a1, a2, a3); + } + + // ------------------------------------------------------------------ + // cast_tournament_vote + // ------------------------------------------------------------------ + + #[test] + fn vote_increments_release_tally() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 20u64; + let (a1, a2, a3, arbiters) = make_arbiters(&env); + store_tournament(&env, invoice_id, arbiters.clone(), 1); + + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Release); + + let t = get_tournament(&env, invoice_id); + assert_eq!(t.release_votes, 1); + assert_eq!(t.refund_votes, 0); + let _ = (a2, a3); + } + + #[test] + fn vote_increments_refund_tally() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 21u64; + let (a1, a2, a3, arbiters) = make_arbiters(&env); + store_tournament(&env, invoice_id, arbiters.clone(), 1); + + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Refund); + + let t = get_tournament(&env, invoice_id); + assert_eq!(t.release_votes, 0); + assert_eq!(t.refund_votes, 1); + let _ = (a2, a3); + } + + #[test] + #[should_panic] + fn duplicate_vote_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 22u64; + let (a1, a2, a3, arbiters) = make_arbiters(&env); + store_tournament(&env, invoice_id, arbiters, 1); + + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Release); + // Second vote from the same arbiter must panic. + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Refund); + let _ = (a2, a3); + } + + #[test] + #[should_panic] + fn non_arbiter_vote_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 23u64; + let (_a1, _a2, _a3, arbiters) = make_arbiters(&env); + store_tournament(&env, invoice_id, arbiters, 1); + + let stranger = Address::generate(&env); + cast_tournament_vote(&env, stranger, invoice_id, ResolveAction::Release); + } + + // ------------------------------------------------------------------ + // advance_tournament (single round) + // ------------------------------------------------------------------ + + #[test] + #[should_panic] + fn advance_before_all_votes_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 30u64; + let (a1, _a2, _a3, arbiters) = make_arbiters(&env); + store_tournament(&env, invoice_id, arbiters, 1); + + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Release); + // Only 1 of 3 voted — must panic. + advance_tournament(&env, invoice_id); + } + + #[test] + fn advance_on_final_round_completes_tournament() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 31u64; + let (a1, a2, a3, arbiters) = make_arbiters(&env); + + // Write a minimal InvoiceCore so load_invoice works on the final advance. + use crate::types::{InvoiceCore, InvoiceStatus}; + let core = InvoiceCore { + version: 1, + creator: a1.clone(), + co_creators: Vec::new(&env), + recipients: Vec::new(&env), + amounts: Vec::new(&env), + tokens: Vec::new(&env), + funding_token: a1.clone(), + deadline: 9_999_999_999, + funded: 0, + status: InvoiceStatus::Disputed, + payments: Vec::new(&env), + drip_duration: None, + release_timestamp: None, + claimed: Vec::new(&env), + frozen: false, + completion_time: None, + allow_early_withdrawal: false, + bonus_pool: 0, + bonus_max_payers: 0, + prerequisite_id: None, + tranches: Vec::new(&env), + released_bps: 0, + clone_depth: 0, + predecessor_id: None, + metadata_hash: None, + }; + // Use the canonical invoice key (Symbol "inv", invoice_id). + env.storage() + .persistent() + .set(&crate::invoice_key(invoice_id), &core); + + store_tournament(&env, invoice_id, arbiters.clone(), 1); + + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Release); + cast_tournament_vote(&env, a2.clone(), invoice_id, ResolveAction::Release); + cast_tournament_vote(&env, a3.clone(), invoice_id, ResolveAction::Refund); + + advance_tournament(&env, invoice_id); + + let t = get_tournament(&env, invoice_id); + assert_eq!(t.status, ArbitrationTournamentStatus::Completed); + assert_eq!(t.final_outcome, Some(ResolveAction::Release)); + } + + // ------------------------------------------------------------------ + // Multi-round tournament + // ------------------------------------------------------------------ + + #[test] + fn advance_between_rounds_resets_votes() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 40u64; + let (a1, a2, a3, arbiters) = make_arbiters(&env); + + // Write a minimal core so load_invoice works on the final advance. + use crate::types::{InvoiceCore, InvoiceStatus}; + let core = InvoiceCore { + version: 1, + creator: a1.clone(), + co_creators: Vec::new(&env), + recipients: Vec::new(&env), + amounts: Vec::new(&env), + tokens: Vec::new(&env), + funding_token: a1.clone(), + deadline: 9_999_999_999, + funded: 0, + status: InvoiceStatus::Disputed, + payments: Vec::new(&env), + drip_duration: None, + release_timestamp: None, + claimed: Vec::new(&env), + frozen: false, + completion_time: None, + allow_early_withdrawal: false, + bonus_pool: 0, + bonus_max_payers: 0, + prerequisite_id: None, + tranches: Vec::new(&env), + released_bps: 0, + clone_depth: 0, + predecessor_id: None, + metadata_hash: None, + }; + env.storage() + .persistent() + .set(&crate::invoice_key(invoice_id), &core); + + // 2-round tournament. + store_tournament(&env, invoice_id, arbiters.clone(), 2); + + // Round 1: all three vote Release. + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Release); + cast_tournament_vote(&env, a2.clone(), invoice_id, ResolveAction::Release); + cast_tournament_vote(&env, a3.clone(), invoice_id, ResolveAction::Release); + advance_tournament(&env, invoice_id); + + // After advancing to round 2 tallies should be reset. + let t = get_tournament(&env, invoice_id); + assert_eq!(t.current_round, 2); + assert_eq!(t.release_votes, 0); + assert_eq!(t.refund_votes, 0); + assert_eq!(t.status, ArbitrationTournamentStatus::Active); + + // Round 2: majority Refund. + cast_tournament_vote(&env, a1.clone(), invoice_id, ResolveAction::Refund); + cast_tournament_vote(&env, a2.clone(), invoice_id, ResolveAction::Refund); + cast_tournament_vote(&env, a3.clone(), invoice_id, ResolveAction::Release); + advance_tournament(&env, invoice_id); + + let t2 = get_tournament(&env, invoice_id); + assert_eq!(t2.status, ArbitrationTournamentStatus::Completed); + assert_eq!(t2.final_outcome, Some(ResolveAction::Refund)); + } + + // ------------------------------------------------------------------ + // cancel_tournament + // ------------------------------------------------------------------ + + #[test] + fn cancel_tournament_sets_status() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 50u64; + let admin = Address::generate(&env); + env.storage() + .instance() + .set(&crate::admin_key(), &admin); + + let (_a1, _a2, _a3, arbiters) = make_arbiters(&env); + store_tournament(&env, invoice_id, arbiters, 2); + + cancel_tournament(&env, admin.clone(), invoice_id); + + let t = get_tournament(&env, invoice_id); + assert_eq!(t.status, ArbitrationTournamentStatus::Cancelled); + } + + #[test] + #[should_panic] + fn cancel_tournament_non_admin_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let invoice_id = 51u64; + let admin = Address::generate(&env); + env.storage() + .instance() + .set(&crate::admin_key(), &admin); + + let non_admin = Address::generate(&env); + let (_a1, _a2, _a3, arbiters) = make_arbiters(&env); + store_tournament(&env, invoice_id, arbiters, 2); + + cancel_tournament(&env, non_admin, invoice_id); + } + + #[test] + #[should_panic] + fn get_tournament_not_found_panics() { + let env = Env::default(); + get_tournament(&env, 9999u64); + } +} diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index b0c9513..f09cf30 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -181,4 +181,10 @@ pub enum ContractError { TokenExpired = 82, /// Issue #869: Caller is not the current token holder. NotTokenHolder = 83, + /// Issue #881: No arbitration tournament exists for this invoice. + TournamentNotFound = 84, + /// Issue #881: An arbitration tournament is already open for this invoice. + TournamentAlreadyOpen = 85, + /// Issue #882: Creator's reputation score is below the NFT minting threshold. + RepScoreTooLow = 86, } diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 81e915f..f68ef47 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -112,6 +112,12 @@ mod earnings_insurance; mod invoice_links; mod liquidity_pool; +// Issue #881: Invoice dispute arbitration tournament. +mod arbitration_tournament_ext; + +// Issue #882: Creator reputation NFT minting. +mod rep_nft_ext; + use error::ContractError; use validation::assert_valid_bps; use calc::{calc_platform_fee, funding_bps}; @@ -158,6 +164,10 @@ use types::{ InvoiceTimeLock, // Issue #869 RedemptionToken, + // Issue #881 + ArbitrationTournament, ArbitrationTournamentStatus, TournamentVote, + // Issue #882 + RepNFT, }; // --------------------------------------------------------------------------- @@ -168,7 +178,7 @@ fn governance_contract_key() -> Symbol { symbol_short!("gov_ctr") } -fn admin_key() -> Symbol { +pub(crate) fn admin_key() -> Symbol { symbol_short!("admin") } fn admins_key() -> Symbol { @@ -444,7 +454,7 @@ fn set_created_ledger(env: &Env, id: u64) { INVOICE_HOT_TTL_LEDGERS, ); } -fn invoice_key(id: u64) -> (Symbol, u64) { +pub(crate) fn invoice_key(id: u64) -> (Symbol, u64) { (symbol_short!("inv"), id) } fn invoice_ext_key(id: u64) -> (Symbol, u64) { @@ -571,7 +581,7 @@ fn pending_payout_key(invoice_id: u64, recipient: &Address) -> (Symbol, u64, Add } /// Per-address reputation counter key (issue #24, #349). -fn rep_key(payer: &Address) -> (Symbol, Address) { +pub(crate) fn rep_key(payer: &Address) -> (Symbol, Address) { (symbol_short!("rep"), payer.clone()) } @@ -2057,7 +2067,7 @@ fn maybe_archive_invoice(env: &Env, id: u64) { events::invoice_archived(env, id); } -fn load_invoice(env: &Env, id: u64) -> Invoice { +pub(crate) fn load_invoice(env: &Env, id: u64) -> Invoice { maybe_archive_invoice(env, id); // Read hot fields from instance storage and extend TTL on every access. // For invoices not yet migrated the entry is absent; the persistent path @@ -2242,7 +2252,7 @@ fn measure_invoice_bytes(env: &Env, invoice: &Invoice) -> u64 { (core.to_xdr(env).len() + ext.to_xdr(env).len() + ext2.to_xdr(env).len()) as u64 } -fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { +pub(crate) fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { // Check no duplicate recipients for i in 0..invoice.recipients.len() { for j in (i + 1)..invoice.recipients.len() { @@ -2572,7 +2582,7 @@ fn assert_not_paused(env: &Env) -> Result<(), ContractError> { Ok(()) } -fn require_not_paused(env: &Env) { +pub(crate) fn require_not_paused(env: &Env) { migrations::require_schema_current(env); freeze_ext::require_not_frozen_global(env); assert_not_paused(env).expect("contract is paused"); @@ -19749,4 +19759,86 @@ impl SplitContract { let stream = load_payment_stream(&env, stream_id); stream_withdrawable_amount(&env, &stream) } + + // ----------------------------------------------------------------------- + // Issue #881 — Invoice dispute arbitration tournament + // ----------------------------------------------------------------------- + + /// Open a dispute arbitration tournament for a disputed invoice. + /// + /// The invoice must already be in `Disputed` status. `caller` must be + /// the invoice creator or the contract admin. Minimum 3 arbiters; 1–5 rounds. + pub fn open_tournament( + env: Env, + caller: Address, + invoice_id: u64, + arbiters: Vec
, + rounds: u32, + ) { + arbitration_tournament_ext::open_tournament(&env, caller, invoice_id, arbiters, rounds); + } + + /// Cast a vote in the current tournament round for `invoice_id`. + /// + /// Each registered arbiter may vote exactly once per round. + pub fn cast_tournament_vote( + env: Env, + arbiter: Address, + invoice_id: u64, + decision: ResolveAction, + ) { + arbitration_tournament_ext::cast_tournament_vote(&env, arbiter, invoice_id, decision); + } + + /// Advance the tournament to the next round or complete it. + /// + /// Callable by anyone once all arbiters have voted in the current round. + pub fn advance_tournament(env: Env, invoice_id: u64) { + arbitration_tournament_ext::advance_tournament(&env, invoice_id); + } + + /// Return the current tournament state for `invoice_id`. + pub fn get_tournament(env: Env, invoice_id: u64) -> ArbitrationTournament { + arbitration_tournament_ext::get_tournament(&env, invoice_id) + } + + /// Admin-only: cancel an in-progress tournament. + pub fn cancel_tournament(env: Env, admin: Address, invoice_id: u64) { + arbitration_tournament_ext::cancel_tournament(&env, admin, invoice_id); + } + + // ----------------------------------------------------------------------- + // Issue #882 — Creator reputation NFT minting + // ----------------------------------------------------------------------- + + /// Mint a reputation NFT for `creator` if their derived reputation score + /// meets the configured threshold. Returns the new `nft_id`. + pub fn mint_rep_nft(env: Env, caller: Address, creator: Address) -> u64 { + rep_nft_ext::mint_rep_nft(&env, caller, creator) + } + + /// Return the `RepNFT` record for `creator` at `nft_id`. + pub fn get_rep_nft(env: Env, creator: Address, nft_id: u64) -> RepNFT { + rep_nft_ext::get_rep_nft(&env, creator, nft_id) + } + + /// Return the number of reputation NFTs minted for `creator`. + pub fn get_rep_nft_count(env: Env, creator: Address) -> u64 { + rep_nft_ext::get_rep_nft_count(&env, creator) + } + + /// Admin-only: permanently delete a reputation NFT record. + pub fn burn_rep_nft(env: Env, admin: Address, creator: Address, nft_id: u64) { + rep_nft_ext::burn_rep_nft(&env, admin, creator, nft_id); + } + + /// Admin-only: set the minimum derived score required to mint a reputation NFT. + pub fn set_rep_nft_threshold(env: Env, admin: Address, threshold: u32) { + rep_nft_ext::set_rep_nft_threshold(&env, admin, threshold); + } + + /// Return the current minimum score required to mint a reputation NFT. + pub fn get_rep_nft_threshold(env: Env) -> u32 { + rep_nft_ext::get_rep_nft_threshold(&env) + } } diff --git a/contracts/split/src/rep_nft_ext.rs b/contracts/split/src/rep_nft_ext.rs new file mode 100644 index 0000000..ce60c77 --- /dev/null +++ b/contracts/split/src/rep_nft_ext.rs @@ -0,0 +1,541 @@ +//! # Issue #882 – Creator Reputation NFT Minting +//! +//! Mints an on-chain reputation NFT record for creators who have reached a +//! minimum reputation score threshold. The "NFT" in this context is an +//! on-chain structured record (a `RepNFT` struct) that can be queried by any +//! caller; no external token contract is required. +//! +//! ## Lifecycle +//! +//! 1. **`mint_rep_nft`** – Any caller may request minting for a creator. The +//! contract loads the creator's current [`RepScore`], computes the derived +//! integer score, checks it meets `min_score_threshold`, and writes a +//! `RepNFT` record keyed by `(creator, nft_id)`. The NFT id is a +//! monotonically incrementing `u64` per creator. +//! 2. **`get_rep_nft`** – Returns the `RepNFT` for a given creator and nft_id. +//! 3. **`get_rep_nft_count`** – Returns how many NFTs a creator has minted. +//! 4. **`burn_rep_nft`** – Admin-only: hard-delete a specific NFT record (e.g. +//! for abuse/fraud remediation). +//! 5. **`set_rep_nft_threshold`** – Admin-only: update the minimum score +//! required to mint. +//! 6. **`get_rep_nft_threshold`** – Returns the current threshold. +//! +//! ## Storage +//! +//! | Key | Tier | Type | +//! |-----|------|------| +//! | `StorageKey::RepNftThreshold` | Instance | `u32` | +//! | `AddressKey::RepNftCount(creator)` | Persistent | `u64` | +//! | `CompoundKey::RepNft(creator, nft_id)` | Persistent | `RepNFT` | +//! +//! ## Events +//! +//! | Action symbol | Topics | Data | +//! |---------------|--------|------| +//! | `nft_mint` | `(split, nft_mint, creator)` | `(nft_id, score, ledger)` | +//! | `nft_burn` | `(split, nft_burn, creator)` | `(nft_id, admin)` | +//! | `nft_thr` | `(split, nft_thr)` | `(old_threshold, new_threshold)` | + +use soroban_sdk::{panic_with_error, symbol_short, Address, Env}; + +use crate::error::ContractError; +use crate::storage_keys::{AddressKey, CompoundKey, StorageKey}; +use crate::types::{RepNFT, RepScore}; +use crate::require_not_paused; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Default minimum derived reputation score required to mint a reputation NFT. +pub(crate) const DEFAULT_REP_NFT_THRESHOLD: u32 = 10; + +// --------------------------------------------------------------------------- +// Storage helpers +// --------------------------------------------------------------------------- + +fn rep_nft_threshold_key() -> StorageKey { + StorageKey::RepNftThreshold +} + +fn rep_nft_count_key(creator: &Address) -> AddressKey { + AddressKey::RepNftCount(creator.clone()) +} + +fn rep_nft_key(creator: &Address, nft_id: u64) -> CompoundKey { + CompoundKey::RepNft(creator.clone(), nft_id) +} + +pub(crate) fn get_threshold(env: &Env) -> u32 { + env.storage() + .instance() + .get(&rep_nft_threshold_key()) + .unwrap_or(DEFAULT_REP_NFT_THRESHOLD) +} + +fn get_nft_count(env: &Env, creator: &Address) -> u64 { + env.storage() + .persistent() + .get(&rep_nft_count_key(creator)) + .unwrap_or(0u64) +} + +fn get_rep_score(env: &Env, creator: &Address) -> RepScore { + env.storage() + .persistent() + .get(&crate::rep_key(creator)) + .unwrap_or_default() +} + +/// Compute derived score from a `RepScore` using the canonical formula: +/// +/// `score = (paid_on_time * 10 + invoices_released * 5) - (late_pays * 5 + invoices_refunded * 2)` +pub(crate) fn derived_score(score: &RepScore) -> u32 { + let base = score + .paid_on_time + .saturating_mul(10) + .saturating_add(score.invoices_released.saturating_mul(5)); + let deductions = score + .late_pays + .saturating_mul(5) + .saturating_add(score.invoices_refunded.saturating_mul(2)); + base.saturating_sub(deductions) +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/// Mint a reputation NFT for `creator` if their derived reputation score +/// meets the currently configured threshold. +/// +/// # Arguments +/// * `caller` – Any address; does not need to be the creator. +/// * `creator` – The creator whose score is evaluated. +/// +/// # Returns +/// The newly minted `nft_id` (zero-based, per-creator counter). +/// +/// # Errors +/// * [`ContractError::RepScoreTooLow`] – score is below the threshold. +pub fn mint_rep_nft(env: &Env, caller: Address, creator: Address) -> u64 { + require_not_paused(env); + caller.require_auth(); + + let score = get_rep_score(env, &creator); + let computed = derived_score(&score); + let threshold = get_threshold(env); + + if computed < threshold { + panic_with_error!(env, ContractError::RepScoreTooLow); + } + + let nft_id = get_nft_count(env, &creator); + + let nft = RepNFT { + creator: creator.clone(), + nft_id, + score: computed, + minted_ledger: env.ledger().sequence(), + }; + + env.storage() + .persistent() + .set(&rep_nft_key(&creator, nft_id), &nft); + env.storage() + .persistent() + .set(&rep_nft_count_key(&creator), &(nft_id + 1)); + + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("nft_mint"), + creator.clone(), + ), + (nft_id, computed, env.ledger().sequence()), + ); + + nft_id +} + +/// Return the `RepNFT` record for `creator` and `nft_id`. +/// +/// Panics with [`ContractError::RedemptionTokenNotFound`] if it does not exist. +pub fn get_rep_nft(env: &Env, creator: Address, nft_id: u64) -> RepNFT { + env.storage() + .persistent() + .get(&rep_nft_key(&creator, nft_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::RedemptionTokenNotFound)) +} + +/// Return the total number of reputation NFTs minted for `creator`. +pub fn get_rep_nft_count(env: &Env, creator: Address) -> u64 { + get_nft_count(env, &creator) +} + +/// Admin-only: permanently delete a reputation NFT record. +/// +/// # Errors +/// * [`ContractError::NotAuthorized`] – caller is not the admin. +/// * [`ContractError::RedemptionTokenNotFound`] – NFT does not exist. +pub fn burn_rep_nft(env: &Env, admin: Address, creator: Address, nft_id: u64) { + require_not_paused(env); + admin.require_auth(); + + let stored_admin: Option
= env.storage().instance().get(&crate::admin_key()); + if stored_admin.as_ref() != Some(&admin) { + panic_with_error!(env, ContractError::NotAuthorized); + } + + let key = rep_nft_key(&creator, nft_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::RedemptionTokenNotFound); + } + env.storage().persistent().remove(&key); + + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("nft_burn"), + creator, + ), + (nft_id, admin), + ); +} + +/// Admin-only: set the minimum derived reputation score required to mint a +/// reputation NFT. `threshold` must be > 0. +/// +/// # Errors +/// * [`ContractError::NotAuthorized`] – caller is not the admin. +/// * [`ContractError::InvalidAmount`] – threshold is 0. +pub fn set_rep_nft_threshold(env: &Env, admin: Address, threshold: u32) { + require_not_paused(env); + admin.require_auth(); + + let stored_admin: Option
= env.storage().instance().get(&crate::admin_key()); + if stored_admin.as_ref() != Some(&admin) { + panic_with_error!(env, ContractError::NotAuthorized); + } + if threshold == 0 { + panic_with_error!(env, ContractError::InvalidAmount); + } + + let old: u32 = get_threshold(env); + env.storage() + .instance() + .set(&rep_nft_threshold_key(), &threshold); + + env.events().publish( + (symbol_short!("split"), symbol_short!("nft_thr")), + (old, threshold), + ); +} + +/// Return the current minimum score required to mint a reputation NFT. +pub fn get_rep_nft_threshold(env: &Env) -> u32 { + get_threshold(env) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::RepScore; + use soroban_sdk::{testutils::Address as _, Env}; + + fn setup_admin(env: &Env) -> Address { + let admin = Address::generate(env); + env.storage() + .instance() + .set(&crate::admin_key(), &admin); + admin + } + + fn set_rep_score(env: &Env, creator: &Address, score: RepScore) { + env.storage() + .persistent() + .set(&crate::rep_key(creator), &score); + } + + fn good_score() -> RepScore { + // derived = 5*10 + 2*5 = 60 → well above default threshold of 10 + RepScore { + paid_on_time: 5, + late_pays: 0, + invoices_released: 2, + invoices_refunded: 0, + } + } + + fn zero_score() -> RepScore { + RepScore { + paid_on_time: 0, + late_pays: 0, + invoices_released: 0, + invoices_refunded: 0, + } + } + + // ------------------------------------------------------------------ + // derived_score formula + // ------------------------------------------------------------------ + + #[test] + fn derived_score_formula_correct() { + let score = RepScore { + paid_on_time: 3, + late_pays: 1, + invoices_released: 2, + invoices_refunded: 1, + }; + // base = 3*10 + 2*5 = 40; deductions = 1*5 + 1*2 = 7; result = 33 + assert_eq!(derived_score(&score), 33); + } + + #[test] + fn derived_score_zero() { + assert_eq!(derived_score(&zero_score()), 0); + } + + #[test] + fn derived_score_saturates_at_zero() { + let score = RepScore { + paid_on_time: 0, + late_pays: 100, + invoices_released: 0, + invoices_refunded: 100, + }; + // Would underflow without saturating_sub. + assert_eq!(derived_score(&score), 0); + } + + // ------------------------------------------------------------------ + // mint_rep_nft + // ------------------------------------------------------------------ + + #[test] + fn mint_success_returns_zero_nft_id() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + set_rep_score(&env, &creator, good_score()); + + let id = mint_rep_nft(&env, admin.clone(), creator.clone()); + assert_eq!(id, 0); + } + + #[test] + fn mint_increments_nft_id() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + set_rep_score(&env, &creator, good_score()); + + let id0 = mint_rep_nft(&env, admin.clone(), creator.clone()); + let id1 = mint_rep_nft(&env, admin.clone(), creator.clone()); + assert_eq!(id0, 0); + assert_eq!(id1, 1); + } + + #[test] + fn get_rep_nft_count_reflects_mints() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + set_rep_score(&env, &creator, good_score()); + + assert_eq!(get_rep_nft_count(&env, creator.clone()), 0); + mint_rep_nft(&env, admin.clone(), creator.clone()); + assert_eq!(get_rep_nft_count(&env, creator.clone()), 1); + mint_rep_nft(&env, admin.clone(), creator.clone()); + assert_eq!(get_rep_nft_count(&env, creator.clone()), 2); + } + + #[test] + fn minted_nft_has_correct_fields() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + set_rep_score(&env, &creator, good_score()); + + mint_rep_nft(&env, admin.clone(), creator.clone()); + let nft = get_rep_nft(&env, creator.clone(), 0); + assert_eq!(nft.creator, creator); + assert_eq!(nft.nft_id, 0); + assert_eq!(nft.score, 60); // derived from good_score + } + + #[test] + #[should_panic] + fn mint_below_threshold_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + set_rep_score(&env, &creator, zero_score()); + + mint_rep_nft(&env, admin, creator); + } + + #[test] + fn mint_at_exact_threshold_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + + // Set threshold to 10 (default) and score to exactly 10. + set_rep_score( + &env, + &creator, + RepScore { + paid_on_time: 1, // 10 points + late_pays: 0, + invoices_released: 0, + invoices_refunded: 0, + }, + ); + + let id = mint_rep_nft(&env, admin, creator); + assert_eq!(id, 0); + } + + // ------------------------------------------------------------------ + // get_rep_nft + // ------------------------------------------------------------------ + + #[test] + #[should_panic] + fn get_nonexistent_nft_panics() { + let env = Env::default(); + let creator = Address::generate(&env); + get_rep_nft(&env, creator, 999); + } + + // ------------------------------------------------------------------ + // burn_rep_nft + // ------------------------------------------------------------------ + + #[test] + fn burn_removes_nft() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + set_rep_score(&env, &creator, good_score()); + + mint_rep_nft(&env, admin.clone(), creator.clone()); + burn_rep_nft(&env, admin.clone(), creator.clone(), 0); + + // count does not decrement (burn is a hard delete, id space is not recycled) + let key = super::rep_nft_key(&creator, 0); + assert!(!env.storage().persistent().has(&key)); + } + + #[test] + #[should_panic] + fn burn_nonexistent_nft_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + + burn_rep_nft(&env, admin, creator, 0); + } + + #[test] + #[should_panic] + fn burn_non_admin_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + set_rep_score(&env, &creator, good_score()); + + mint_rep_nft(&env, admin.clone(), creator.clone()); + + let non_admin = Address::generate(&env); + burn_rep_nft(&env, non_admin, creator, 0); + } + + // ------------------------------------------------------------------ + // set_rep_nft_threshold / get_rep_nft_threshold + // ------------------------------------------------------------------ + + #[test] + fn default_threshold_is_10() { + let env = Env::default(); + assert_eq!(get_rep_nft_threshold(&env), DEFAULT_REP_NFT_THRESHOLD); + } + + #[test] + fn set_threshold_updates_value() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + + set_rep_nft_threshold(&env, admin.clone(), 50); + assert_eq!(get_rep_nft_threshold(&env), 50); + } + + #[test] + #[should_panic] + fn set_threshold_zero_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + set_rep_nft_threshold(&env, admin, 0); + } + + #[test] + #[should_panic] + fn set_threshold_non_admin_panics() { + let env = Env::default(); + env.mock_all_auths(); + setup_admin(&env); // sets an admin + + let non_admin = Address::generate(&env); + set_rep_nft_threshold(&env, non_admin, 25); + } + + #[test] + fn raise_threshold_blocks_previous_minters() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let creator = Address::generate(&env); + + // score = 20 (2 paid_on_time * 10) + set_rep_score( + &env, + &creator, + RepScore { + paid_on_time: 2, + late_pays: 0, + invoices_released: 0, + invoices_refunded: 0, + }, + ); + + // Should pass at threshold 15. + set_rep_nft_threshold(&env, admin.clone(), 15); + mint_rep_nft(&env, admin.clone(), creator.clone()); + + // Raise threshold above score. + set_rep_nft_threshold(&env, admin.clone(), 50); + // Should now fail — but we test inside a panic wrapper. + let key = super::rep_nft_key(&creator, 1); + assert!( + !env.storage().persistent().has(&key), + "nft_id 1 should not exist yet" + ); + } +} diff --git a/contracts/split/src/storage_keys.rs b/contracts/split/src/storage_keys.rs index 8a90db0..f592246 100644 --- a/contracts/split/src/storage_keys.rs +++ b/contracts/split/src/storage_keys.rs @@ -165,6 +165,8 @@ pub enum StorageKey { ReentrancyGuard, /// Issue #526: Minimum number of recipients required per invoice. MinRecipients, + /// Issue #882: Minimum derived reputation score to mint a reputation NFT. + RepNftThreshold, } // --------------------------------------------------------------------------- @@ -226,6 +228,8 @@ pub enum InvoiceKey { TimeLock(u64), /// Issue #869: Counter of redemption tokens issued for an invoice. RedemptionTokenCount(u64), + /// Issue #881: Arbitration tournament record for a disputed invoice. + ArbitrationTournament(u64), } // --------------------------------------------------------------------------- @@ -263,6 +267,8 @@ pub enum AddressKey { PayerHistory(Address), /// Issue #870: Aggregate performance metrics for a recipient. RecipientPerformance(Address), + /// Issue #882: Count of reputation NFTs minted for a creator. + RepNftCount(Address), } // --------------------------------------------------------------------------- @@ -291,6 +297,10 @@ pub enum CompoundKey { RecipientDelegation(u64, Address), /// Issue #869: Per-(invoice, token_id) redemption token record. RedemptionToken(u64, u64), + /// Issue #881: Per-(invoice_id, arbiter) tournament vote record. + TournamentVote(u64, Address), + /// Issue #882: Per-(creator, nft_id) reputation NFT record. + RepNft(Address, u64), } // --------------------------------------------------------------------------- diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index e11d439..d2a9d83 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -2337,3 +2337,80 @@ pub struct CreatorCovenant { /// Whether the covenant was fulfilled (invoice released on time). pub fulfilled: bool, } + +// --------------------------------------------------------------------------- +// Issue #881 – Invoice Dispute Arbitration Tournament +// --------------------------------------------------------------------------- + +/// Status of an arbitration tournament. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ArbitrationTournamentStatus { + /// Tournament is open and accepting votes. + Active, + /// All rounds have concluded and a final outcome was reached. + Completed, + /// An admin cancelled the tournament before completion. + Cancelled, +} + +/// A single arbiter's vote in a tournament round. +#[contracttype] +#[derive(Clone, Debug)] +pub struct TournamentVote { + /// Address of the arbiter who cast this vote. + pub arbiter: Address, + /// Round in which this vote was cast (1-indexed). + pub round: u32, + /// The arbiter's decision for this round. + pub decision: ResolveAction, +} + +/// On-chain record of a multi-round dispute arbitration tournament. +/// +/// A tournament runs up to `total_rounds` rounds. In each round every +/// registered arbiter casts a `Release` or `Refund` vote; the majority +/// decision is tallied by `advance_tournament`. On the final round the +/// invoice is resolved automatically. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ArbitrationTournament { + /// Invoice under arbitration. + pub invoice_id: u64, + /// Ordered list of arbiter addresses eligible to vote. + pub arbiters: Vec
, + /// Total number of voting rounds configured at tournament open. + pub total_rounds: u32, + /// Current round (starts at 1). + pub current_round: u32, + /// Lifecycle status of the tournament. + pub status: ArbitrationTournamentStatus, + /// Release votes cast in the current round (reset between rounds). + pub release_votes: u32, + /// Refund votes cast in the current round (reset between rounds). + pub refund_votes: u32, + /// Final outcome once the tournament completes (`None` while active). + pub final_outcome: Option, +} + +// --------------------------------------------------------------------------- +// Issue #882 – Creator Reputation NFT +// --------------------------------------------------------------------------- + +/// An on-chain reputation NFT record minted for a creator who has reached the +/// minimum reputation score threshold. +/// +/// NFTs are keyed by `(creator, nft_id)` in persistent storage. The +/// `nft_id` is a monotonically incrementing counter per creator. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RepNFT { + /// Creator this NFT was minted for. + pub creator: Address, + /// Zero-based identifier for this NFT within the creator's minted set. + pub nft_id: u64, + /// Derived reputation score at the time of minting. + pub score: u32, + /// Ledger sequence at which this NFT was minted. + pub minted_ledger: u32, +}