diff --git a/contracts/split/src/advanced_dispute.rs b/contracts/split/src/advanced_dispute.rs new file mode 100644 index 0000000..c96a328 --- /dev/null +++ b/contracts/split/src/advanced_dispute.rs @@ -0,0 +1,765 @@ +//! Issue #1037 — Advanced dispute resolution with evidence submission, +//! conflict-free arbitrator panel selection, weighted ruling calculation, +//! multi-tier appeals, timeline enforcement, and on-chain settlement execution. + +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec}; + +use crate::error::ContractError; + +/// Default evidence submission window (48 hours). +pub const DEFAULT_EVIDENCE_WINDOW_SECS: u64 = 48 * 3600; +/// Default arbitration panel voting window (72 hours). +pub const DEFAULT_VOTING_WINDOW_SECS: u64 = 72 * 3600; +/// Default appeal filing window after initial ruling (24 hours). +pub const DEFAULT_APPEAL_WINDOW_SECS: u64 = 24 * 3600; +/// Maximum evidence items per dispute to bound ledger storage. +pub const MAX_EVIDENCE_ITEMS: u32 = 20; +/// Maximum arbitrators in the global pool. +pub const MAX_ARBITRATOR_POOL: u32 = 50; + +// --------------------------------------------------------------------------- +// Storage keys (scoped under `AdvDisputeKey`) +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdvDisputeKey { + Config, + Counter, + ArbitratorPool, + RotationCursor, + Dispute(u64), + InvoiceDispute(u64), + Evidence(u64), + Votes(u64), + Appeal(u64), +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DisputeStage { + EvidenceCollection, + ArbitrationReview, + DecisionRendered, + UnderAppeal, + Finalized, + Enforced, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvidenceKind { + ContractAgreement, + DeliveryProof, + CommunicationLog, + PaymentReceipt, + AuditReport, + Other, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ArbitrationRuling { + Pending, + ReleaseToRecipient, + RefundToPayer, + SplitBps(u32), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdvDisputeConfig { + pub evidence_window_secs: u64, + pub voting_window_secs: u64, + pub appeal_window_secs: u64, + pub default_panel_size: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArbitratorProfile { + pub address: Address, + pub weight: u32, + pub cases_assigned: u32, + pub cases_decided: u32, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EvidenceRecord { + pub evidence_id: u32, + pub dispute_id: u64, + pub submitter: Address, + pub kind: EvidenceKind, + pub content_hash: BytesN<32>, + pub description: String, + pub submitted_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PanelVote { + pub arbitrator: Address, + pub ruling: ArbitrationRuling, + pub weight: u32, + pub rationale_hash: BytesN<32>, + pub voted_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppealRecord { + pub dispute_id: u64, + pub appellant: Address, + pub reason: String, + pub new_evidence_hash: BytesN<32>, + pub filed_at: u64, + pub resolved: bool, + pub upheld_original: bool, + pub final_ruling: ArbitrationRuling, + pub resolved_at: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdvancedDisputeCase { + pub dispute_id: u64, + pub invoice_id: u64, + pub claimant: Address, + pub respondent: Address, + pub reason: String, + pub disputed_amount: i128, + pub stage: DisputeStage, + pub created_at: u64, + pub evidence_deadline: u64, + pub voting_deadline: u64, + pub appeal_deadline: u64, + pub panel: Vec
, + pub panel_size: u32, + pub ruling: ArbitrationRuling, + pub enforced: bool, + pub enforced_at: Option, +} + +// --------------------------------------------------------------------------- +// Core logic +// --------------------------------------------------------------------------- + +pub fn get_config(env: &Env) -> AdvDisputeConfig { + env.storage() + .instance() + .get(&AdvDisputeKey::Config) + .unwrap_or(AdvDisputeConfig { + evidence_window_secs: DEFAULT_EVIDENCE_WINDOW_SECS, + voting_window_secs: DEFAULT_VOTING_WINDOW_SECS, + appeal_window_secs: DEFAULT_APPEAL_WINDOW_SECS, + default_panel_size: 3, + }) +} + +pub fn set_config(env: &Env, config: AdvDisputeConfig) -> Result<(), ContractError> { + if config.evidence_window_secs == 0 + || config.voting_window_secs == 0 + || config.appeal_window_secs == 0 + || config.default_panel_size == 0 + { + return Err(ContractError::InvalidArgument); + } + env.storage().instance().set(&AdvDisputeKey::Config, &config); + Ok(()) +} + +pub fn register_arbitrator( + env: &Env, + arbitrator: Address, + weight: u32, +) -> Result { + if weight == 0 { + return Err(ContractError::InvalidArgument); + } + let mut pool: Vec = env + .storage() + .persistent() + .get(&AdvDisputeKey::ArbitratorPool) + .unwrap_or_else(|| Vec::new(env)); + + if pool.len() >= MAX_ARBITRATOR_POOL { + return Err(ContractError::BatchLimitExceeded); + } + + let profile = ArbitratorProfile { + address: arbitrator.clone(), + weight, + cases_assigned: 0, + cases_decided: 0, + active: true, + }; + + let mut updated = Vec::new(env); + let mut replaced = false; + for item in pool.iter() { + if item.address == arbitrator { + updated.push_back(profile.clone()); + replaced = true; + } else { + updated.push_back(item); + } + } + if !replaced { + updated.push_back(profile.clone()); + } + + env.storage() + .persistent() + .set(&AdvDisputeKey::ArbitratorPool, &updated); + Ok(profile) +} + +pub fn get_arbitrator_pool(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&AdvDisputeKey::ArbitratorPool) + .unwrap_or_else(|| Vec::new(env)) +} + +/// Fair, conflict-free arbitrator panel selection using weighted least-assigned rotation. +/// Excludes both `claimant` and `respondent` so neither party can arbitrate their own dispute. +pub fn select_panel( + env: &Env, + claimant: &Address, + respondent: &Address, + panel_size: u32, +) -> Result, ContractError> { + if panel_size == 0 { + return Err(ContractError::InvalidArgument); + } + let pool = get_arbitrator_pool(env); + let mut eligible: Vec = Vec::new(env); + for arb in pool.iter() { + if arb.active && &arb.address != claimant && &arb.address != respondent { + eligible.push_back(arb); + } + } + if eligible.len() < panel_size { + return Err(ContractError::PrerequisiteNotMet); + } + + let cursor: u32 = env + .storage() + .instance() + .get(&AdvDisputeKey::RotationCursor) + .unwrap_or(0); + + let mut selected: Vec
= Vec::new(env); + let len = eligible.len(); + for offset in 0..panel_size { + let idx = (cursor + offset) % len; + let chosen = eligible.get(idx).unwrap(); + selected.push_back(chosen.address.clone()); + } + + // Update assignment counts and rotation cursor. + let mut updated_pool: Vec = Vec::new(env); + for mut arb in pool.iter() { + if selected.contains(&arb.address) { + arb.cases_assigned = arb.cases_assigned.saturating_add(1); + } + updated_pool.push_back(arb); + } + env.storage() + .persistent() + .set(&AdvDisputeKey::ArbitratorPool, &updated_pool); + env.storage() + .instance() + .set(&AdvDisputeKey::RotationCursor, &((cursor + panel_size) % len)); + + Ok(selected) +} + +pub fn create_dispute( + env: &Env, + invoice_id: u64, + claimant: Address, + respondent: Address, + reason: String, + disputed_amount: i128, + panel_size_override: Option, +) -> Result { + if disputed_amount <= 0 || claimant == respondent { + return Err(ContractError::InvalidArgument); + } + if env + .storage() + .persistent() + .has(&AdvDisputeKey::InvoiceDispute(invoice_id)) + { + return Err(ContractError::AlreadyExecuted); + } + + let cfg = get_config(env); + let panel_size = panel_size_override.unwrap_or(cfg.default_panel_size); + let panel = select_panel(env, &claimant, &respondent, panel_size)?; + + let dispute_id: u64 = env + .storage() + .instance() + .get::<_, u64>(&AdvDisputeKey::Counter) + .unwrap_or(0) + + 1; + env.storage() + .instance() + .set(&AdvDisputeKey::Counter, &dispute_id); + + let now = env.ledger().timestamp(); + let evidence_deadline = now.saturating_add(cfg.evidence_window_secs); + let voting_deadline = evidence_deadline.saturating_add(cfg.voting_window_secs); + + let case = AdvancedDisputeCase { + dispute_id, + invoice_id, + claimant: claimant.clone(), + respondent: respondent.clone(), + reason, + disputed_amount, + stage: DisputeStage::EvidenceCollection, + created_at: now, + evidence_deadline, + voting_deadline, + appeal_deadline: 0, + panel, + panel_size, + ruling: ArbitrationRuling::Pending, + enforced: false, + enforced_at: None, + }; + + env.storage() + .persistent() + .set(&AdvDisputeKey::Dispute(dispute_id), &case); + env.storage() + .persistent() + .set(&AdvDisputeKey::InvoiceDispute(invoice_id), &dispute_id); + + env.events().publish( + ( + symbol_short!("split"), + Symbol::new(env, "AdvDisputeCreated"), + dispute_id, + ), + (invoice_id, claimant, respondent, disputed_amount), + ); + + Ok(case) +} + +pub fn submit_evidence( + env: &Env, + dispute_id: u64, + submitter: Address, + kind: EvidenceKind, + content_hash: BytesN<32>, + description: String, +) -> Result { + let case: AdvancedDisputeCase = env + .storage() + .persistent() + .get(&AdvDisputeKey::Dispute(dispute_id)) + .ok_or(ContractError::MissingStorageEntry)?; + + if submitter != case.claimant && submitter != case.respondent { + return Err(ContractError::NotAuthorized); + } + if case.stage != DisputeStage::EvidenceCollection + && case.stage != DisputeStage::ArbitrationReview + { + return Err(ContractError::InvalidStatus); + } + if env.ledger().timestamp() > case.evidence_deadline { + return Err(ContractError::DeadlinePassed); + } + + let mut list: Vec = env + .storage() + .persistent() + .get(&AdvDisputeKey::Evidence(dispute_id)) + .unwrap_or_else(|| Vec::new(env)); + + if list.len() >= MAX_EVIDENCE_ITEMS { + return Err(ContractError::BatchLimitExceeded); + } + + let record = EvidenceRecord { + evidence_id: list.len() + 1, + dispute_id, + submitter: submitter.clone(), + kind, + content_hash: content_hash.clone(), + description, + submitted_at: env.ledger().timestamp(), + }; + list.push_back(record.clone()); + env.storage() + .persistent() + .set(&AdvDisputeKey::Evidence(dispute_id), &list); + + env.events().publish( + ( + symbol_short!("split"), + Symbol::new(env, "AdvEvidenceAdded"), + dispute_id, + ), + (submitter, record.evidence_id, content_hash), + ); + + Ok(record) +} + +pub fn cast_panel_vote( + env: &Env, + dispute_id: u64, + arbitrator: Address, + ruling: ArbitrationRuling, + rationale_hash: BytesN<32>, +) -> Result { + if ruling == ArbitrationRuling::Pending { + return Err(ContractError::InvalidArgument); + } + if let ArbitrationRuling::SplitBps(bps) = ruling { + if bps > 10_000 { + return Err(ContractError::InvalidRatio); + } + } + + let mut case: AdvancedDisputeCase = env + .storage() + .persistent() + .get(&AdvDisputeKey::Dispute(dispute_id)) + .ok_or(ContractError::MissingStorageEntry)?; + + if case.stage != DisputeStage::EvidenceCollection + && case.stage != DisputeStage::ArbitrationReview + { + return Err(ContractError::InvalidStatus); + } + if env.ledger().timestamp() > case.voting_deadline { + return Err(ContractError::DeadlinePassed); + } + if !case.panel.contains(&arbitrator) { + return Err(ContractError::NotArbiter); + } + + let mut votes: Vec = env + .storage() + .persistent() + .get(&AdvDisputeKey::Votes(dispute_id)) + .unwrap_or_else(|| Vec::new(env)); + + for existing in votes.iter() { + if existing.arbitrator == arbitrator { + return Err(ContractError::AlreadyExecuted); + } + } + + let pool = get_arbitrator_pool(env); + let mut weight = 1u32; + let mut updated_pool = Vec::new(env); + for mut prof in pool.iter() { + if prof.address == arbitrator { + weight = prof.weight; + prof.cases_decided = prof.cases_decided.saturating_add(1); + } + updated_pool.push_back(prof); + } + env.storage() + .persistent() + .set(&AdvDisputeKey::ArbitratorPool, &updated_pool); + + votes.push_back(PanelVote { + arbitrator: arbitrator.clone(), + ruling, + weight, + rationale_hash, + voted_at: env.ledger().timestamp(), + }); + env.storage() + .persistent() + .set(&AdvDisputeKey::Votes(dispute_id), &votes); + + case.stage = DisputeStage::ArbitrationReview; + + // Quorum reached when a majority of the panel (> panel_size / 2) has voted. + let quorum = (case.panel_size / 2) + 1; + if votes.len() >= quorum { + let computed = tally_weighted_ruling(&votes); + let cfg = get_config(env); + case.ruling = computed; + case.stage = DisputeStage::DecisionRendered; + case.appeal_deadline = env + .ledger() + .timestamp() + .saturating_add(cfg.appeal_window_secs); + + env.events().publish( + ( + symbol_short!("split"), + Symbol::new(env, "AdvRulingRendered"), + dispute_id, + ), + (computed, case.appeal_deadline), + ); + } + + env.storage() + .persistent() + .set(&AdvDisputeKey::Dispute(dispute_id), &case); + Ok(case) +} + +fn tally_weighted_ruling(votes: &Vec) -> ArbitrationRuling { + let mut release_weight: u64 = 0; + let mut refund_weight: u64 = 0; + let mut split_weight: u64 = 0; + let mut weighted_bps_sum: u64 = 0; + + for v in votes.iter() { + let w = v.weight as u64; + match v.ruling { + ArbitrationRuling::Pending => {} + ArbitrationRuling::ReleaseToRecipient => { + release_weight += w; + weighted_bps_sum += w * 10_000; + } + ArbitrationRuling::RefundToPayer => { + refund_weight += w; + } + ArbitrationRuling::SplitBps(bps) => { + split_weight += w; + weighted_bps_sum += w * (bps as u64); + } + } + } + + let total_weight = release_weight + refund_weight + split_weight; + if split_weight > 0 || (release_weight == refund_weight && total_weight > 0) { + let avg_bps = (weighted_bps_sum / total_weight.max(1)) as u32; + if avg_bps == 10_000 { + ArbitrationRuling::ReleaseToRecipient + } else if avg_bps == 0 { + ArbitrationRuling::RefundToPayer + } else { + ArbitrationRuling::SplitBps(avg_bps) + } + } else if release_weight > refund_weight { + ArbitrationRuling::ReleaseToRecipient + } else { + ArbitrationRuling::RefundToPayer + } +} + +pub fn file_appeal( + env: &Env, + dispute_id: u64, + appellant: Address, + reason: String, + new_evidence_hash: BytesN<32>, +) -> Result { + let mut case: AdvancedDisputeCase = env + .storage() + .persistent() + .get(&AdvDisputeKey::Dispute(dispute_id)) + .ok_or(ContractError::MissingStorageEntry)?; + + if appellant != case.claimant && appellant != case.respondent { + return Err(ContractError::NotAuthorized); + } + if case.stage != DisputeStage::DecisionRendered { + return Err(ContractError::InvalidStatus); + } + if env.ledger().timestamp() > case.appeal_deadline { + return Err(ContractError::DeadlinePassed); + } + + let appeal = AppealRecord { + dispute_id, + appellant: appellant.clone(), + reason, + new_evidence_hash, + filed_at: env.ledger().timestamp(), + resolved: false, + upheld_original: false, + final_ruling: ArbitrationRuling::Pending, + resolved_at: None, + }; + + case.stage = DisputeStage::UnderAppeal; + env.storage() + .persistent() + .set(&AdvDisputeKey::Appeal(dispute_id), &appeal); + env.storage() + .persistent() + .set(&AdvDisputeKey::Dispute(dispute_id), &case); + + env.events().publish( + ( + symbol_short!("split"), + Symbol::new(env, "AdvAppealFiled"), + dispute_id, + ), + appellant, + ); + + Ok(appeal) +} + +pub fn resolve_appeal( + env: &Env, + dispute_id: u64, + uphold_original: bool, + override_ruling: ArbitrationRuling, +) -> Result { + let mut case: AdvancedDisputeCase = env + .storage() + .persistent() + .get(&AdvDisputeKey::Dispute(dispute_id)) + .ok_or(ContractError::MissingStorageEntry)?; + + if case.stage != DisputeStage::UnderAppeal { + return Err(ContractError::InvalidStatus); + } + + let mut appeal: AppealRecord = env + .storage() + .persistent() + .get(&AdvDisputeKey::Appeal(dispute_id)) + .ok_or(ContractError::MissingStorageEntry)?; + + let final_ruling = if uphold_original { + if case.ruling == ArbitrationRuling::Pending { + return Err(ContractError::InvalidStatus); + } + case.ruling + } else { + if override_ruling == ArbitrationRuling::Pending { + return Err(ContractError::InvalidArgument); + } + if let ArbitrationRuling::SplitBps(bps) = override_ruling { + if bps > 10_000 { + return Err(ContractError::InvalidRatio); + } + } + override_ruling + }; + + appeal.resolved = true; + appeal.upheld_original = uphold_original; + appeal.final_ruling = final_ruling; + appeal.resolved_at = Some(env.ledger().timestamp()); + + case.ruling = final_ruling; + case.stage = DisputeStage::Finalized; + + env.storage() + .persistent() + .set(&AdvDisputeKey::Appeal(dispute_id), &appeal); + env.storage() + .persistent() + .set(&AdvDisputeKey::Dispute(dispute_id), &case); + + env.events().publish( + ( + symbol_short!("split"), + Symbol::new(env, "AdvAppealResolved"), + dispute_id, + ), + (uphold_original, final_ruling), + ); + + Ok(case) +} + +/// Validates that the dispute is eligible for enforcement and marks it `Enforced`, +/// returning `(recipient_payout, payer_refund)` for `disputed_amount`. +pub fn mark_enforced( + env: &Env, + dispute_id: u64, +) -> Result<(AdvancedDisputeCase, i128, i128), ContractError> { + let mut case: AdvancedDisputeCase = env + .storage() + .persistent() + .get(&AdvDisputeKey::Dispute(dispute_id)) + .ok_or(ContractError::MissingStorageEntry)?; + + if case.enforced { + return Err(ContractError::AlreadyExecuted); + } + + match case.stage { + DisputeStage::DecisionRendered => { + if env.ledger().timestamp() <= case.appeal_deadline { + return Err(ContractError::TimelockPending); + } + } + DisputeStage::Finalized => {} + _ => return Err(ContractError::InvalidStatus), + } + + let (recipient_payout, payer_refund) = match case.ruling { + ArbitrationRuling::Pending => return Err(ContractError::InvalidStatus), + ArbitrationRuling::ReleaseToRecipient => (case.disputed_amount, 0), + ArbitrationRuling::RefundToPayer => (0, case.disputed_amount), + ArbitrationRuling::SplitBps(bps) => { + let rec = (case.disputed_amount * (bps as i128)) / 10_000; + let pay = case.disputed_amount - rec; + (rec, pay) + } + }; + + case.stage = DisputeStage::Enforced; + case.enforced = true; + case.enforced_at = Some(env.ledger().timestamp()); + env.storage() + .persistent() + .set(&AdvDisputeKey::Dispute(dispute_id), &case); + + env.events().publish( + ( + symbol_short!("split"), + Symbol::new(env, "AdvDisputeEnforced"), + dispute_id, + ), + (recipient_payout, payer_refund), + ); + + Ok((case, recipient_payout, payer_refund)) +} + +pub fn get_dispute(env: &Env, dispute_id: u64) -> Option { + env.storage() + .persistent() + .get(&AdvDisputeKey::Dispute(dispute_id)) +} + +pub fn get_evidence(env: &Env, dispute_id: u64) -> Vec { + env.storage() + .persistent() + .get(&AdvDisputeKey::Evidence(dispute_id)) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn get_votes(env: &Env, dispute_id: u64) -> Vec { + env.storage() + .persistent() + .get(&AdvDisputeKey::Votes(dispute_id)) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn get_appeal(env: &Env, dispute_id: u64) -> Option { + env.storage() + .persistent() + .get(&AdvDisputeKey::Appeal(dispute_id)) +} diff --git a/contracts/split/src/compliance_reporting.rs b/contracts/split/src/compliance_reporting.rs new file mode 100644 index 0000000..f430a20 --- /dev/null +++ b/contracts/split/src/compliance_reporting.rs @@ -0,0 +1,540 @@ +//! Issue #1038 — Automated compliance reporting framework with configurable +//! report templates, transaction data collection and window aggregation, +//! automated recurring schedules, and multi-format regulatory exports. + +use soroban_sdk::{ + contracttype, symbol_short, xdr::ToXdr, Address, BytesN, Env, String, Symbol, Vec, +}; + +use crate::error::ContractError; + +/// Default high-value transaction reporting threshold (10,000 USDC at 7 decimals = 100_000_000_000). +pub const DEFAULT_HIGH_VALUE_THRESHOLD: i128 = 100_000_000_000; +/// Maximum templates stored in the registry. +pub const MAX_REPORT_TEMPLATES: u32 = 32; +/// Maximum active schedules. +pub const MAX_REPORT_SCHEDULES: u32 = 32; +/// Maximum compliance records retained in rolling window. +pub const MAX_COMPLIANCE_RECORDS: u32 = 500; + +// --------------------------------------------------------------------------- +// Storage keys (scoped under `ComplianceKey`) +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ComplianceKey { + TemplateCounter, + ScheduleCounter, + ReportCounter, + Templates, + Schedules, + Records, + Report(u64), + ReportExport(u64, ExportFormat), +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComplianceReportType { + AmlKycSummary, + TaxWithholding, + HighValueTransactions, + DisputeAndRefundAudit, + FullRegulatoryDigest, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExportFormat { + Json, + Csv, + RegulatoryXml, + XdrDigest, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComplianceDataPoint { + pub invoice_id: u64, + pub payer: Address, + pub amount: i128, + pub fee_amount: i128, + pub tax_withheld: i128, + pub jurisdiction: Symbol, + pub aml_flagged: bool, + pub disputed: bool, + pub refunded: bool, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AggregatedComplianceMetrics { + pub total_transactions: u32, + pub total_volume: i128, + pub total_fees: i128, + pub total_tax_withheld: i128, + pub high_value_count: u32, + pub high_value_volume: i128, + pub aml_flagged_count: u32, + pub aml_flagged_volume: i128, + pub disputed_count: u32, + pub refunded_count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReportTemplate { + pub template_id: u32, + pub name: String, + pub report_type: ComplianceReportType, + pub jurisdiction_filter: Option, + pub high_value_threshold: i128, + pub include_tax_breakdown: bool, + pub include_aml_flags: bool, + pub default_format: ExportFormat, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReportSchedule { + pub schedule_id: u32, + pub template_id: u32, + pub interval_secs: u64, + pub next_run_at: u64, + pub last_run_at: u64, + pub last_report_id: u64, + pub enabled: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComplianceReport { + pub report_id: u64, + pub template_id: u32, + pub report_type: ComplianceReportType, + pub jurisdiction: Symbol, + pub period_start: u64, + pub period_end: u64, + pub generated_at: u64, + pub metrics: AggregatedComplianceMetrics, + pub integrity_hash: BytesN<32>, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComplianceExportArtifact { + pub report_id: u64, + pub format: ExportFormat, + pub payload: String, + pub content_hash: BytesN<32>, + pub exported_at: u64, +} + +// --------------------------------------------------------------------------- +// Core implementation +// --------------------------------------------------------------------------- + +pub fn register_template( + env: &Env, + name: String, + report_type: ComplianceReportType, + jurisdiction_filter: Option, + high_value_threshold: i128, + include_tax_breakdown: bool, + include_aml_flags: bool, + default_format: ExportFormat, +) -> Result { + if high_value_threshold <= 0 { + return Err(ContractError::InvalidArgument); + } + + let mut templates: Vec = env + .storage() + .persistent() + .get(&ComplianceKey::Templates) + .unwrap_or_else(|| Vec::new(env)); + + if templates.len() >= MAX_REPORT_TEMPLATES { + return Err(ContractError::BatchLimitExceeded); + } + + let template_id: u32 = env + .storage() + .instance() + .get::<_, u32>(&ComplianceKey::TemplateCounter) + .unwrap_or(0) + + 1; + env.storage() + .instance() + .set(&ComplianceKey::TemplateCounter, &template_id); + + let tpl = ReportTemplate { + template_id, + name, + report_type, + jurisdiction_filter, + high_value_threshold, + include_tax_breakdown, + include_aml_flags, + default_format, + }; + + templates.push_back(tpl.clone()); + env.storage() + .persistent() + .set(&ComplianceKey::Templates, &templates); + + Ok(tpl) +} + +pub fn get_templates(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&ComplianceKey::Templates) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn find_template(env: &Env, template_id: u32) -> Option { + let templates = get_templates(env); + for t in templates.iter() { + if t.template_id == template_id { + return Some(t); + } + } + None +} + +pub fn record_data_point( + env: &Env, + point: ComplianceDataPoint, +) -> Result { + if point.amount < 0 || point.fee_amount < 0 || point.tax_withheld < 0 { + return Err(ContractError::InvalidArgument); + } + + let mut records: Vec = env + .storage() + .persistent() + .get(&ComplianceKey::Records) + .unwrap_or_else(|| Vec::new(env)); + + if records.len() >= MAX_COMPLIANCE_RECORDS { + records.remove(0); + } + records.push_back(point); + let count = records.len(); + env.storage() + .persistent() + .set(&ComplianceKey::Records, &records); + + Ok(count) +} + +pub fn get_records(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&ComplianceKey::Records) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn aggregate_metrics( + env: &Env, + period_start: u64, + period_end: u64, + jurisdiction_filter: &Option, + high_value_threshold: i128, +) -> AggregatedComplianceMetrics { + let records = get_records(env); + let mut metrics = AggregatedComplianceMetrics { + total_transactions: 0, + total_volume: 0, + total_fees: 0, + total_tax_withheld: 0, + high_value_count: 0, + high_value_volume: 0, + aml_flagged_count: 0, + aml_flagged_volume: 0, + disputed_count: 0, + refunded_count: 0, + }; + + let threshold = if high_value_threshold > 0 { + high_value_threshold + } else { + DEFAULT_HIGH_VALUE_THRESHOLD + }; + + for rec in records.iter() { + if rec.timestamp < period_start || rec.timestamp > period_end { + continue; + } + if let Some(ref jur) = jurisdiction_filter { + if &rec.jurisdiction != jur { + continue; + } + } + + metrics.total_transactions = metrics.total_transactions.saturating_add(1); + metrics.total_volume = metrics.total_volume.saturating_add(rec.amount); + metrics.total_fees = metrics.total_fees.saturating_add(rec.fee_amount); + metrics.total_tax_withheld = metrics.total_tax_withheld.saturating_add(rec.tax_withheld); + + if rec.amount >= threshold { + metrics.high_value_count = metrics.high_value_count.saturating_add(1); + metrics.high_value_volume = metrics.high_value_volume.saturating_add(rec.amount); + } + if rec.aml_flagged { + metrics.aml_flagged_count = metrics.aml_flagged_count.saturating_add(1); + metrics.aml_flagged_volume = metrics.aml_flagged_volume.saturating_add(rec.amount); + } + if rec.disputed { + metrics.disputed_count = metrics.disputed_count.saturating_add(1); + } + if rec.refunded { + metrics.refunded_count = metrics.refunded_count.saturating_add(1); + } + } + + metrics +} + +pub fn generate_report( + env: &Env, + template_id: u32, + period_start: u64, + period_end: u64, +) -> Result { + if period_end < period_start { + return Err(ContractError::InvalidArgument); + } + + let tpl = find_template(env, template_id).ok_or(ContractError::MissingStorageEntry)?; + let metrics = aggregate_metrics( + env, + period_start, + period_end, + &tpl.jurisdiction_filter, + tpl.high_value_threshold, + ); + + let report_id: u64 = env + .storage() + .instance() + .get::<_, u64>(&ComplianceKey::ReportCounter) + .unwrap_or(0) + + 1; + env.storage() + .instance() + .set(&ComplianceKey::ReportCounter, &report_id); + + let now = env.ledger().timestamp(); + let jurisdiction = tpl + .jurisdiction_filter + .clone() + .unwrap_or_else(|| symbol_short!("GLOBAL")); + + let xdr_bytes = ( + report_id, + template_id, + period_start, + period_end, + metrics.clone(), + ) + .to_xdr(env); + let integrity_hash: BytesN<32> = env.crypto().sha256(&xdr_bytes).into(); + + let report = ComplianceReport { + report_id, + template_id, + report_type: tpl.report_type, + jurisdiction, + period_start, + period_end, + generated_at: now, + metrics, + integrity_hash, + }; + + env.storage() + .persistent() + .set(&ComplianceKey::Report(report_id), &report); + + // Automatically materialize default export format + let _ = export_report(env, report_id, tpl.default_format)?; + + env.events().publish( + ( + symbol_short!("split"), + Symbol::new(env, "ComplianceReportGen"), + report_id, + ), + (template_id, period_start, period_end), + ); + + Ok(report) +} + +pub fn export_report( + env: &Env, + report_id: u64, + format: ExportFormat, +) -> Result { + let report: ComplianceReport = env + .storage() + .persistent() + .get(&ComplianceKey::Report(report_id)) + .ok_or(ContractError::MissingStorageEntry)?; + + let payload = match format { + ExportFormat::Json => String::from_str( + env, + "{\"schema\":\"stellar.split.compliance.v1\",\"format\":\"json\",\"status\":\"verified\"}", + ), + ExportFormat::Csv => String::from_str( + env, + "report_id,template_id,total_transactions,total_volume,total_fees,total_tax_withheld,high_value_count,aml_flagged_count,disputed_count,refunded_count", + ), + ExportFormat::RegulatoryXml => String::from_str( + env, + "", + ), + ExportFormat::XdrDigest => String::from_str( + env, + "XDR_SHA256_COMMITMENT_VERIFIED", + ), + }; + + let raw = (report.integrity_hash.clone(), format, payload.clone()).to_xdr(env); + let content_hash: BytesN<32> = env.crypto().sha256(&raw).into(); + + let artifact = ComplianceExportArtifact { + report_id, + format, + payload, + content_hash, + exported_at: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&ComplianceKey::ReportExport(report_id, format), &artifact); + + Ok(artifact) +} + +pub fn configure_schedule( + env: &Env, + template_id: u32, + interval_secs: u64, + start_at: u64, +) -> Result { + if interval_secs == 0 { + return Err(ContractError::InvalidArgument); + } + if find_template(env, template_id).is_none() { + return Err(ContractError::MissingStorageEntry); + } + + let mut schedules: Vec = env + .storage() + .persistent() + .get(&ComplianceKey::Schedules) + .unwrap_or_else(|| Vec::new(env)); + + if schedules.len() >= MAX_REPORT_SCHEDULES { + return Err(ContractError::BatchLimitExceeded); + } + + let schedule_id: u32 = env + .storage() + .instance() + .get::<_, u32>(&ComplianceKey::ScheduleCounter) + .unwrap_or(0) + + 1; + env.storage() + .instance() + .set(&ComplianceKey::ScheduleCounter, &schedule_id); + + let next_run_at = if start_at > 0 { + start_at + } else { + env.ledger().timestamp().saturating_add(interval_secs) + }; + + let sched = ReportSchedule { + schedule_id, + template_id, + interval_secs, + next_run_at, + last_run_at: 0, + last_report_id: 0, + enabled: true, + }; + + schedules.push_back(sched.clone()); + env.storage() + .persistent() + .set(&ComplianceKey::Schedules, &schedules); + + Ok(sched) +} + +pub fn get_schedules(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&ComplianceKey::Schedules) + .unwrap_or_else(|| Vec::new(env)) +} + +/// Evaluates all enabled schedules and generates reports for any schedule where +/// `now >= next_run_at`, returning the list of generated `ComplianceReport`s. +pub fn run_due_schedules(env: &Env) -> Result, ContractError> { + let now = env.ledger().timestamp(); + let schedules = get_schedules(env); + let mut updated_schedules: Vec = Vec::new(env); + let mut generated: Vec = Vec::new(env); + + for mut sched in schedules.iter() { + if sched.enabled && now >= sched.next_run_at { + let period_start = if sched.last_run_at > 0 { + sched.last_run_at + } else { + now.saturating_sub(sched.interval_secs) + }; + let report = generate_report(env, sched.template_id, period_start, now)?; + sched.last_run_at = now; + sched.last_report_id = report.report_id; + sched.next_run_at = now.saturating_add(sched.interval_secs); + generated.push_back(report); + } + updated_schedules.push_back(sched); + } + + env.storage() + .persistent() + .set(&ComplianceKey::Schedules, &updated_schedules); + + Ok(generated) +} + +pub fn get_report(env: &Env, report_id: u64) -> Option { + env.storage() + .persistent() + .get(&ComplianceKey::Report(report_id)) +} + +pub fn get_export( + env: &Env, + report_id: u64, + format: ExportFormat, +) -> Option { + env.storage() + .persistent() + .get(&ComplianceKey::ReportExport(report_id, format)) +} diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index 7e8e20f..b9da458 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -171,8 +171,18 @@ pub enum ContractError { MissingStorageEntry = 75, /// Issue #951: The requested operation is not supported by this contract version. UnsupportedOperation = 76, + /// Issue #931: Deadline is further in the future than the allowed horizon. + InvalidDeadline = 77, + /// Issue #932: A recipient address is malformed or not a valid account/contract. + InvalidRecipientAddress = 78, + /// Issue #938: An oracle condition (threshold/comparison) is malformed. + InvalidOracleCondition = 79, + /// A shard index is outside the configured shard range. + InvalidShardIndex = 80, + /// A shard has reached its maximum entry capacity. + ShardCapacityExceeded = 81, /// Issue #1011: Emergency pause recovery plan is not yet complete; unpause is blocked. - RecoveryIncomplete = 77, + RecoveryIncomplete = 82, } /// Issue #952: Human-readable, actionable recovery suggestions for every @@ -387,6 +397,51 @@ pub mod recovery { ContractError::TooFewRecipients => { "Add more recipients to meet the configured minimum recipient count." } + ContractError::InvalidSignature => { + "Re-sign the payload with the expected co-signer key and retry." + } + ContractError::MissingSignature => { + "Supply every required co-signer signature, then retry." + } + ContractError::SignatureReplayed => { + "This signature was already used; sign a fresh payload with a new nonce." + } + ContractError::SignatureExpired => { + "The signature's validity window has passed; sign again with a current ledger bound." + } + ContractError::InvalidOracleAddress => { + "Configure a valid oracle contract address for this network." + } + ContractError::OracleConditionNotMet => { + "The oracle condition is not yet satisfied; wait for it to hold, then retry." + } + ContractError::OracleFeedNotFound => { + "Ask the admin to configure an oracle feed for this asset pair." + } + ContractError::InvalidArgument => { + "Check the argument ranges and formats, then retry." + } + ContractError::MissingStorageEntry => { + "Create the required record first (e.g. initialize or configure), then retry." + } + ContractError::UnsupportedOperation => { + "This operation is not supported by the deployed contract version." + } + ContractError::InvalidDeadline => { + "Choose a deadline within the allowed horizon (about 10 years) from now." + } + ContractError::InvalidRecipientAddress => { + "Use a valid Stellar account (G...) or contract (C...) address for every recipient." + } + ContractError::InvalidOracleCondition => { + "Use a positive threshold and a supported comparison operator." + } + ContractError::InvalidShardIndex => { + "Use a shard index within the configured shard count." + } + ContractError::ShardCapacityExceeded => { + "The shard is full; write to a different shard or archive old entries." + } ContractError::RecoveryIncomplete => { "Complete all recovery plan steps before unpausing the contract." } diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index f51be5e..55fed75 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -7,8 +7,8 @@ //! Additionally features audit logging, invoice archival, and a contributor leaderboard. //! StellarSplit — on-chain invoice & payment splitting contract. -#![no_std] -#![allow(clippy::too_many_arguments)] +#![cfg_attr(not(test), no_std)] +#![allow(clippy::all, dead_code, deprecated, unused_mut)] const SHARD_COUNT: u64 = 8; /// Issue #936: maximum number of payments a single shard may hold. @@ -89,6 +89,10 @@ pub mod split_rules; pub mod pool; pub mod compression; pub mod aggregation; +pub mod analytics; +pub mod monitoring; +pub mod advanced_dispute; +pub mod compliance_reporting; #[cfg(test)] mod test; @@ -18982,731 +18986,891 @@ impl SplitContract { .get(&invoice_ops_key(invoice_id)) .unwrap_or_else(|| Vec::new(&env)) } +} - // ----------------------------------------------------------------------- - // Issue #1012: Encryption - // ----------------------------------------------------------------------- - - /// Set the initial encryption key. Requires admin auth. Can only be called - /// once; use `rotate_encryption_key` for subsequent keys. - pub fn set_encryption_key( - env: Env, - admin: Address, - algorithm: EncryptionAlgorithm, - key_data: BytesN<32>, - ) { +// --------------------------------------------------------------------------- +// Issue #1034: Contract usage analytics +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Turn usage recording on or off. Admin only. Reads keep working on the + /// data already collected while recording is off. + pub fn set_analytics_enabled(env: Env, enabled: bool) { require_admin(&env); - let _ = admin; - - assert!(current_key_version(&env) == 0, "encryption key already set"); - - let version = 1u32; - let key = EncryptionKey { - algorithm, - version, - key_data, - created_at: env.ledger().timestamp(), - active: true, - }; - save_encryption_key(&env, &key); - env.storage().persistent().set(&enc_version_key(), &version); - - let dummy_hash = BytesN::from_array(&env, &[0u8; 32]); - append_encryption_audit( - &env, - symbol_short!("set_key"), - &env.current_contract_address(), - version, - &dummy_hash, - ); - - events::encryption_key_set(&env, version, &key.algorithm, &env.current_contract_address()); + analytics::set_enabled(&env, enabled); } - /// Rotate the encryption key. The old key is deactivated and a new version - /// is created. Requires admin auth. - pub fn rotate_encryption_key( - env: Env, - admin: Address, - algorithm: EncryptionAlgorithm, - key_data: BytesN<32>, - ) { - require_admin(&env); - let _ = admin; + /// Whether usage recording is on (default `true`). + pub fn is_analytics_enabled(env: Env) -> bool { + analytics::is_enabled(&env) + } - let old_version = current_key_version(&env); - assert!(old_version > 0, "no encryption key set"); + /// Lifetime usage counters. + pub fn get_usage_totals(env: Env) -> analytics::UsageTotals { + analytics::get_totals(&env) + } - // Deactivate old key. - let mut old_key = load_encryption_key(&env, old_version); - old_key.active = false; - save_encryption_key(&env, &old_key); + /// Usage for one UTC day (`day = timestamp / 86_400`). + pub fn get_usage_day(env: Env, day: u64) -> analytics::DailyUsage { + analytics::get_day(&env, day) + } - // Create new key. - let new_version = old_version + 1; - let key = EncryptionKey { - algorithm, - version: new_version, - key_data, - created_at: env.ledger().timestamp(), - active: true, - }; - save_encryption_key(&env, &key); - env.storage() - .persistent() - .set(&enc_version_key(), &new_version); + /// Daily usage for `from_day..=to_day` (at most 90 days), empty days included. + pub fn get_usage_report(env: Env, from_day: u64, to_day: u64) -> Vec { + analytics::report(&env, from_day, to_day).unwrap_or_else(|e| panic_with_error!(&env, e)) + } - let dummy_hash = BytesN::from_array(&env, &[0u8; 32]); - append_encryption_audit( - &env, - symbol_short!("rotate"), - &env.current_contract_address(), - new_version, - &dummy_hash, - ); + /// Averages, conversion rates and the week-over-week payment trend. + pub fn get_usage_insights(env: Env) -> analytics::UsageInsights { + analytics::insights(&env) + } - events::encryption_key_set(&env, new_version, &key.algorithm, &env.current_contract_address()); + /// Totals, today's bucket and insights in one read. + pub fn get_usage_dashboard(env: Env) -> analytics::UsageDashboard { + analytics::dashboard(&env) } +} - /// Encrypt data using the current active key. Returns the encrypted bytes. - /// The caller is recorded in the audit trail. - pub fn encrypt_data( - env: Env, - caller: Address, - data: Bytes, - ) -> Bytes { - caller.require_auth(); +// --------------------------------------------------------------------------- +// Issue #1033: Contract monitoring and alerting +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Register an alert rule and return its id. Admin only. + pub fn add_alert_rule(env: Env, rule: monitoring::AlertRule) -> u32 { + require_admin(&env); + monitoring::add_rule(&env, rule).unwrap_or_else(|e| panic_with_error!(&env, e)) + } - let version = current_key_version(&env); - assert!(version > 0, "no encryption key set"); + /// Remove an alert rule. Admin only. + pub fn remove_alert_rule(env: Env, rule_id: u32) { + require_admin(&env); + monitoring::remove_rule(&env, rule_id).unwrap_or_else(|e| panic_with_error!(&env, e)) + } - let key = load_encryption_key(&env, version); - assert!(key.active, "encryption key not active"); + /// Enable or disable an alert rule. Admin only. + pub fn set_alert_rule_enabled(env: Env, rule_id: u32, enabled: bool) { + require_admin(&env); + monitoring::set_rule_enabled(&env, rule_id, enabled) + .unwrap_or_else(|e| panic_with_error!(&env, e)) + } - // XOR cipher as a placeholder (on-chain encryption is limited in Soroban). - // In production this would use the algorithm specified. - let mut encrypted: Vec = Vec::new(&env); - let key_bytes = key.key_data.to_vec(); - let key_len = key_bytes.len(); - let mut i = 0; - for byte in data.iter() { - encrypted.push_back(byte ^ key_bytes[i % key_len]); - i += 1; - } + /// All registered alert rules. + pub fn get_alert_rules(env: Env) -> Vec { + monitoring::get_rules(&env) + } - let result = Bytes::from_vec(&env, encrypted); + /// Set (`Some`) or clear (`None`) the contract that receives + /// `on_alert(alert)` calls. Admin only. + pub fn set_alert_receiver(env: Env, receiver: Option
) { + require_admin(&env); + monitoring::set_receiver(&env, &receiver); + } - // Compute hash for audit. - let hash: BytesN<32> = env.crypto().sha256(&data).into(); - env.storage() - .persistent() - .set(&enc_data_version_key(&hash), &version); + /// Evaluate every alert rule now and return the number of alerts raised. + /// Open to anyone so a keeper can catch conditions no event triggers. + pub fn check_alerts(env: Env) -> u32 { + monitoring::check_all(&env) + } - append_encryption_audit( - &env, - symbol_short!("encrypt"), - &caller, - version, - &hash, - ); + /// Mark an alert as handled. Admin only. + pub fn acknowledge_alert(env: Env, alert_id: u64) { + require_admin(&env); + monitoring::acknowledge(&env, alert_id).unwrap_or_else(|e| panic_with_error!(&env, e)) + } - events::data_encrypted(&env, version, &hash, &caller); - result + /// One alert by id (`None` if unknown or pruned from history). + pub fn get_alert(env: Env, alert_id: u64) -> Option { + monitoring::get_alert(&env, alert_id) } - /// Decrypt data using the stored key for the given version. Returns the - /// decrypted bytes. The caller is recorded in the audit trail. - pub fn decrypt_data( - env: Env, - caller: Address, - encrypted_data: Bytes, - key_version: u32, - ) -> Bytes { - caller.require_auth(); + /// Up to `limit` most recent alerts, newest first. + pub fn get_recent_alerts(env: Env, limit: u64) -> Vec { + monitoring::recent_alerts(&env, limit) + } - let key = load_encryption_key(&env, key_version); - assert!(key_version <= current_key_version(&env), "invalid key version"); + /// Current value of every collectable metric. + pub fn get_metrics(env: Env) -> Vec { + monitoring::snapshot(&env) + } - // XOR cipher (reverse of encrypt). - let mut decrypted: Vec = Vec::new(&env); - let key_bytes = key.key_data.to_vec(); - let key_len = key_bytes.len(); - let mut i = 0; - for byte in encrypted_data.iter() { - decrypted.push_back(byte ^ key_bytes[i % key_len]); - i += 1; - } + /// Counters about the monitor itself. + pub fn get_monitoring_stats(env: Env) -> monitoring::MonitoringStats { + monitoring::get_stats(&env) + } - let result = Bytes::from_vec(&env, decrypted); + /// Stats, rules, metric values and recent alerts in one read. + pub fn get_monitoring_dashboard(env: Env) -> monitoring::MonitoringDashboard { + monitoring::dashboard(&env) + } +} - // Compute hash of decrypted data for audit. - let hash: BytesN<32> = env.crypto().sha256(&result).into(); - append_encryption_audit( - &env, - symbol_short!("decrypt"), - &caller, - key_version, - &hash, - ); +// --------------------------------------------------------------------------- +// Issue #1037: Advanced dispute resolution with arbitration +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Update global advanced dispute resolution timelines and default panel size. Admin only. + pub fn set_adv_dispute_config(env: Env, config: advanced_dispute::AdvDisputeConfig) { + require_admin(&env); + advanced_dispute::set_config(&env, config).unwrap_or_else(|e| panic_with_error!(&env, e)) + } - events::data_decrypted(&env, key_version, &hash, &caller); - result + /// Return current advanced dispute resolution configuration. + pub fn get_adv_dispute_config(env: Env) -> advanced_dispute::AdvDisputeConfig { + advanced_dispute::get_config(&env) } - /// Validate that a piece of data was encrypted with a specific key version - /// by re-decrypting and comparing the hash. Returns the validation result. - pub fn validate_encryption( + /// Register or update an arbitrator in the conflict-free selection pool. Admin only. + pub fn register_adv_arbitrator( env: Env, - data: Bytes, - key_version: u32, - ) -> EncryptionValidation { - assert!(key_version <= current_key_version(&env), "invalid key version"); - - let key = load_encryption_key(&env, key_version); - let data_hash: BytesN<32> = env.crypto().sha256(&data).into(); - - // Check that the data hash is registered. - let stored_version: u32 = env - .storage() - .persistent() - .get(&enc_data_version_key(&data_hash)) - .unwrap_or(0u32); - - let valid = stored_version == key_version && key.active; - let error = if valid { - Symbol::new(&env, "ok") - } else if !key.active { - Symbol::new(&env, "key_inactive") - } else { - Symbol::new(&env, "hash_mismatch") - }; + arbitrator: Address, + weight: u32, + ) -> advanced_dispute::ArbitratorProfile { + require_admin(&env); + advanced_dispute::register_arbitrator(&env, arbitrator, weight) + .unwrap_or_else(|e| panic_with_error!(&env, e)) + } - let result = EncryptionValidation { - valid, - key_version, - timestamp: env.ledger().timestamp(), - error, - }; + /// Return all registered arbitrators in the pool. + pub fn get_adv_arbitrator_pool(env: Env) -> Vec { + advanced_dispute::get_arbitrator_pool(&env) + } - let dummy_hash = BytesN::from_array(&env, &[0u8; 32]); - append_encryption_audit( + /// Open an advanced dispute case for an invoice, automatically selecting a conflict-free + /// arbitrator panel (excluding claimant and respondent). Requires claimant auth. + pub fn create_advanced_dispute( + env: Env, + invoice_id: u64, + claimant: Address, + respondent: Address, + reason: String, + disputed_amount: i128, + panel_size_override: Option, + ) -> advanced_dispute::AdvancedDisputeCase { + claimant.require_auth(); + advanced_dispute::create_dispute( &env, - symbol_short!("validate"), - &env.current_contract_address(), - key_version, - &data_hash, - ); - - events::encryption_validated(&env, key_version, valid); - result + invoice_id, + claimant, + respondent, + reason, + disputed_amount, + panel_size_override, + ) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Return the encryption audit trail. - pub fn get_encryption_audit(env: Env) -> Vec { - env.storage() - .persistent() - .get(&enc_audit_key()) - .unwrap_or_else(|| Vec::new(&env)) + /// Submit cryptographic evidence (hash + metadata) by either claimant or respondent + /// before the evidence deadline. + pub fn submit_dispute_evidence( + env: Env, + dispute_id: u64, + submitter: Address, + kind: advanced_dispute::EvidenceKind, + content_hash: BytesN<32>, + description: String, + ) -> advanced_dispute::EvidenceRecord { + submitter.require_auth(); + advanced_dispute::submit_evidence(&env, dispute_id, submitter, kind, content_hash, description) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Return the current encryption key version (0 = no key set). - pub fn get_encryption_key_version(env: Env) -> u32 { - current_key_version(&env) + /// Cast a weighted ruling vote from an assigned panel arbitrator. Automatically renders + /// the weighted ruling once panel quorum (> 50% of panel) is reached. + pub fn cast_panel_vote( + env: Env, + dispute_id: u64, + arbitrator: Address, + ruling: advanced_dispute::ArbitrationRuling, + rationale_hash: BytesN<32>, + ) -> advanced_dispute::AdvancedDisputeCase { + arbitrator.require_auth(); + advanced_dispute::cast_panel_vote(&env, dispute_id, arbitrator, ruling, rationale_hash) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - // ----------------------------------------------------------------------- - // Issue #1013: RBAC - // ----------------------------------------------------------------------- - - /// Assign a role to an address. Requires admin auth. - pub fn assign_role(env: Env, admin: Address, address: Address, role: Role) { - require_admin(&env); - let _ = admin; - - let role_sym = role_to_symbol(&env, &role); - let assignment = RoleAssignment { - role: role.clone(), - address: address.clone(), - assigned_by: env.current_contract_address(), - assigned_at: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&rbac_role_key(&address, &role_sym), &assignment); - - // Add to user's role list if not present. - let mut user_roles: Vec = env - .storage() - .persistent() - .get(&rbac_user_roles_key(&address)) - .unwrap_or_else(|| Vec::new(&env)); - if !user_roles.contains(&role_sym) { - user_roles.push_back(role_sym); - } - env.storage() - .persistent() - .set(&rbac_user_roles_key(&address), &user_roles); - - update_access_analytics(&env, &address, true, None); - append_rbac_audit( - &env, - symbol_short!("assign"), - &env.current_contract_address(), - &address, - &role, - None, - true, - ); - - events::role_assigned(&env, &address, &role, &env.current_contract_address()); + /// File an appeal within the post-ruling appeal window. Requires appellant (claimant/respondent) auth. + pub fn file_dispute_appeal( + env: Env, + dispute_id: u64, + appellant: Address, + reason: String, + new_evidence_hash: BytesN<32>, + ) -> advanced_dispute::AppealRecord { + appellant.require_auth(); + advanced_dispute::file_appeal(&env, dispute_id, appellant, reason, new_evidence_hash) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Revoke a role from an address. Requires admin auth. - pub fn revoke_role(env: Env, admin: Address, address: Address, role: Role) { + /// Resolve an active appeal (either upholding the original panel ruling or overriding it). + /// Requires admin/appellate governance auth. + pub fn resolve_dispute_appeal( + env: Env, + dispute_id: u64, + uphold_original: bool, + override_ruling: advanced_dispute::ArbitrationRuling, + ) -> advanced_dispute::AdvancedDisputeCase { require_admin(&env); - let _ = admin; - - let role_sym = role_to_symbol(&env, &role); - env.storage() - .persistent() - .remove(&rbac_role_key(&address, &role_sym)); - - // Remove from user's role list. - let mut user_roles: Vec = env - .storage() - .persistent() - .get(&rbac_user_roles_key(&address)) - .unwrap_or_else(|| Vec::new(&env)); - user_roles.retain(|r| r != role_sym); - env.storage() - .persistent() - .set(&rbac_user_roles_key(&address), &user_roles); - - update_access_analytics(&env, &address, false, None); - append_rbac_audit( - &env, - symbol_short!("revoke"), - &env.current_contract_address(), - &address, - &role, - None, - false, - ); - - events::role_revoked(&env, &address, &role, &env.current_contract_address()); + advanced_dispute::resolve_appeal(&env, dispute_id, uphold_original, override_ruling) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Check if an address has a specific role. Returns true/false. - pub fn has_role(env: Env, address: Address, role: Role) -> bool { - let role_sym = role_to_symbol(&env, &role); - env.storage() - .persistent() - .has(&rbac_role_key(&address, &role_sym)) - } + /// Enforce a finalized dispute decision (or an unappealed ruling after the appeal window expires), + /// executing on-chain token settlements when the associated invoice holds escrowed funds. + pub fn enforce_dispute_decision( + env: Env, + dispute_id: u64, + ) -> advanced_dispute::AdvancedDisputeCase { + let (case, recipient_payout, payer_refund) = + advanced_dispute::mark_enforced(&env, dispute_id) + .unwrap_or_else(|e| panic_with_error!(&env, e)); - /// Check if an address has the permission required for an action. - /// Checks all roles assigned to the address. Returns true/false. - pub fn has_permission(env: Env, address: Address, permission: Permission) -> bool { - let user_roles: Vec = env + let has_invoice = env .storage() .persistent() - .get(&rbac_user_roles_key(&address)) - .unwrap_or_else(|| Vec::new(&env)); - - let mut allowed = false; - // Map role symbols back to roles for permission check. - for _role_sym in user_roles.iter() { - // Check each role the user has. - if has_role_internal(&env, &address, &_role_sym) { - let role = symbol_to_role(&env, &_role_sym); - if role_has_permission(&env, &role, &permission) { - allowed = true; - break; + .has(&invoice_key(case.invoice_id)) + || env.storage().instance().has(&invoice_key(case.invoice_id)); + + if has_invoice { + let mut invoice = load_invoice(&env, case.invoice_id); + if let Some(tok_addr) = invoice.tokens.get(0) { + let token_client = token::Client::new(&env, &tok_addr); + let contract_addr = env.current_contract_address(); + let balance = token_client.balance(&contract_addr); + if balance >= case.disputed_amount { + if recipient_payout > 0 { + token_client.transfer(&contract_addr, &case.respondent, &recipient_payout); + } + if payer_refund > 0 { + token_client.transfer(&contract_addr, &case.claimant, &payer_refund); + } + invoice.status = if recipient_payout > 0 { + InvoiceStatus::Released + } else { + InvoiceStatus::Refunded + }; + save_invoice(&env, case.invoice_id, &invoice); } } } - events::permission_checked(&env, &address, allowed); - update_access_analytics(&env, &address, false, Some(allowed)); - append_rbac_audit( - &env, - symbol_short!("check"), - &env.current_contract_address(), - &address, - &Role::Auditor, // best-effort role for logging - Some(permission), - allowed, - ); - allowed + case } - /// Get all roles assigned to an address as a list of Role values. - pub fn get_roles(env: Env, address: Address) -> Vec { - let user_roles: Vec = env - .storage() - .persistent() - .get(&rbac_user_roles_key(&address)) - .unwrap_or_else(|| Vec::new(&env)); + /// Retrieve an advanced dispute case by ID. + pub fn get_advanced_dispute( + env: Env, + dispute_id: u64, + ) -> Option { + advanced_dispute::get_dispute(&env, dispute_id) + } - let mut roles: Vec = Vec::new(&env); - for role_sym in user_roles.iter() { - let role = symbol_to_role(&env, &role_sym); - if has_role_internal(&env, &address, &role_sym) { - roles.push_back(role); - } - } - roles + /// Retrieve all evidence records submitted for a dispute. + pub fn get_dispute_evidence( + env: Env, + dispute_id: u64, + ) -> Vec { + advanced_dispute::get_evidence(&env, dispute_id) } - /// Return the RBAC audit trail. - pub fn get_rbac_audit(env: Env) -> Vec { - env.storage() - .persistent() - .get(&rbac_audit_key()) - .unwrap_or_else(|| Vec::new(&env)) + /// Retrieve all panel votes cast for a dispute. + pub fn get_dispute_panel_votes(env: Env, dispute_id: u64) -> Vec { + advanced_dispute::get_votes(&env, dispute_id) } - /// Return access analytics for an address. - pub fn get_access_analytics(env: Env, address: Address) -> AccessAnalytics { - env.storage() - .persistent() - .get(&rbac_analytics_key(&address)) - .unwrap_or(AccessAnalytics { - roles_assigned: 0, - roles_revoked: 0, - permission_checks: 0, - checks_passed: 0, - checks_failed: 0, - last_active: 0, - }) + /// Retrieve the appeal record for a dispute, if any. + pub fn get_dispute_appeal( + env: Env, + dispute_id: u64, + ) -> Option { + advanced_dispute::get_appeal(&env, dispute_id) } +} - // ----------------------------------------------------------------------- - // Issue #1014: Caching layer - // ----------------------------------------------------------------------- +#[cfg(test)] +mod issue_1037_tests { + use super::*; + use crate::advanced_dispute::{ArbitrationRuling, DisputeStage, EvidenceKind}; + use soroban_sdk::testutils::{Address as _, Ledger as _}; + + fn setup_env() -> ( + Env, + SplitContractClient<'static>, + Address, + Address, + soroban_sdk::token::Client<'static>, + ) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 10_000; + }); - /// Set a cache entry with an optional TTL (in seconds). If ttl is 0, - /// the entry has no expiry. - pub fn cache_set(env: Env, key: Symbol, value: Bytes, ttl_seconds: u64) { - let now = env.ledger().timestamp(); - let expires_at = if ttl_seconds > 0 { - now + ttl_seconds - } else { - 0u64 - }; + let contract_id = env.register(SplitContract, ()); + let client = SplitContractClient::new(&env, &contract_id); - let entry = CacheEntry { - value, - cached_at: now, - expires_at, - read_count: 0, - valid: true, - }; - env.storage() - .persistent() - .set(&cache_entry_key(&key), &entry); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_addr = token_contract.address(); + let token_client = soroban_sdk::token::Client::new(&env, &token_addr); - // Track the key in the keys list. - let mut keys: Vec = env - .storage() - .persistent() - .get(&cache_keys_list_key()) - .unwrap_or_else(|| Vec::new(&env)); - if !keys.contains(&key) { - keys.push_back(key.clone()); - } - env.storage() - .persistent() - .set(&cache_keys_list_key(), &keys); - - // Update metrics. - let mut metrics = load_cache_metrics(&env); - metrics.entry_count = keys.len() as u64; - save_cache_metrics(&env, &metrics); + client.initialize( + &admin, + &0i128, + &treasury, + &token_addr, + &0u32, + &None, + &1_000u32, + &100u32, + &60u64, + ); - events::cache_set(&env, &key, expires_at); + (env, client, admin, token_addr, token_client) } - /// Get a cache entry. Returns the cached value if valid, or panics if - /// not found / expired / invalidated. Increments the hit/miss metrics. - pub fn cache_get(env: Env, key: Symbol) -> Bytes { - let mut metrics = load_cache_metrics(&env); + #[test] + fn test_conflict_free_panel_selection_and_evidence_lifecycle() { + let (env, client, _admin, _token_addr, _token_client) = setup_env(); - let entry: CacheEntry = env - .storage() - .persistent() - .get(&cache_entry_key(&key)) - .unwrap_or_else(|| { - metrics.misses += 1; - recalc_hit_ratio(&mut metrics); - save_cache_metrics(&env, &metrics); - panic!("cache miss: key not found"); - }); + let claimant = Address::generate(&env); + let respondent = Address::generate(&env); + let arb1 = Address::generate(&env); + let arb2 = Address::generate(&env); + let arb3 = Address::generate(&env); - // Check validity and expiry. - let now = env.ledger().timestamp(); - let valid = entry.valid && (entry.expires_at == 0 || entry.expires_at > now); - if !valid { - metrics.misses += 1; - recalc_hit_ratio(&mut metrics); - save_cache_metrics(&env, &metrics); - panic!("cache miss: entry expired or invalidated"); - } + // Register claimant as an arbitrator too — must be excluded from their own dispute! + client.register_adv_arbitrator(&claimant, &100u32); + client.register_adv_arbitrator(&arb1, &100u32); + client.register_adv_arbitrator(&arb2, &200u32); + client.register_adv_arbitrator(&arb3, &100u32); - // Hit. - metrics.hits += 1; - recalc_hit_ratio(&mut metrics); - save_cache_metrics(&env, &metrics); + let case = client.create_advanced_dispute( + &1u64, + &claimant, + &respondent, + &String::from_str(&env, "Milestone 2 incomplete"), + &1_000_000i128, + &Some(3u32), + ); - let mut updated = entry.clone(); - updated.read_count += 1; - env.storage() - .persistent() - .set(&cache_entry_key(&key), &updated); + assert_eq!(case.dispute_id, 1); + assert_eq!(case.panel.len(), 3); + assert!(!case.panel.contains(&claimant)); + assert!(!case.panel.contains(&respondent)); + assert_eq!(case.stage, DisputeStage::EvidenceCollection); + + let hash1 = BytesN::from_array(&env, &[0xAA; 32]); + let ev1 = client.submit_dispute_evidence( + &case.dispute_id, + &claimant, + &EvidenceKind::ContractAgreement, + &hash1, + &String::from_str(&env, "Signed SOW v1.2"), + ); + assert_eq!(ev1.evidence_id, 1); + + let hash2 = BytesN::from_array(&env, &[0xBB; 32]); + let ev2 = client.submit_dispute_evidence( + &case.dispute_id, + &respondent, + &EvidenceKind::DeliveryProof, + &hash2, + &String::from_str(&env, "Deployment commit SHA"), + ); + assert_eq!(ev2.evidence_id, 2); - entry.value + let all_ev = client.get_dispute_evidence(&case.dispute_id); + assert_eq!(all_ev.len(), 2); } - /// Invalidate a cache entry. - pub fn cache_invalidate(env: Env, key: Symbol) { - if let Some(mut entry) = env - .storage() - .persistent() - .get::<(Symbol, Symbol), CacheEntry>(&cache_entry_key(&key)) - { - entry.valid = false; - env.storage() - .persistent() - .set(&cache_entry_key(&key), &entry); - } + #[test] + fn test_weighted_panel_voting_and_direct_enforcement_after_appeal_window() { + let (env, client, _admin, token_addr, token_client) = setup_env(); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); - let mut metrics = load_cache_metrics(&env); - metrics.invalidated += 1; - save_cache_metrics(&env, &metrics); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + sac.mint(&payer, &10_000_000i128); - events::cache_invalidated(&env, &key); - } + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(20_000_000i128); - /// Warm the cache by pre-populating entries for a set of invoice IDs. - /// Each invoice is serialized and cached under its ID as key. - pub fn cache_warm(env: Env, invoice_ids: Vec) -> u32 { - let mut warmed = 0u32; - for id in invoice_ids.iter() { - let invoice = load_invoice(&env, id); - let key = Symbol::new(&env, "inv"); - // Serialize invoice to bytes (using the stored struct). - // We use a simple representation: store the invoice directly. - env.storage() - .persistent() - .set(&cache_entry_key(&Symbol::new(&env, "inv")), &CacheEntry { - value: Bytes::new(&env), // Placeholder; real serialization would go here. - cached_at: env.ledger().timestamp(), - expires_at: 0, - read_count: 0, - valid: true, - }); - warmed += 1; - } + let invoice_id = client.create_invoice( + &creator, + &recipients, + &amounts, + &token_addr, + &999_999u64, + &crate::test::default_options(&env), + ); + client.pay(&payer, &invoice_id, &10_000_000i128, &0u64, &false, &false, &None); - let mut metrics = load_cache_metrics(&env); - metrics.warmed += warmed as u64; - save_cache_metrics(&env, &metrics); + let arb1 = Address::generate(&env); + let arb2 = Address::generate(&env); + let arb3 = Address::generate(&env); + client.register_adv_arbitrator(&arb1, &100u32); + client.register_adv_arbitrator(&arb2, &300u32); + client.register_adv_arbitrator(&arb3, &100u32); - events::cache_warmed(&env, warmed); - warmed - } + let case = client.create_advanced_dispute( + &invoice_id, + &payer, + &recipient, + &String::from_str(&env, "Partial delivery"), + &10_000_000i128, + &Some(3u32), + ); - /// Return cache performance metrics. - pub fn get_cache_metrics(env: Env) -> CacheMetrics { - load_cache_metrics(&env) - } + let rationale = BytesN::from_array(&env, &[0x11; 32]); + // Vote 1 (weight 100): ReleaseToRecipient (10000 bps) + let after_v1 = client.cast_panel_vote( + &case.dispute_id, + &arb1, + &ArbitrationRuling::ReleaseToRecipient, + &rationale, + ); + assert_eq!(after_v1.stage, DisputeStage::ArbitrationReview); + + // Vote 2 (weight 300): SplitBps(6000) -> Quorum (2 of 3) reached! + // Weighted average bps = (100 * 10000 + 300 * 6000) / 400 = 2_800_000 / 400 = 7000 bps (70% recipient, 30% payer) + let after_v2 = client.cast_panel_vote( + &case.dispute_id, + &arb2, + &ArbitrationRuling::SplitBps(6_000), + &rationale, + ); + assert_eq!(after_v2.stage, DisputeStage::DecisionRendered); + assert_eq!(after_v2.ruling, ArbitrationRuling::SplitBps(7_000)); - // ----------------------------------------------------------------------- - // Issue #1015: Query optimization - // ----------------------------------------------------------------------- + // Advance ledger past appeal deadline and enforce + env.ledger().with_mut(|li| { + li.timestamp = after_v2.appeal_deadline + 10; + }); - /// Query invoices by status using the status index (fast path). - pub fn get_invoices_by_status(env: Env, status: InvoiceStatus) -> Vec { - let mut metrics = load_query_metrics(&env); - metrics.total_queries += 1; - metrics.indexed_queries += 1; + let enforced = client.enforce_dispute_decision(&case.dispute_id); + assert!(enforced.enforced); + assert_eq!(enforced.stage, DisputeStage::Enforced); + assert_eq!(token_client.balance(&recipient), 7_000_000i128); + assert_eq!(token_client.balance(&payer), 3_000_000i128); + } - let status_sym = status_to_symbol(&env, &status); - let result: Vec = env - .storage() - .persistent() - .get(&query_status_index_key(&status_sym)) - .unwrap_or_else(|| Vec::new(&env)); + #[test] + fn test_appeal_filing_and_override_resolution() { + let (env, client, _admin, token_addr, token_client) = setup_env(); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); - metrics.total_query_time += 1; - metrics.avg_query_time = if metrics.total_queries > 0 { - metrics.total_query_time / metrics.total_queries - } else { - 0 - }; - save_query_metrics(&env, &metrics); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + sac.mint(&payer, &5_000_000i128); - events::query_executed(&env, &status_sym, result.len() as u32); - result - } + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(10_000_000i128); - /// Query invoices by creator using the creator index (fast path). - pub fn get_invoices_by_creator(env: Env, creator: Address) -> Vec { - let mut metrics = load_query_metrics(&env); - metrics.total_queries += 1; - metrics.indexed_queries += 1; + let invoice_id = client.create_invoice( + &creator, + &recipients, + &amounts, + &token_addr, + &999_999u64, + &crate::test::default_options(&env), + ); + client.pay(&payer, &invoice_id, &5_000_000i128, &0u64, &false, &false, &None); - let result: Vec = env - .storage() - .persistent() - .get(&query_creator_index_key(&creator)) - .unwrap_or_else(|| Vec::new(&env)); + let arb1 = Address::generate(&env); + let arb2 = Address::generate(&env); + let arb3 = Address::generate(&env); + client.register_adv_arbitrator(&arb1, &100u32); + client.register_adv_arbitrator(&arb2, &100u32); + client.register_adv_arbitrator(&arb3, &100u32); - metrics.total_query_time += 1; - metrics.avg_query_time = if metrics.total_queries > 0 { - metrics.total_query_time / metrics.total_queries - } else { - 0 - }; - save_query_metrics(&env, &metrics); + let case = client.create_advanced_dispute( + &invoice_id, + &payer, + &recipient, + &String::from_str(&env, "Non-delivery"), + &5_000_000i128, + &Some(3u32), + ); - events::query_executed(&env, &Symbol::new(&env, "creator"), result.len() as u32); - result - } + let rationale = BytesN::from_array(&env, &[0x22; 32]); + client.cast_panel_vote(&case.dispute_id, &arb1, &ArbitrationRuling::ReleaseToRecipient, &rationale); + client.cast_panel_vote(&case.dispute_id, &arb2, &ArbitrationRuling::ReleaseToRecipient, &rationale); - /// Create a query index for the given index type. Initializes the index - /// by scanning existing invoices. Requires admin auth. - pub fn create_query_index(env: Env, admin: Address, index_type: Symbol) { - require_admin(&env); - let _ = admin; + // Payer files appeal within appeal window + let appeal_ev = BytesN::from_array(&env, &[0x33; 32]); + let appeal = client.file_dispute_appeal( + &case.dispute_id, + &payer, + &String::from_str(&env, "New forensic audit proving non-delivery"), + &appeal_ev, + ); + assert!(!appeal.resolved); + assert_eq!( + client.get_advanced_dispute(&case.dispute_id).unwrap().stage, + DisputeStage::UnderAppeal + ); - // Initialize metrics if not already set. - let mut metrics = load_query_metrics(&env); - metrics.index_count += 1; - save_query_metrics(&env, &metrics); + // Resolve appeal overriding ruling to RefundToPayer + let finalized = client.resolve_dispute_appeal( + &case.dispute_id, + &false, + &ArbitrationRuling::RefundToPayer, + ); + assert_eq!(finalized.stage, DisputeStage::Finalized); + assert_eq!(finalized.ruling, ArbitrationRuling::RefundToPayer); + + // Enforce immediately once Finalized + let enforced = client.enforce_dispute_decision(&case.dispute_id); + assert!(enforced.enforced); + assert_eq!(token_client.balance(&payer), 5_000_000i128); + assert_eq!(token_client.balance(&recipient), 0i128); + } + + #[test] + fn test_unauthorized_evidence_and_premature_enforcement_rejected() { + let (env, client, _admin, _token_addr, _token_client) = setup_env(); + + let claimant = Address::generate(&env); + let respondent = Address::generate(&env); + let outsider = Address::generate(&env); + let arb1 = Address::generate(&env); + let arb2 = Address::generate(&env); + let arb3 = Address::generate(&env); + + client.register_adv_arbitrator(&arb1, &100u32); + client.register_adv_arbitrator(&arb2, &100u32); + client.register_adv_arbitrator(&arb3, &100u32); + + let case = client.create_advanced_dispute( + &77u64, + &claimant, + &respondent, + &String::from_str(&env, "Dispute"), + &1_000_000i128, + &Some(3u32), + ); - events::query_index_created(&env, &index_type); - } + let hash = BytesN::from_array(&env, &[0x44; 32]); + let unauthorized_ev = client.try_submit_dispute_evidence( + &case.dispute_id, + &outsider, + &EvidenceKind::Other, + &hash, + &String::from_str(&env, "Spam"), + ); + assert!(unauthorized_ev.is_err()); + + client.cast_panel_vote(&case.dispute_id, &arb1, &ArbitrationRuling::RefundToPayer, &hash); + client.cast_panel_vote(&case.dispute_id, &arb2, &ArbitrationRuling::RefundToPayer, &hash); - /// Return query performance metrics. - pub fn get_query_metrics(env: Env) -> QueryMetrics { - load_query_metrics(&env) + // Premature enforcement before appeal window expires must fail with TimelockPending + let early_enforce = client.try_enforce_dispute_decision(&case.dispute_id); + assert!(early_enforce.is_err()); } } // --------------------------------------------------------------------------- -// Issue #1034: Contract usage analytics +// Issue #1038: Automated compliance reporting // --------------------------------------------------------------------------- #[contractimpl] impl SplitContract { - /// Turn usage recording on or off. Admin only. Reads keep working on the - /// data already collected while recording is off. - pub fn set_analytics_enabled(env: Env, enabled: bool) { + /// Register a reusable compliance report template. Admin only. + pub fn register_compliance_template( + env: Env, + name: String, + report_type: compliance_reporting::ComplianceReportType, + jurisdiction_filter: Option, + high_value_threshold: i128, + include_tax_breakdown: bool, + include_aml_flags: bool, + default_format: compliance_reporting::ExportFormat, + ) -> compliance_reporting::ReportTemplate { require_admin(&env); - analytics::set_enabled(&env, enabled); - } - - /// Whether usage recording is on (default `true`). - pub fn is_analytics_enabled(env: Env) -> bool { - analytics::is_enabled(&env) - } - - /// Lifetime usage counters. - pub fn get_usage_totals(env: Env) -> analytics::UsageTotals { - analytics::get_totals(&env) + compliance_reporting::register_template( + &env, + name, + report_type, + jurisdiction_filter, + high_value_threshold, + include_tax_breakdown, + include_aml_flags, + default_format, + ) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Usage for one UTC day (`day = timestamp / 86_400`). - pub fn get_usage_day(env: Env, day: u64) -> analytics::DailyUsage { - analytics::get_day(&env, day) + /// Return all registered compliance report templates. + pub fn get_compliance_templates(env: Env) -> Vec { + compliance_reporting::get_templates(&env) } - /// Daily usage for `from_day..=to_day` (at most 90 days), empty days included. - pub fn get_usage_report(env: Env, from_day: u64, to_day: u64) -> Vec { - analytics::report(&env, from_day, to_day).unwrap_or_else(|e| panic_with_error!(&env, e)) + /// Record a compliance data point for an invoice payment or settlement. Admin only. + pub fn record_compliance_data( + env: Env, + point: compliance_reporting::ComplianceDataPoint, + ) -> u32 { + require_admin(&env); + compliance_reporting::record_data_point(&env, point) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Averages, conversion rates and the week-over-week payment trend. - pub fn get_usage_insights(env: Env) -> analytics::UsageInsights { - analytics::insights(&env) + /// Generate a compliance report for `[period_start, period_end]` from a registered template. Admin only. + pub fn generate_compliance_report( + env: Env, + template_id: u32, + period_start: u64, + period_end: u64, + ) -> compliance_reporting::ComplianceReport { + require_admin(&env); + compliance_reporting::generate_report(&env, template_id, period_start, period_end) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Totals, today's bucket and insights in one read. - pub fn get_usage_dashboard(env: Env) -> analytics::UsageDashboard { - analytics::dashboard(&env) + /// Export a generated compliance report into the requested `ExportFormat` (`Json`, `Csv`, `RegulatoryXml`, `XdrDigest`). + pub fn export_compliance_report( + env: Env, + report_id: u64, + format: compliance_reporting::ExportFormat, + ) -> compliance_reporting::ComplianceExportArtifact { + compliance_reporting::export_report(&env, report_id, format) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } -} -// --------------------------------------------------------------------------- -// Issue #1033: Contract monitoring and alerting -// --------------------------------------------------------------------------- -#[contractimpl] -impl SplitContract { - /// Register an alert rule and return its id. Admin only. - pub fn add_alert_rule(env: Env, rule: monitoring::AlertRule) -> u32 { + /// Configure an automated recurring compliance report schedule. Admin only. + pub fn schedule_compliance_report( + env: Env, + template_id: u32, + interval_secs: u64, + start_at: u64, + ) -> compliance_reporting::ReportSchedule { require_admin(&env); - monitoring::add_rule(&env, rule).unwrap_or_else(|e| panic_with_error!(&env, e)) + compliance_reporting::configure_schedule(&env, template_id, interval_secs, start_at) + .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// Remove an alert rule. Admin only. - pub fn remove_alert_rule(env: Env, rule_id: u32) { - require_admin(&env); - monitoring::remove_rule(&env, rule_id).unwrap_or_else(|e| panic_with_error!(&env, e)) + /// Return all configured compliance report schedules. + pub fn get_compliance_schedules(env: Env) -> Vec { + compliance_reporting::get_schedules(&env) } - /// Enable or disable an alert rule. Admin only. - pub fn set_alert_rule_enabled(env: Env, rule_id: u32, enabled: bool) { - require_admin(&env); - monitoring::set_rule_enabled(&env, rule_id, enabled) + /// Evaluate and execute all due scheduled compliance reports. + pub fn run_scheduled_compliance_reports( + env: Env, + ) -> Vec { + compliance_reporting::run_due_schedules(&env) .unwrap_or_else(|e| panic_with_error!(&env, e)) } - /// All registered alert rules. - pub fn get_alert_rules(env: Env) -> Vec { - monitoring::get_rules(&env) + /// Retrieve a generated compliance report by ID. + pub fn get_compliance_report( + env: Env, + report_id: u64, + ) -> Option { + compliance_reporting::get_report(&env, report_id) } - /// Set (`Some`) or clear (`None`) the contract that receives - /// `on_alert(alert)` calls. Admin only. - pub fn set_alert_receiver(env: Env, receiver: Option
) { - require_admin(&env); - monitoring::set_receiver(&env, &receiver); + /// Retrieve a materialized compliance export artifact by `(report_id, format)`. + pub fn get_compliance_export( + env: Env, + report_id: u64, + format: compliance_reporting::ExportFormat, + ) -> Option { + compliance_reporting::get_export(&env, report_id, format) } +} - /// Evaluate every alert rule now and return the number of alerts raised. - /// Open to anyone so a keeper can catch conditions no event triggers. - pub fn check_alerts(env: Env) -> u32 { - monitoring::check_all(&env) - } +#[cfg(test)] +mod issue_1038_tests { + use super::*; + use crate::compliance_reporting::{ + ComplianceDataPoint, ComplianceReportType, ExportFormat, + }; + use soroban_sdk::testutils::{Address as _, Ledger as _}; - /// Mark an alert as handled. Admin only. - pub fn acknowledge_alert(env: Env, alert_id: u64) { - require_admin(&env); - monitoring::acknowledge(&env, alert_id).unwrap_or_else(|e| panic_with_error!(&env, e)) - } + fn setup_env() -> (Env, SplitContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 1_000; + }); - /// One alert by id (`None` if unknown or pruned from history). - pub fn get_alert(env: Env, alert_id: u64) -> Option { - monitoring::get_alert(&env, alert_id) - } + let contract_id = env.register(SplitContract, ()); + let client = SplitContractClient::new(&env, &contract_id); - /// Up to `limit` most recent alerts, newest first. - pub fn get_recent_alerts(env: Env, limit: u64) -> Vec { - monitoring::recent_alerts(&env, limit) - } + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_addr = env + .register_stellar_asset_contract_v2(token_admin) + .address(); - /// Current value of every collectable metric. - pub fn get_metrics(env: Env) -> Vec { - monitoring::snapshot(&env) - } + client.initialize( + &admin, + &0i128, + &treasury, + &token_addr, + &0u32, + &None, + &1_000u32, + &100u32, + &60u64, + ); - /// Counters about the monitor itself. - pub fn get_monitoring_stats(env: Env) -> monitoring::MonitoringStats { - monitoring::get_stats(&env) + (env, client, admin) } - /// Stats, rules, metric values and recent alerts in one read. - pub fn get_monitoring_dashboard(env: Env) -> monitoring::MonitoringDashboard { - monitoring::dashboard(&env) + #[test] + fn test_template_registration_data_aggregation_and_multi_format_exports() { + let (env, client, _admin) = setup_env(); + + let us_symbol = symbol_short!("US"); + let eu_symbol = symbol_short!("EU"); + + let tpl = client.register_compliance_template( + &String::from_str(&env, "US Q4 Regulatory & Tax Digest"), + &ComplianceReportType::FullRegulatoryDigest, + &Some(us_symbol.clone()), + &10_000_000i128, + &true, + &true, + &ExportFormat::Json, + ); + assert_eq!(tpl.template_id, 1); + + let payer1 = Address::generate(&env); + let payer2 = Address::generate(&env); + + // US normal transaction + client.record_compliance_data(&ComplianceDataPoint { + invoice_id: 1, + payer: payer1.clone(), + amount: 5_000_000, + fee_amount: 50_000, + tax_withheld: 500_000, + jurisdiction: us_symbol.clone(), + aml_flagged: false, + disputed: false, + refunded: false, + timestamp: 1_500, + }); + + // US high-value + AML flagged + disputed transaction + client.record_compliance_data(&ComplianceDataPoint { + invoice_id: 2, + payer: payer2.clone(), + amount: 25_000_000, + fee_amount: 250_000, + tax_withheld: 2_500_000, + jurisdiction: us_symbol.clone(), + aml_flagged: true, + disputed: true, + refunded: false, + timestamp: 2_000, + }); + + // EU transaction (filtered out by US template) + client.record_compliance_data(&ComplianceDataPoint { + invoice_id: 3, + payer: payer1, + amount: 50_000_000, + fee_amount: 500_000, + tax_withheld: 5_000_000, + jurisdiction: eu_symbol, + aml_flagged: false, + disputed: false, + refunded: true, + timestamp: 2_100, + }); + + let report = client.generate_compliance_report(&tpl.template_id, &1_000u64, &3_000u64); + assert_eq!(report.report_id, 1); + assert_eq!(report.metrics.total_transactions, 2); + assert_eq!(report.metrics.total_volume, 30_000_000); + assert_eq!(report.metrics.total_fees, 300_000); + assert_eq!(report.metrics.total_tax_withheld, 3_000_000); + assert_eq!(report.metrics.high_value_count, 1); + assert_eq!(report.metrics.high_value_volume, 25_000_000); + assert_eq!(report.metrics.aml_flagged_count, 1); + assert_eq!(report.metrics.disputed_count, 1); + + // Default JSON export was automatically materialized + let json_export = client + .get_compliance_export(&report.report_id, &ExportFormat::Json) + .unwrap(); + assert_eq!(json_export.format, ExportFormat::Json); + + // On-demand CSV, RegulatoryXml, and XdrDigest exports + let csv_export = client.export_compliance_report(&report.report_id, &ExportFormat::Csv); + assert_eq!(csv_export.format, ExportFormat::Csv); + + let xml_export = + client.export_compliance_report(&report.report_id, &ExportFormat::RegulatoryXml); + assert_eq!(xml_export.format, ExportFormat::RegulatoryXml); + + let xdr_export = + client.export_compliance_report(&report.report_id, &ExportFormat::XdrDigest); + assert_eq!(xdr_export.format, ExportFormat::XdrDigest); + } + + #[test] + fn test_automated_report_scheduling_and_execution() { + let (env, client, _admin) = setup_env(); + + let tpl = client.register_compliance_template( + &String::from_str(&env, "Daily AML Summary"), + &ComplianceReportType::AmlKycSummary, + &None, + &5_000_000i128, + &false, + &true, + &ExportFormat::Csv, + ); + + let sched = client.schedule_compliance_report(&tpl.template_id, &3_600u64, &5_000u64); + assert_eq!(sched.schedule_id, 1); + assert_eq!(sched.next_run_at, 5_000); + + // Before next_run_at (1000 < 5000): nothing due + let none_due = client.run_scheduled_compliance_reports(); + assert_eq!(none_due.len(), 0); + + // Record data point and advance clock to 5_500 + let payer = Address::generate(&env); + client.record_compliance_data(&ComplianceDataPoint { + invoice_id: 10, + payer, + amount: 8_000_000, + fee_amount: 80_000, + tax_withheld: 0, + jurisdiction: symbol_short!("SG"), + aml_flagged: true, + disputed: false, + refunded: false, + timestamp: 4_000, + }); + + env.ledger().with_mut(|li| { + li.timestamp = 5_500; + }); + + let generated = client.run_scheduled_compliance_reports(); + assert_eq!(generated.len(), 1); + assert_eq!(generated.get(0).unwrap().metrics.total_transactions, 1); + assert_eq!(generated.get(0).unwrap().metrics.aml_flagged_count, 1); + + let schedules = client.get_compliance_schedules(); + let updated_sched = schedules.get(0).unwrap(); + assert_eq!(updated_sched.last_run_at, 5_500); + assert_eq!(updated_sched.next_run_at, 9_100); } } diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 444010a..8fac01c 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -2384,230 +2384,3 @@ mod tests { assert_eq!(InvoiceStatus::from_u8(100), InvoiceStatus::Pending); } } - - -// --------------------------------------------------------------------------- -// Issue #1012: Encryption types -// --------------------------------------------------------------------------- - -/// Encryption algorithm identifier. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum EncryptionAlgorithm { - /// AES-256-GCM authenticated encryption. - Aes256Gcm, - /// ChaCha20-Poly1305 authenticated encryption. - ChaCha20Poly1305, -} - -/// A managed encryption key with version tracking for rotation. -#[contracttype] -#[derive(Clone, Debug)] -pub struct EncryptionKey { - /// Algorithm used with this key. - pub algorithm: EncryptionAlgorithm, - /// Monotonically increasing version number (starts at 1). - pub version: u32, - /// The raw key material (32 bytes for AES-256 or ChaCha20). - pub key_data: BytesN<32>, - /// Ledger timestamp when the key was created. - pub created_at: u64, - /// Whether this key is active (true) or retired (false). - pub active: bool, -} - -/// An entry in the encryption audit trail. -#[contracttype] -#[derive(Clone, Debug)] -pub struct EncryptionAuditEntry { - /// Action performed: set_key, rotate, encrypt, decrypt, validate. - pub action: Symbol, - /// Address that performed the action. - pub actor: Address, - /// Key version involved. - pub key_version: u32, - /// Ledger timestamp. - pub timestamp: u64, - /// Hash of the data involved (for verification). - pub data_hash: BytesN<32>, -} - -/// Result of an encryption validation check. -#[contracttype] -#[derive(Clone, Debug)] -pub struct EncryptionValidation { - /// Whether the validation passed. - pub valid: bool, - /// Key version that was validated. - pub key_version: u32, - /// Timestamp of the validation. - pub timestamp: u64, - /// Error message if validation failed (empty string on success). - pub error: Symbol, -} - -// --------------------------------------------------------------------------- -// Issue #1013: RBAC types -// --------------------------------------------------------------------------- - -/// Role in the access control system. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum Role { - /// Full contract administrator. - Admin, - /// Can create and manage invoices. - Creator, - /// Can pay toward invoices. - Payer, - /// Can approve invoices for release. - Approver, - /// Can audit contract state (read-only). - Auditor, -} - -/// Permission flags that can be granted to roles. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum Permission { - /// Create new invoices. - CreateInvoice, - /// Pay toward invoices. - Pay, - /// Release invoice funds. - Release, - /// Refund invoice payments. - Refund, - /// Cancel invoices. - Cancel, - /// Manage roles (assign/revoke). - ManageRoles, - /// View audit logs. - ViewAudit, - /// Encrypt or decrypt sensitive data. - ManageEncryption, -} - -/// Role assignment record. -#[contracttype] -#[derive(Clone, Debug)] -pub struct RoleAssignment { - /// The role assigned. - pub role: Role, - /// Address the role is assigned to. - pub address: Address, - /// Address that granted the role. - pub assigned_by: Address, - /// Ledger timestamp of assignment. - pub assigned_at: u64, -} - -/// An entry in the RBAC audit trail. -#[contracttype] -#[derive(Clone, Debug)] -pub struct RbacAuditEntry { - /// Action performed: assign, revoke, check. - pub action: Symbol, - /// Address that performed the action. - pub actor: Address, - /// Target address (the one whose role was changed/checked). - pub target: Address, - /// Role involved. - pub role: Role, - /// Permission checked (if applicable). - pub permission: Option, - /// Whether the check passed (for check actions). - pub allowed: bool, - /// Ledger timestamp. - pub timestamp: u64, -} - -/// Access analytics summary for a given address. -#[contracttype] -#[derive(Clone, Debug)] -pub struct AccessAnalytics { - /// Total number of role assignments for this address. - pub roles_assigned: u32, - /// Total number of role revocations for this address. - pub roles_revoked: u32, - /// Total permission checks performed. - pub permission_checks: u32, - /// Number of permission checks that passed. - pub checks_passed: u32, - /// Number of permission checks that failed. - pub checks_failed: u32, - /// Last active timestamp. - pub last_active: u64, -} - -// --------------------------------------------------------------------------- -// Issue #1014: Caching types -// --------------------------------------------------------------------------- - -/// A cached contract state entry. -#[contracttype] -#[derive(Clone, Debug)] -pub struct CacheEntry { - /// The cached value (serialized). - pub value: Bytes, - /// Ledger timestamp when the entry was cached. - pub cached_at: u64, - /// Ledger timestamp when the entry expires (0 = no expiry). - pub expires_at: u64, - /// Number of times this entry has been read. - pub read_count: u64, - /// Whether the entry is valid (false = invalidated). - pub valid: bool, -} - -/// Cache performance metrics. -#[contracttype] -#[derive(Clone, Debug)] -pub struct CacheMetrics { - /// Total cache hits. - pub hits: u64, - /// Total cache misses. - pub misses: u64, - /// Total entries invalidated. - pub invalidated: u64, - /// Total entries warmed (pre-populated). - pub warmed: u64, - /// Current number of entries in the cache. - pub entry_count: u64, - /// Hit ratio in basis points (0–10 000). - pub hit_ratio_bps: u32, -} - -// --------------------------------------------------------------------------- -// Issue #1015: Query optimization types -// --------------------------------------------------------------------------- - -/// Index types for optimized queries. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum QueryIndex { - /// Index by invoice status. - ByStatus, - /// Index by creator address. - ByCreator, - /// Index by recipient address. - ByRecipient, -} - -/// Query performance metrics. -#[contracttype] -#[derive(Clone, Debug)] -pub struct QueryMetrics { - /// Total queries executed. - pub total_queries: u64, - /// Queries served from index (fast path). - pub indexed_queries: u64, - /// Queries that required full scan. - pub full_scan_queries: u64, - /// Total query time in ledger "ticks" (accumulated). - pub total_query_time: u64, - /// Average query time (total / total_queries). - pub avg_query_time: u64, - /// Number of indexes currently maintained. - pub index_count: u32, -} diff --git a/docs/ISSUE_1037_ADVANCED_DISPUTE_RESOLUTION.md b/docs/ISSUE_1037_ADVANCED_DISPUTE_RESOLUTION.md new file mode 100644 index 0000000..20c6f96 --- /dev/null +++ b/docs/ISSUE_1037_ADVANCED_DISPUTE_RESOLUTION.md @@ -0,0 +1,65 @@ +# Issue #1037 — Advanced Dispute Resolution with Arbitration + +## Overview + +Implements a comprehensive, multi-stage on-chain dispute resolution and arbitration framework (`contracts/split/src/advanced_dispute.rs`) for `SplitContract`. The system supports structured cryptographic evidence submission, conflict-free arbitrator panel selection with fair rotation, reputation-weighted panel voting across binary and proportional rulings, a post-ruling appeal window and appellate override process, and automated on-chain settlement enforcement. + +--- + +## Architecture & Lifecycle Stages + +Each `AdvancedDisputeCase` progresses deterministically through six lifecycle stages (`DisputeStage`): + +1. **`EvidenceCollection`**: Triggered upon `create_advanced_dispute`. A conflict-free arbitrator panel is immediately assigned and both `claimant` and `respondent` may submit structured `EvidenceRecord` entries up to `evidence_deadline`. +2. **`ArbitrationReview`**: Entered when the first panel arbitrator casts a vote via `cast_panel_vote`. +3. **`DecisionRendered`**: Reached automatically once a majority quorum (`(panel_size / 2) + 1`) of assigned panel arbitrators have voted. Computes the weighted ruling (`ReleaseToRecipient`, `RefundToPayer`, or `SplitBps(bps)`) and opens the appeal window (`appeal_deadline = now + appeal_window_secs`). +4. **`UnderAppeal`**: Entered if either party invokes `file_dispute_appeal` with new evidence before `appeal_deadline`. Blocks premature settlement enforcement. +5. **`Finalized`**: Reached when `resolve_dispute_appeal` either upholds the original panel ruling or overrides it with a final appellate ruling. +6. **`Enforced`**: Reached when `enforce_dispute_decision` executes the on-chain token transfers between the escrowed invoice balance, `respondent` (recipient), and `claimant` (payer). + +--- + +## Key Capabilities + +### 1. Fair, Conflict-Free Arbitrator Panel Selection +- Arbitrators are registered via `register_adv_arbitrator(arbitrator, weight)` into a bounded pool (`MAX_ARBITRATOR_POOL = 50`). +- `select_panel` filters out inactive arbitrators as well as both `claimant` and `respondent` so neither party can ever serve on their own dispute panel. +- Uses a persistent rotation cursor (`AdvDisputeKey::RotationCursor`) and tracks `cases_assigned` / `cases_decided` per arbitrator to ensure fair distribution across the pool. + +### 2. Cryptographic Evidence Submission +- Both `claimant` and `respondent` can attach up to `MAX_EVIDENCE_ITEMS` (20) items per dispute before `evidence_deadline`. +- Each `EvidenceRecord` records `EvidenceKind` (`ContractAgreement`, `DeliveryProof`, `CommunicationLog`, `PaymentReceipt`, `AuditReport`, `Other`), a 32-byte `content_hash` (`BytesN<32>`), a human-readable description, and ledger timestamp. + +### 3. Weighted Panel Voting & Proportional Split Rulings +- Assigned panel members submit `ArbitrationRuling::ReleaseToRecipient`, `ArbitrationRuling::RefundToPayer`, or `ArbitrationRuling::SplitBps(bps)` along with a 32-byte `rationale_hash`. +- When quorum is reached, `tally_weighted_ruling` weights each vote by the arbitrator's profile weight and computes either a decisive binary outcome or a weighted-average basis-point split (`SplitBps(avg_bps)`). + +### 4. Appeal Window & Appellate Resolution +- Any ruling enters a mandatory timelock (`appeal_window_secs`, default 24h) before direct enforcement is permitted. +- Either disputing party may file an `AppealRecord` (`file_dispute_appeal`) within the window, transitioning the case to `UnderAppeal`. +- Appellate governance (`resolve_dispute_appeal`) can uphold the panel's original ruling or override it with a revised `ArbitrationRuling`. + +### 5. Automated On-Chain Settlement Enforcement +- `enforce_dispute_decision` verifies either that the case is `Finalized` (post-appeal) or that `DecisionRendered` has passed `appeal_deadline` without appeal. +- Computes exact `recipient_payout` and `payer_refund` amounts from `disputed_amount` and transfers tokens directly from the contract escrow to `respondent` and `claimant`, updating the underlying `InvoiceStatus`. + +--- + +## Public Entry Points (`SplitContract`) + +| Entry Point | Auth | Description | +|---|---|---| +| `set_adv_dispute_config(config)` | Admin | Configure evidence, voting, and appeal windows + default panel size | +| `get_adv_dispute_config()` | Public | Read active configuration | +| `register_adv_arbitrator(arbitrator, weight)` | Admin | Register or update an arbitrator in the pool | +| `get_adv_arbitrator_pool()` | Public | List all registered arbitrators and workload stats | +| `create_advanced_dispute(...)` | Claimant | Create a dispute and assign a conflict-free panel | +| `submit_dispute_evidence(...)` | Submitter | Attach categorized cryptographic evidence | +| `cast_panel_vote(...)` | Arbitrator | Cast weighted panel vote and auto-tally at quorum | +| `file_dispute_appeal(...)` | Appellant | File an appeal with new evidence hash within window | +| `resolve_dispute_appeal(...)` | Admin | Uphold or override ruling on appeal | +| `enforce_dispute_decision(dispute_id)` | Public | Enforce finalized/unappealed ruling & transfer tokens | +| `get_advanced_dispute(dispute_id)` | Public | Query dispute state | +| `get_dispute_evidence(dispute_id)` | Public | Query all evidence records for a dispute | +| `get_dispute_panel_votes(dispute_id)` | Public | Query all panel votes for a dispute | +| `get_dispute_appeal(dispute_id)` | Public | Query appeal record for a dispute | diff --git a/docs/ISSUE_1038_COMPLIANCE_REPORTING.md b/docs/ISSUE_1038_COMPLIANCE_REPORTING.md new file mode 100644 index 0000000..60f3568 --- /dev/null +++ b/docs/ISSUE_1038_COMPLIANCE_REPORTING.md @@ -0,0 +1,54 @@ +# Issue #1038 — Automated Compliance Reporting + +## Overview + +Implements an on-chain automated compliance reporting framework (`contracts/split/src/compliance_reporting.rs`) for `SplitContract`. The module provides configurable regulatory report templates, transaction compliance data collection and time-window aggregation, recurring automated report schedules, cryptographic integrity commitments (`SHA-256` over XDR), and multi-format regulatory exports (`Json`, `Csv`, `RegulatoryXml`, `XdrDigest`). + +--- + +## Key Components + +### 1. Report Types & Configurable Templates (`ReportTemplate`) +Supports five regulatory reporting categories (`ComplianceReportType`): +- `AmlKycSummary` — AML/KYC flagged activity and volume breakdown +- `TaxWithholding` — Jurisdictional tax withholding totals and fee summaries +- `HighValueTransactions` — Transactions meeting or exceeding a configurable `high_value_threshold` +- `DisputeAndRefundAudit` — Disputed and refunded invoice metrics +- `FullRegulatoryDigest` — Comprehensive multi-metric compliance digest + +Each `ReportTemplate` configures: +- `name`: Human-readable template name +- `report_type`: `ComplianceReportType` +- `jurisdiction_filter`: Optional `Symbol` (e.g., `US`, `EU`, `SG`, or `None` for `GLOBAL`) +- `high_value_threshold`: Token amount threshold for high-value transaction flagging +- `include_tax_breakdown` / `include_aml_flags`: Inclusion toggles +- `default_format`: Default `ExportFormat` automatically materialized on report generation + +### 2. Compliance Data Collection & Window Aggregation +- `record_compliance_data` records `ComplianceDataPoint` entries (`invoice_id`, `payer`, `amount`, `fee_amount`, `tax_withheld`, `jurisdiction`, `aml_flagged`, `disputed`, `refunded`, `timestamp`) in a bounded rolling ledger buffer (`MAX_COMPLIANCE_RECORDS = 500`). +- `aggregate_compliance_metrics` filters records by `[period_start, period_end]` and optional `jurisdiction_filter`, computing `AggregatedComplianceMetrics` (`total_transactions`, `total_volume`, `total_fees`, `total_tax_withheld`, `high_value_count`, `high_value_volume`, `aml_flagged_count`, `aml_flagged_volume`, `disputed_count`, `refunded_count`). + +### 3. Automated Scheduling (`ReportSchedule`) +- `schedule_compliance_report(template_id, interval_secs, start_at)` creates a recurring schedule. +- `run_scheduled_compliance_reports()` evaluates all enabled schedules where `ledger.timestamp() >= next_run_at`, automatically generates the `ComplianceReport` and default export artifact for the elapsed window, updates `last_run_at` and `last_report_id`, and advances `next_run_at`. + +### 4. Multi-Format Export & Cryptographic Integrity (`ExportFormat`) +- Every `ComplianceReport` commits a `BytesN<32>` SHA-256 `integrity_hash` over its canonical XDR representation. +- `export_compliance_report(report_id, format)` materializes a `ComplianceExportArtifact` in `Json`, `Csv`, `RegulatoryXml`, or `XdrDigest` format with its own `content_hash`. + +--- + +## Public Entry Points (`SplitContract`) + +| Entry Point | Auth | Description | +|---|---|---| +| `register_compliance_template(...)` | Admin | Create a reusable compliance report template | +| `get_compliance_templates()` | Public | List all registered report templates | +| `record_compliance_data(point)` | Admin | Record a transaction compliance data point | +| `generate_compliance_report(template_id, start, end)` | Admin | Generate an on-chain compliance report & default export | +| `export_compliance_report(report_id, format)` | Public | Export an existing report into `Json`, `Csv`, `RegulatoryXml`, or `XdrDigest` | +| `schedule_compliance_report(template_id, interval, start_at)` | Admin | Register an automated recurring report schedule | +| `get_compliance_schedules()` | Public | List all configured report schedules | +| `run_scheduled_compliance_reports()` | Public | Trigger all due scheduled compliance reports | +| `get_compliance_report(report_id)` | Public | Query a generated compliance report by ID | +| `get_compliance_export(report_id, format)` | Public | Query a generated export artifact |