diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index bde49e3..df77d4f 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -492,6 +492,13 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispute" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "downcast-rs" version = "1.2.1" diff --git a/contracts/contracts/dispute/Cargo.toml b/contracts/contracts/dispute/Cargo.toml new file mode 100644 index 0000000..17ba874 --- /dev/null +++ b/contracts/contracts/dispute/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dispute" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/contracts/dispute/Makefile b/contracts/contracts/dispute/Makefile new file mode 100644 index 0000000..efc2633 --- /dev/null +++ b/contracts/contracts/dispute/Makefile @@ -0,0 +1,16 @@ +default: build + +all: test + +test: build + cargo test + +build: + cargo build --target wasm32-unknown-unknown --release + cargo build --target wasm32-unknown-unknown --release --profile release-with-logs + +fmt: + cargo fmt --all + +clean: + cargo clean diff --git a/contracts/contracts/dispute/src/lib.rs b/contracts/contracts/dispute/src/lib.rs new file mode 100644 index 0000000..3a1d4cd --- /dev/null +++ b/contracts/contracts/dispute/src/lib.rs @@ -0,0 +1,257 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, contracterror, token, Address, Env, String +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[contracttype] +pub enum DisputeStatus { + Open = 0, + Resolved = 1, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[contracttype] +pub struct Dispute { + pub id: u32, + pub title: String, + pub description: String, + pub end_time: u64, + pub votes_for: i128, + pub votes_against: i128, + pub status: DisputeStatus, + pub disputed_amount: i128, + pub winner_address: Address, + pub loser_address: Address, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[contracttype] +pub enum DataKey { + Token, + DisputeCount, + Dispute(u32), + Stake(Address, u32), // User's stake on a specific dispute + VoteSupport(Address, u32), // User's vote choice (true = for, false = against) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[contracterror] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + DisputeNotFound = 3, + DisputeClosed = 4, + DisputeNotClosed = 5, + VotingEnded = 6, + VotingNotEnded = 7, + ZeroAmount = 8, + AlreadyVoted = 9, + NoStake = 10, + DisputeNotResolved = 11, +} + +#[contract] +pub struct DisputeContract; + +#[contractimpl] +impl DisputeContract { + pub fn initialize(env: Env, token: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Token) { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Token, &token); + env.storage().instance().set(&DataKey::DisputeCount, &0u32); + Ok(()) + } + + pub fn create_dispute( + env: Env, + title: String, + description: String, + duration: u64, + disputed_amount: i128, + winner_address: Address, + loser_address: Address, + ) -> Result { + let caller = env.caller(); + caller.require_auth(); + + if !env.storage().instance().has(&DataKey::Token) { + return Err(Error::NotInitialized); + } + + if disputed_amount <= 0 { + return Err(Error::ZeroAmount); + } + + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized)?; + + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&caller, &env.current_contract_address(), &disputed_amount); + + let dispute_id: u32 = env.storage().instance().get(&DataKey::DisputeCount).unwrap_or(0); + let current_time = env.ledger().timestamp(); + + let dispute = Dispute { + id: dispute_id, + title, + description, + end_time: current_time + duration, + votes_for: 0, + votes_against: 0, + status: DisputeStatus::Open, + disputed_amount, + winner_address, + loser_address, + }; + + env.storage().instance().set(&DataKey::Dispute(dispute_id), &dispute); + env.storage().instance().set(&DataKey::DisputeCount, &(dispute_id + 1)); + + Ok(dispute_id) + } + + pub fn vote( + env: Env, + voter: Address, + dispute_id: u32, + amount: i128, + support: bool, + ) -> Result<(), Error> { + voter.require_auth(); + + if amount <= 0 { + return Err(Error::ZeroAmount); + } + + let mut dispute: Dispute = env + .storage() + .instance() + .get(&DataKey::Dispute(dispute_id)) + .ok_or(Error::DisputeNotFound)?; + + if dispute.status != DisputeStatus::Open { + return Err(Error::DisputeClosed); + } + + if env.ledger().timestamp() >= dispute.end_time { + return Err(Error::VotingEnded); + } + + let stake_key = DataKey::Stake(voter.clone(), dispute_id); + if env.storage().instance().has(&stake_key) { + return Err(Error::AlreadyVoted); // Simplification: one vote per address + } + + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized)?; + + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&voter, &env.current_contract_address(), &amount); + + env.storage().instance().set(&stake_key, &amount); + env.storage().instance().set(&DataKey::VoteSupport(voter.clone(), dispute_id), &support); + + if support { + dispute.votes_for += amount; + } else { + dispute.votes_against += amount; + } + + env.storage().instance().set(&DataKey::Dispute(dispute_id), &dispute); + + Ok(()) + } + + pub fn resolve(env: Env, dispute_id: u32) -> Result { + let mut dispute: Dispute = env + .storage() + .instance() + .get(&DataKey::Dispute(dispute_id)) + .ok_or(Error::DisputeNotFound)?; + + if dispute.status != DisputeStatus::Open { + return Err(Error::DisputeClosed); + } + + if env.ledger().timestamp() < dispute.end_time { + return Err(Error::VotingNotEnded); + } + + let is_in_favor = dispute.votes_for > dispute.votes_against; + dispute.status = DisputeStatus::Resolved; + + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized)?; + + let token_client = token::Client::new(&env, &token_address); + + if is_in_favor { + token_client.transfer(&env.current_contract_address(), &dispute.winner_address, &dispute.disputed_amount); + } else { + token_client.transfer(&env.current_contract_address(), &dispute.loser_address, &dispute.disputed_amount); + } + + env.storage().instance().set(&DataKey::Dispute(dispute_id), &dispute); + + Ok(is_in_favor) + } + + pub fn claim_stake(env: Env, voter: Address, dispute_id: u32) -> Result<(), Error> { + voter.require_auth(); + + let dispute: Dispute = env + .storage() + .instance() + .get(&DataKey::Dispute(dispute_id)) + .ok_or(Error::DisputeNotFound)?; + + if dispute.status != DisputeStatus::Resolved { + return Err(Error::DisputeNotResolved); + } + + let stake_key = DataKey::Stake(voter.clone(), dispute_id); + let amount: i128 = env.storage().instance().get(&stake_key).unwrap_or(0); + + if amount == 0 { + return Err(Error::NoStake); + } + + // Return the stake back to the voter + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized)?; + + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&env.current_contract_address(), &voter, &amount); + + // Remove stake to prevent double claiming + env.storage().instance().remove(&stake_key); + + Ok(()) + } + + // Getters + pub fn get_dispute(env: Env, dispute_id: u32) -> Result { + env.storage().instance().get(&DataKey::Dispute(dispute_id)).ok_or(Error::DisputeNotFound) + } + + pub fn get_dispute_count(env: Env) -> u32 { + env.storage().instance().get(&DataKey::DisputeCount).unwrap_or(0) + } +} + +mod test; diff --git a/contracts/contracts/dispute/src/test.rs b/contracts/contracts/dispute/src/test.rs new file mode 100644 index 0000000..280e376 --- /dev/null +++ b/contracts/contracts/dispute/src/test.rs @@ -0,0 +1,158 @@ +#![cfg(test)] +extern crate alloc; + +use alloc::string::ToString; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, String +}; + +use crate::{DisputeContract, DisputeContractClient, DisputeStatus, Error}; +use soroban_sdk::token; + +fn create_token_contract<'a>(e: &Env, admin: &Address) -> token::Client<'a> { + token::Client::new(e, &e.register_stellar_asset_contract(admin.clone())) +} + +#[test] +fn test_initialization() { + let env = Env::default(); + let contract_id = env.register_contract(None, DisputeContract); + let client = DisputeContractClient::new(&env, &contract_id); + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + + client.initialize(&token.address); + assert_eq!(client.get_dispute_count(), 0); +} + +#[test] +fn test_create_dispute() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, DisputeContract); + let client = DisputeContractClient::new(&env, &contract_id); + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + + client.initialize(&token.address); + + let title = String::from_str(&env, "Dispute 1"); + let description = String::from_str(&env, "Client vs Freelancer"); + let duration = 86400; // 1 day + let disputed_amount = 5000; + let winner = Address::generate(&env); + let loser = Address::generate(&env); + let caller = Address::generate(&env); + + let token_admin_client = token::StellarAssetClient::new(&env, &token.address); + token_admin_client.mint(&caller, &disputed_amount); + + env.ledger().set_timestamp(1000); + + let dispute_id = client.create_dispute(&title, &description, &duration, &disputed_amount, &winner, &loser); + + assert_eq!(dispute_id, 0); + assert_eq!(client.get_dispute_count(), 1); + + let dispute = client.get_dispute(&dispute_id); + assert_eq!(dispute.id, 0); + assert_eq!(dispute.title, title); + assert_eq!(dispute.end_time, 1000 + duration); + assert_eq!(dispute.status, DisputeStatus::Open); + assert_eq!(dispute.disputed_amount, disputed_amount); + assert_eq!(dispute.winner_address, winner); + assert_eq!(dispute.loser_address, loser); +} + +#[test] +fn test_voting_and_resolving() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, DisputeContract); + let client = DisputeContractClient::new(&env, &contract_id); + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + + client.initialize(&token.address); + + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + let winner = Address::generate(&env); + let loser = Address::generate(&env); + let caller = Address::generate(&env); + let disputed_amount = 2000; + + // Mint tokens to voters and caller + let token_admin_client = token::StellarAssetClient::new(&env, &token.address); + token_admin_client.mint(&voter1, &1000); + token_admin_client.mint(&voter2, &500); + token_admin_client.mint(&caller, &disputed_amount); + + let title = String::from_str(&env, "Dispute"); + let desc = String::from_str(&env, "Desc"); + + env.ledger().set_timestamp(1000); + let dispute_id = client.create_dispute(&title, &desc, &100, &disputed_amount, &winner, &loser); + + client.vote(&voter1, &dispute_id, &1000, &true); + client.vote(&voter2, &dispute_id, &500, &false); + + let dispute_mid = client.get_dispute(&dispute_id); + assert_eq!(dispute_mid.votes_for, 1000); + assert_eq!(dispute_mid.votes_against, 500); + + // Fast forward to after deadline + env.ledger().set_timestamp(1101); + + let is_resolved_in_favor = client.resolve(&dispute_id); + assert_eq!(is_resolved_in_favor, true); // votes_for > votes_against + + let dispute_final = client.get_dispute(&dispute_id); + assert_eq!(dispute_final.status, DisputeStatus::Resolved); + + // Check disputed amount distribution + assert_eq!(token.balance(&winner), disputed_amount); + assert_eq!(token.balance(&loser), 0); + + // Claim stakes + client.claim_stake(&voter1, &dispute_id); + client.claim_stake(&voter2, &dispute_id); + + assert_eq!(token.balance(&voter1), 1000); + assert_eq!(token.balance(&voter2), 500); + assert_eq!(token.balance(&contract_id), 0); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #6)")] +fn test_vote_after_deadline() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, DisputeContract); + let client = DisputeContractClient::new(&env, &contract_id); + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + + client.initialize(&token.address); + + let voter = Address::generate(&env); + let token_admin_client = token::StellarAssetClient::new(&env, &token.address); + token_admin_client.mint(&voter, &100); + + env.ledger().set_timestamp(1000); + let dispute_id = client.create_dispute( + &String::from_str(&env, "title"), + &String::from_str(&env, "desc"), + &100, + &0, + &Address::generate(&env), + &Address::generate(&env) + ); + + env.ledger().set_timestamp(1101); // Past deadline + + client.vote(&voter, &dispute_id, &100, &true); // Should panic with VotingEnded (6) +}