diff --git a/veritixpay/contract/token/src/contract.rs b/veritixpay/contract/token/src/contract.rs index b83ff8e..172fe9f 100644 --- a/veritixpay/contract/token/src/contract.rs +++ b/veritixpay/contract/token/src/contract.rs @@ -4,12 +4,21 @@ use crate::balance::{ decrease_supply, increase_supply, read_balance, read_total_supply, receive_balance, spend_balance, }; +use crate::dispute::{get_dispute as dispute_get, open_dispute, resolve_dispute, DisputeRecord}; +use crate::escrow::{ + create_escrow as escrow_create, get_escrow as escrow_get, refund_escrow as escrow_refund, + release_escrow as escrow_release, EscrowRecord, +}; use crate::freeze::{freeze_account, is_frozen as read_frozen_status, unfreeze_account}; use crate::metadata::{ read_decimal, read_name, read_symbol, validate_metadata, write_metadata, TokenMetadata, }; +use crate::splitter::{ + create_split as split_create, distribute as split_distribute, get_split as split_get, + SplitRecord, SplitRecipient, +}; use crate::validation::{require_not_frozen_account, require_positive_amount}; -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String}; +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Vec}; #[contract] pub struct VeritixToken; @@ -171,4 +180,65 @@ impl VeritixToken { pub fn symbol(e: Env) -> String { read_symbol(&e) } + + // --- Escrow --- + + pub fn create_escrow(e: Env, depositor: Address, beneficiary: Address, amount: i128) -> u32 { + escrow_create(&e, depositor, beneficiary, amount) + } + + pub fn release_escrow(e: Env, caller: Address, escrow_id: u32) { + escrow_release(&e, caller, escrow_id) + } + + pub fn refund_escrow(e: Env, caller: Address, escrow_id: u32) { + escrow_refund(&e, caller, escrow_id) + } + + pub fn get_escrow(e: Env, escrow_id: u32) -> EscrowRecord { + escrow_get(&e, escrow_id) + } + + // --- Dispute --- + + pub fn open_dispute( + e: Env, + claimant: Address, + escrow_id: u32, + resolver: Address, + ) -> u32 { + open_dispute(&e, claimant, escrow_id, resolver) + } + + pub fn resolve_dispute( + e: Env, + resolver: Address, + dispute_id: u32, + release_to_beneficiary: bool, + ) { + resolve_dispute(&e, resolver, dispute_id, release_to_beneficiary) + } + + pub fn get_dispute(e: Env, dispute_id: u32) -> DisputeRecord { + dispute_get(&e, dispute_id) + } + + // --- Splitter --- + + pub fn create_split( + e: Env, + sender: Address, + recipients: Vec, + total_amount: i128, + ) -> u32 { + split_create(&e, sender, recipients, total_amount) + } + + pub fn distribute(e: Env, caller: Address, split_id: u32) { + split_distribute(&e, caller, split_id) + } + + pub fn get_split(e: Env, split_id: u32) -> SplitRecord { + split_get(&e, split_id) + } } diff --git a/veritixpay/contract/token/src/dispute.rs b/veritixpay/contract/token/src/dispute.rs index 40f1e5b..995113b 100644 --- a/veritixpay/contract/token/src/dispute.rs +++ b/veritixpay/contract/token/src/dispute.rs @@ -1,5 +1,6 @@ -use crate::escrow::{get_escrow, release_escrow, refund_escrow}; -use crate::storage_types::{increment_counter, DataKey}; +use crate::balance::{receive_balance, spend_balance}; +use crate::escrow::get_escrow; +use crate::storage_types::{increment_counter, write_persistent_record, DataKey}; use soroban_sdk::{contracttype, Address, Env, Symbol}; #[contracttype] @@ -27,26 +28,20 @@ pub fn open_dispute( escrow_id: u32, resolver: Address, ) -> u32 { - // 1. Authorization: Only the claimant can initiate this call claimant.require_auth(); - // 2. Fetch escrow and validate current state let escrow = get_escrow(e, escrow_id); - - // Check if the escrow is already finalized + if escrow.released || escrow.refunded { panic!("InvalidState: Cannot open dispute on a settled escrow"); } - // 3. Authorization check: Claimant must be a party involved in the escrow if claimant != escrow.depositor && claimant != escrow.beneficiary { panic!("Unauthorized: Only depositor or beneficiary can open a dispute"); } - // 4. Generate a new Dispute ID using the counter in storage let count = increment_counter(e, &DataKey::DisputeCount); - // 5. Create and store the dispute record let record = DisputeRecord { id: count, escrow_id, @@ -54,68 +49,88 @@ pub fn open_dispute( resolver, status: DisputeStatus::Open, }; - - // Store in persistent storage as disputes may last longer than instance TTL + e.storage().persistent().set(&DataKey::Dispute(count), &record); - // 6. Emit Observability Event e.events().publish( (Symbol::new(e, "dispute"), Symbol::new(e, "opened"), escrow_id), - claimant + claimant, ); count } -/// Resolves an open dispute. +/// Private helper: settle an escrow by outcome without requiring depositor/beneficiary auth. +/// The resolver has already been authenticated by `resolve_dispute`. +fn settle_escrow_by_outcome(e: &Env, escrow_id: u32, release_to_beneficiary: bool) { + let mut escrow = get_escrow(e, escrow_id); + + if escrow.released || escrow.refunded { + panic!("AlreadySettled: escrow is already settled"); + } + + if release_to_beneficiary { + escrow.released = true; + write_persistent_record(e, &DataKey::Escrow(escrow_id), &escrow); + spend_balance(e, e.current_contract_address(), escrow.amount); + receive_balance(e, escrow.beneficiary.clone(), escrow.amount); + e.events().publish( + (Symbol::new(e, "escrow"), Symbol::new(e, "released"), escrow_id), + escrow.beneficiary, + ); + } else { + escrow.refunded = true; + write_persistent_record(e, &DataKey::Escrow(escrow_id), &escrow); + spend_balance(e, e.current_contract_address(), escrow.amount); + receive_balance(e, escrow.depositor.clone(), escrow.amount); + e.events().publish( + (Symbol::new(e, "escrow"), Symbol::new(e, "refunded"), escrow_id), + escrow.depositor, + ); + } +} + +/// Resolves an open dispute. Only the designated resolver can call this. +/// Settlement does not require beneficiary/depositor auth. pub fn resolve_dispute( e: &Env, resolver: Address, dispute_id: u32, release_to_beneficiary: bool, ) { - // 1. Authorization: Only the designated resolver can resolve the dispute resolver.require_auth(); - // 2. Fetch the dispute record let mut dispute: DisputeRecord = e .storage() .persistent() .get(&DataKey::Dispute(dispute_id)) .expect("Dispute not found"); - // 3. Validation: Check if already resolved (Double-resolution panic) if dispute.status != DisputeStatus::Open { panic!("AlreadyResolved: This dispute has already been resolved"); } - // 4. Validation: Verify the resolver matches the record if dispute.resolver != resolver { panic!("UnauthorizedResolver: Only the designated resolver can resolve this"); } - // 5. Execute resolution by calling the core escrow logic - if release_to_beneficiary { - // Triggers the standard release logic from escrow.rs - release_escrow(e, dispute.escrow_id); - dispute.status = DisputeStatus::ResolvedForBeneficiary; + settle_escrow_by_outcome(e, dispute.escrow_id, release_to_beneficiary); + + dispute.status = if release_to_beneficiary { + DisputeStatus::ResolvedForBeneficiary } else { - // Triggers the standard refund logic from escrow.rs - refund_escrow(e, dispute.escrow_id); - dispute.status = DisputeStatus::ResolvedForDepositor; - } + DisputeStatus::ResolvedForDepositor + }; - // 6. Persist the updated dispute status e.storage().persistent().set(&DataKey::Dispute(dispute_id), &dispute); - // 7. Emit Observability Event e.events().publish( (Symbol::new(e, "dispute"), Symbol::new(e, "resolved"), dispute_id), - release_to_beneficiary + release_to_beneficiary, ); } -/// Helper to read a dispute record +/// Helper to read a dispute record. pub fn get_dispute(e: &Env, dispute_id: u32) -> DisputeRecord { e.storage() .persistent() diff --git a/veritixpay/contract/token/src/dispute_test.rs b/veritixpay/contract/token/src/dispute_test.rs new file mode 100644 index 0000000..4057d69 --- /dev/null +++ b/veritixpay/contract/token/src/dispute_test.rs @@ -0,0 +1,129 @@ +use soroban_sdk::{testutils::Address as _, Address, Env}; + +use crate::balance::read_balance; +use crate::contract::VeritixToken; +use crate::dispute::{get_dispute, open_dispute, resolve_dispute, DisputeStatus}; +use crate::escrow::{create_escrow, get_escrow}; + +fn setup_env() -> Env { + let e = Env::default(); + e.mock_all_auths(); + e +} + +fn setup_escrow(e: &Env, contract_id: &Address) -> (Address, Address, u32) { + let depositor = Address::generate(e); + let beneficiary = Address::generate(e); + let amount = 1_000i128; + let mut escrow_id = 0u32; + e.as_contract(contract_id, || { + crate::balance::receive_balance(e, depositor.clone(), amount); + escrow_id = create_escrow(e, depositor.clone(), beneficiary.clone(), amount); + }); + (depositor, beneficiary, escrow_id) +} + +#[test] +fn test_open_dispute_stores_record() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let resolver = Address::generate(&e); + let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id); + + e.as_contract(&contract_id, || { + let dispute_id = open_dispute(&e, depositor.clone(), escrow_id, resolver.clone()); + let record = get_dispute(&e, dispute_id); + assert_eq!(record.escrow_id, escrow_id); + assert_eq!(record.claimant, depositor); + assert_eq!(record.resolver, resolver); + assert_eq!(record.status, DisputeStatus::Open); + }); +} + +#[test] +fn test_resolve_dispute_for_beneficiary() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let resolver = Address::generate(&e); + let (_depositor, beneficiary, escrow_id) = setup_escrow(&e, &contract_id); + + e.as_contract(&contract_id, || { + let dispute_id = + open_dispute(&e, beneficiary.clone(), escrow_id, resolver.clone()); + resolve_dispute(&e, resolver.clone(), dispute_id, true); + + let record = get_dispute(&e, dispute_id); + assert_eq!(record.status, DisputeStatus::ResolvedForBeneficiary); + + let escrow = get_escrow(&e, escrow_id); + assert!(escrow.released); + + assert_eq!(read_balance(&e, beneficiary.clone()), 1_000); + }); +} + +#[test] +fn test_resolve_dispute_for_depositor() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let resolver = Address::generate(&e); + let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id); + + e.as_contract(&contract_id, || { + let dispute_id = + open_dispute(&e, depositor.clone(), escrow_id, resolver.clone()); + resolve_dispute(&e, resolver.clone(), dispute_id, false); + + let record = get_dispute(&e, dispute_id); + assert_eq!(record.status, DisputeStatus::ResolvedForDepositor); + + let escrow = get_escrow(&e, escrow_id); + assert!(escrow.refunded); + + assert_eq!(read_balance(&e, depositor.clone()), 1_000); + }); +} + +#[test] +#[should_panic(expected = "UnauthorizedResolver")] +fn test_resolve_dispute_wrong_resolver_panics() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let resolver = Address::generate(&e); + let impostor = Address::generate(&e); + let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id); + + e.as_contract(&contract_id, || { + let dispute_id = open_dispute(&e, depositor.clone(), escrow_id, resolver.clone()); + resolve_dispute(&e, impostor.clone(), dispute_id, true); + }); +} + +#[test] +#[should_panic(expected = "AlreadyResolved")] +fn test_double_resolve_panics() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let resolver = Address::generate(&e); + let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id); + + e.as_contract(&contract_id, || { + let dispute_id = open_dispute(&e, depositor.clone(), escrow_id, resolver.clone()); + resolve_dispute(&e, resolver.clone(), dispute_id, true); + resolve_dispute(&e, resolver.clone(), dispute_id, false); + }); +} + +#[test] +#[should_panic(expected = "InvalidState")] +fn test_open_dispute_on_settled_escrow_panics() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let resolver = Address::generate(&e); + let (_depositor, beneficiary, escrow_id) = setup_escrow(&e, &contract_id); + + e.as_contract(&contract_id, || { + crate::escrow::release_escrow(&e, beneficiary.clone(), escrow_id); + open_dispute(&e, beneficiary.clone(), escrow_id, resolver.clone()); + }); +} diff --git a/veritixpay/contract/token/src/lib.rs b/veritixpay/contract/token/src/lib.rs index e8949df..688ba4c 100644 --- a/veritixpay/contract/token/src/lib.rs +++ b/veritixpay/contract/token/src/lib.rs @@ -7,9 +7,11 @@ pub mod admin; pub mod allowance; pub mod balance; +pub mod dispute; pub mod escrow; pub mod freeze; pub mod metadata; +pub mod splitter; pub mod storage_types; pub mod validation; @@ -24,4 +26,10 @@ mod escrow_test; #[cfg(test)] mod admin_test; +#[cfg(test)] +mod splitter_test; + +#[cfg(test)] +mod dispute_test; + pub use crate::contract::VeritixToken; diff --git a/veritixpay/contract/token/src/splitter.rs b/veritixpay/contract/token/src/splitter.rs index 1d5fd9e..ddab9be 100644 --- a/veritixpay/contract/token/src/splitter.rs +++ b/veritixpay/contract/token/src/splitter.rs @@ -29,10 +29,24 @@ pub fn create_split( require_positive_amount(total_amount); sender.require_auth(); - // 1. Validate BPS Sums to 10000 (100.00%) + // 1. Reject empty recipient list + if recipients.is_empty() { + panic!("recipients list cannot be empty"); + } + + // 2. Validate recipients: no zero-share, no duplicates; BPS sums to 10000 let mut total_bps: u32 = 0; - for recipient in recipients.iter() { - total_bps += recipient.share_bps; + for i in 0..recipients.len() { + let r = recipients.get(i).unwrap(); + if r.share_bps == 0 { + panic!("recipient share_bps cannot be zero"); + } + for j in (i + 1)..recipients.len() { + if r.address == recipients.get(j).unwrap().address { + panic!("duplicate recipient address"); + } + } + total_bps += r.share_bps; } if total_bps != 10000 { panic!("total bps must equal 10000"); diff --git a/veritixpay/contract/token/src/splitter_test.rs b/veritixpay/contract/token/src/splitter_test.rs index 5007b18..95bc0bc 100644 --- a/veritixpay/contract/token/src/splitter_test.rs +++ b/veritixpay/contract/token/src/splitter_test.rs @@ -1,96 +1,177 @@ -#[cfg(test)] -mod splitter_tests { - use super::*; - // Replace with your actual environment imports (e.g., soroban_sdk or cosmwasm_std) - use crate::{Contract, Recipient}; - - #[test] - fn test_create_split() { - let env = setup_env(); - let sender = env.address("sender"); - let total_amount = 10_000u128; - - // Verify record is stored and initial state is correct - let split_id = create_split(&env, &sender, total_amount); - let split = get_split(&env, split_id); - - assert_eq!(split.sender, sender); - assert_eq!(split.amount, total_amount); - // Add check for sender balance deduction here based on your ledger implementation - } +use soroban_sdk::{testutils::Address as _, Address, Env, Vec}; - #[test] - fn test_distribute_two_recipients() { - let env = setup_env(); - let recipients = vec![ - Recipient { addr: env.address("u1"), bps: 5000 }, - Recipient { addr: env.address("u2"), bps: 5000 }, - ]; - - let results = calculate_distribution(1000, &recipients); - assert_eq!(results[0].amount, 500); - assert_eq!(results[1].amount, 500); - } +use crate::balance::read_balance; +use crate::contract::VeritixToken; +use crate::splitter::{create_split, distribute, get_split, SplitRecipient}; - #[test] - fn test_distribute_three_recipients() { - let env = setup_env(); - let recipients = vec![ - Recipient { addr: env.address("u1"), bps: 5000 }, - Recipient { addr: env.address("u2"), bps: 3000 }, - Recipient { addr: env.address("u3"), bps: 2000 }, - ]; - - let results = calculate_distribution(1000, &recipients); - assert_eq!(results[0].amount, 500); - assert_eq!(results[1].amount, 300); - assert_eq!(results[2].amount, 200); - } +fn setup_env() -> Env { + let e = Env::default(); + e.mock_all_auths(); + e +} - #[test] - #[should_panic(expected = "BPS_SUM_MUST_BE_10000")] - fn test_invalid_bps_panics() { - let recipients = vec![Recipient { addr: "u1", bps: 9999 }]; - validate_split_config(&recipients); +fn make_recipients(e: &Env, shares: &[(Address, u32)]) -> Vec { + let mut v = Vec::new(e); + for (addr, bps) in shares { + v.push_back(SplitRecipient { + address: addr.clone(), + share_bps: *bps, + }); } + v +} - #[test] - #[should_panic(expected = "ALREADY_DISTRIBUTED")] - fn test_double_distribute_panics() { - let mut split = setup_active_split(); - distribute(&mut split); // First call - distribute(&mut split); // Should panic - } +#[test] +fn test_create_split_stores_record() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let r1 = Address::generate(&e); + let r2 = Address::generate(&e); - #[test] - #[should_panic(expected = "UNAUTHORIZED")] - fn test_distribute_unauthorized_panics() { - let env = setup_env(); - let hacker = env.address("hacker"); - distribute_as(&env, hacker, split_id); - } + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 1000); + let recipients = make_recipients(&e, &[(r1.clone(), 5000), (r2.clone(), 5000)]); + let split_id = create_split(&e, sender.clone(), recipients, 1000); + let record = get_split(&e, split_id); + assert_eq!(record.sender, sender); + assert_eq!(record.total_amount, 1000); + assert!(!record.distributed); + }); +} - #[test] - fn test_distribute_rounds_correctly() { - let env = setup_env(); - // Case: 10 units split between 3 people (3333, 3333, 3334 BPS) - let recipients = vec![ - Recipient { addr: env.address("u1"), bps: 3333 }, - Recipient { addr: env.address("u2"), bps: 3333 }, - Recipient { addr: env.address("u3"), bps: 3334 }, - ]; - - let total = 10u128; - let shares = calculate_distribution(total, &recipients); - - let sum: u128 = shares.iter().map(|s| s.amount).sum(); - - // Mathematically: (3.333) + (3.333) + (3.334) = 10.0 - // In integer math: 3 + 3 + 3 = 9. - // We must ensure the sum equals the total. - assert_eq!(sum, total, "Rounding error: Dust remaining in contract"); - assert_eq!(shares[0].amount, 3); - assert_eq!(shares[1].amount, 3); - assert_eq!(shares[2].amount, 4); // Last recipient picks up the remainder - } -} \ No newline at end of file +#[test] +fn test_distribute_two_recipients_equal_split() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let r1 = Address::generate(&e); + let r2 = Address::generate(&e); + + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 1000); + let recipients = make_recipients(&e, &[(r1.clone(), 5000), (r2.clone(), 5000)]); + let split_id = create_split(&e, sender.clone(), recipients, 1000); + distribute(&e, sender.clone(), split_id); + assert_eq!(read_balance(&e, r1.clone()), 500); + assert_eq!(read_balance(&e, r2.clone()), 500); + assert!(get_split(&e, split_id).distributed); + }); +} + +#[test] +fn test_distribute_rounding_dust_goes_to_last_recipient() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let r1 = Address::generate(&e); + let r2 = Address::generate(&e); + let r3 = Address::generate(&e); + + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 10); + // 3333 + 3333 + 3334 = 10000 bps; 10 units → 3 + 3 + 4 + let recipients = make_recipients( + &e, + &[(r1.clone(), 3333), (r2.clone(), 3333), (r3.clone(), 3334)], + ); + let split_id = create_split(&e, sender.clone(), recipients, 10); + distribute(&e, sender.clone(), split_id); + assert_eq!(read_balance(&e, r1.clone()), 3); + assert_eq!(read_balance(&e, r2.clone()), 3); + assert_eq!(read_balance(&e, r3.clone()), 4); + }); +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_distribute_unauthorized_panics() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let hacker = Address::generate(&e); + let r1 = Address::generate(&e); + + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 1000); + let recipients = make_recipients(&e, &[(r1.clone(), 10000)]); + let split_id = create_split(&e, sender.clone(), recipients, 1000); + distribute(&e, hacker.clone(), split_id); + }); +} + +#[test] +#[should_panic(expected = "already distributed")] +fn test_double_distribute_panics() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let r1 = Address::generate(&e); + + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 1000); + let recipients = make_recipients(&e, &[(r1.clone(), 10000)]); + let split_id = create_split(&e, sender.clone(), recipients, 1000); + distribute(&e, sender.clone(), split_id); + distribute(&e, sender.clone(), split_id); + }); +} + +#[test] +#[should_panic(expected = "recipients list cannot be empty")] +fn test_create_split_rejects_empty_recipients() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 1000); + let recipients: Vec = Vec::new(&e); + create_split(&e, sender.clone(), recipients, 1000); + }); +} + +#[test] +#[should_panic(expected = "recipient share_bps cannot be zero")] +fn test_create_split_rejects_zero_share_recipient() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let r1 = Address::generate(&e); + let r2 = Address::generate(&e); + + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 1000); + let recipients = make_recipients(&e, &[(r1.clone(), 10000), (r2.clone(), 0)]); + create_split(&e, sender.clone(), recipients, 1000); + }); +} + +#[test] +#[should_panic(expected = "duplicate recipient address")] +fn test_create_split_rejects_duplicate_recipients() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let r1 = Address::generate(&e); + + e.as_contract(&contract_id, || { + crate::balance::receive_balance(&e, sender.clone(), 1000); + let recipients = make_recipients(&e, &[(r1.clone(), 5000), (r1.clone(), 5000)]); + create_split(&e, sender.clone(), recipients, 1000); + }); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_create_split_rejects_non_positive_amount() { + let e = setup_env(); + let contract_id = e.register_contract(None, VeritixToken); + let sender = Address::generate(&e); + let r1 = Address::generate(&e); + + e.as_contract(&contract_id, || { + let recipients = make_recipients(&e, &[(r1.clone(), 10000)]); + create_split(&e, sender.clone(), recipients, 0); + }); +}