From 0a8484f73c6df3ea6dfc5d6b0cfdd2fc2488ad80 Mon Sep 17 00:00:00 2001 From: Prasiejames Date: Thu, 27 Aug 2026 15:21:23 +0000 Subject: [PATCH] Implement durable on-chain dispute voting in treasury contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispute resolution votes previously had no durable storage anywhere: vote_dispute_resolution was documented in the ABI and glossary but not implemented, and resolve_dispute was an empty stub. Any vote state kept by the backend lived only in memory, so it was silently lost on process restarts and not shared across replicas. Add an on-chain Dispute record (status, resolution_weight, voters) persisted under DataKey::Dispute(settlement_id). raise_dispute now stores the record and emits dispute_raised; vote_dispute_resolution records each signer's vote with double-vote and authorization guards and auto-resolves once cumulative weight reaches the threshold; resolve_dispute finalizes explicitly. Emit dispute_resolution_voted and dispute_resolved events, add TreasuryError variants for the new failure modes, and cover the lifecycle with contract tests. Also sync abis/treasury.json errors to the implemented enum and document that votes are stored on-chain plus the post-resolution settlement outcome in docs/glossary.md. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../contracts/treasury/Cargo.toml | 3 + .../contracts/treasury/src/events.rs | 33 ++ .../contracts/treasury/src/lib.rs | 353 +++++++++++++++++- abis/treasury.json | 24 +- docs/glossary.md | 4 +- 5 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 COMEBACKHERE-contracts/contracts/treasury/src/events.rs diff --git a/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml b/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml index 3954634..05f2cff 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml +++ b/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml @@ -9,6 +9,9 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = "20.0.0" +[dev-dependencies] +soroban-sdk = { version = "20.0.0", features = ["testutils"] } + [profile.release] opt-level = "z" overflow-checks = true diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/events.rs b/COMEBACKHERE-contracts/contracts/treasury/src/events.rs new file mode 100644 index 0000000..ad3d328 --- /dev/null +++ b/COMEBACKHERE-contracts/contracts/treasury/src/events.rs @@ -0,0 +1,33 @@ +use soroban_sdk::{Address, Env, Symbol}; + +pub fn dispute_raised(env: &Env, settlement_id: &u64, raised_by: &Address, reason: &u32) { + env.events().publish( + (Symbol::new(env, "dispute_raised"),), + (settlement_id, raised_by, reason), + ); +} + +pub fn dispute_resolution_voted( + env: &Env, + settlement_id: &u64, + signer: &Address, + weight: &u64, + resolution_weight: &u64, +) { + env.events().publish( + (Symbol::new(env, "dispute_resolution_voted"),), + (settlement_id, signer, weight, resolution_weight), + ); +} + +pub fn dispute_resolved( + env: &Env, + settlement_id: &u64, + resolve_in_favor: &bool, + resolution_weight: &u64, +) { + env.events().publish( + (Symbol::new(env, "dispute_resolved"),), + (settlement_id, resolve_in_favor, resolution_weight), + ); +} diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index cc852a2..f5377cf 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -1,6 +1,8 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Vec}; +mod events; + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Vec}; #[contracttype] pub enum SettlementStatus { @@ -21,6 +23,29 @@ pub struct Settlement { pub proposer: Address, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeStatus { + Raised, + ResolvedClaimant, + ResolvedCounterparty, +} + +// Dispute records are stored on-chain so resolution votes are durable: +// they survive process restarts and are shared across backend replicas. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Dispute { + pub settlement_id: u64, + pub status: DisputeStatus, + /// Cumulative weight of the signers who have voted on the resolution. + pub resolution_weight: u64, + /// Signers who already voted, so each signer can only vote once. + pub voters: Vec
, + pub raised_by: Address, + pub reason: u32, +} + #[contract] pub struct TreasuryContract; @@ -119,13 +144,96 @@ impl TreasuryContract { pub fn raise_dispute(e: Env, signer: Address, settlement_id: u64, reason: u32) { signer.require_auth(); + + if e.storage().instance().get(&DataKey::Paused).unwrap_or(false) { + panic_with_error!(&e, TreasuryError::ContractPaused); + } + + if e.storage().instance().has(&DataKey::Dispute(settlement_id)) { + panic_with_error!(&e, TreasuryError::DisputeAlreadyRaised); + } + let mut settlement = Self::get_settlement_internal(&e, settlement_id); settlement.status = SettlementStatus::OnHold; e.storage().instance().set(&DataKey::Settlement(settlement_id), &settlement); + + let dispute = Dispute { + settlement_id, + status: DisputeStatus::Raised, + resolution_weight: 0u64, + voters: Vec::new(&e), + raised_by: signer.clone(), + reason, + }; + e.storage().instance().set(&DataKey::Dispute(settlement_id), &dispute); + events::dispute_raised(&e, &settlement_id, &signer, &reason); + } + + pub fn vote_dispute_resolution( + e: Env, + signer: Address, + settlement_id: u64, + resolve_in_favor: bool, + ) { + signer.require_auth(); + + if e.storage().instance().get(&DataKey::Paused).unwrap_or(false) { + panic_with_error!(&e, TreasuryError::ContractPaused); + } + + let mut dispute = Self::get_dispute_internal(&e, settlement_id); + if dispute.status != DisputeStatus::Raised { + panic_with_error!(&e, TreasuryError::DisputeNotRaised); + } + + let weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(signer.clone())) + .unwrap_or(0u64); + if weight == 0 { + panic_with_error!(&e, TreasuryError::UnauthorizedSigner); + } + + if dispute.voters.iter().any(|voter| voter == signer) { + panic_with_error!(&e, TreasuryError::AlreadyVoted); + } + + dispute.voters.push(signer.clone()); + dispute.resolution_weight += weight; + e.storage().instance().set(&DataKey::Dispute(settlement_id), &dispute); + events::dispute_resolution_voted( + &e, + &settlement_id, + &signer, + &weight, + &dispute.resolution_weight, + ); + + let threshold: u64 = e.storage().instance().get(&DataKey::Threshold).unwrap_or(0u64); + if threshold > 0 && dispute.resolution_weight >= threshold { + Self::finalize_dispute_internal(&e, settlement_id, resolve_in_favor); + } } pub fn resolve_dispute(e: Env, signer: Address, settlement_id: u64, resolve_in_favor: bool) { signer.require_auth(); + + if e.storage().instance().get(&DataKey::Paused).unwrap_or(false) { + panic_with_error!(&e, TreasuryError::ContractPaused); + } + + let dispute = Self::get_dispute_internal(&e, settlement_id); + if dispute.status != DisputeStatus::Raised { + panic_with_error!(&e, TreasuryError::DisputeNotRaised); + } + + let threshold: u64 = e.storage().instance().get(&DataKey::Threshold).unwrap_or(0u64); + if dispute.resolution_weight < threshold { + panic_with_error!(&e, TreasuryError::ThresholdNotMet); + } + + Self::finalize_dispute_internal(&e, settlement_id, resolve_in_favor); } pub fn deposit(e: Env, from: Address, amount: u64) { @@ -142,6 +250,41 @@ impl TreasuryContract { .get(&DataKey::Settlement(settlement_id)) .unwrap() } + + fn get_dispute_internal(e: &Env, settlement_id: u64) -> Dispute { + e.storage() + .instance() + .get(&DataKey::Dispute(settlement_id)) + .unwrap_or_else(|| panic_with_error!(e, TreasuryError::DisputeNotFound)) + } + + fn finalize_dispute_internal(e: &Env, settlement_id: u64, resolve_in_favor: bool) { + let mut dispute: Dispute = e + .storage() + .instance() + .get(&DataKey::Dispute(settlement_id)) + .unwrap_or_else(|| panic_with_error!(e, TreasuryError::DisputeNotFound)); + + dispute.status = if resolve_in_favor { + DisputeStatus::ResolvedClaimant + } else { + DisputeStatus::ResolvedCounterparty + }; + e.storage().instance().set(&DataKey::Dispute(settlement_id), &dispute); + + // In favour of the claimant (the dispute raiser): the settlement is voided. + // In favour of the counterparty (the merchant): the settlement resumes as + // Pending and can proceed through the normal approval/execution flow. + let mut settlement = Self::get_settlement_internal(e, settlement_id); + settlement.status = if resolve_in_favor { + SettlementStatus::Cancelled + } else { + SettlementStatus::Pending + }; + e.storage().instance().set(&DataKey::Settlement(settlement_id), &settlement); + + events::dispute_resolved(e, &settlement_id, &resolve_in_favor, &dispute.resolution_weight); + } } #[contracterror] @@ -151,6 +294,12 @@ pub enum TreasuryError { ContractPaused = 1, NotPending = 2, InsufficientApprovals = 3, + DisputeNotFound = 4, + DisputeAlreadyRaised = 5, + DisputeNotRaised = 6, + AlreadyVoted = 7, + UnauthorizedSigner = 8, + ThresholdNotMet = 9, } #[contracttype] @@ -159,6 +308,208 @@ pub enum DataKey { Paused, Signer(Address), Settlement(u64), + Dispute(u64), NextSettlementId, Threshold, } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{vec, Env}; + + struct TestContext { + env: Env, + contract_id: Address, + signer1: Address, + signer2: Address, + signer3: Address, + token: Address, + merchant: Address, + } + + fn setup() -> TestContext { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + let signer3 = Address::generate(&env); + let token = Address::generate(&env); + let merchant = Address::generate(&env); + + let signers = vec![ + &env, + (signer1.clone(), 1u64), + (signer2.clone(), 1u64), + (signer3.clone(), 1u64), + ]; + + let contract_id = env.register_contract(None, TreasuryContract); + let client = TreasuryContractClient::new(&env, &contract_id); + client.initialize(&signers, &2u64, &admin); + + TestContext { + env, + contract_id, + signer1, + signer2, + signer3, + token, + merchant, + } + } + + fn propose_and_raise(ctx: &TestContext) -> u64 { + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + let settlement_id = client.propose_settlement(&ctx.signer1, &ctx.token, &1000u64, &ctx.merchant); + client.raise_dispute(&ctx.signer1, &settlement_id, &1u32); + settlement_id + } + + fn read_dispute(ctx: &TestContext, settlement_id: u64) -> Dispute { + ctx.env.as_contract(&ctx.contract_id, || { + ctx.env + .storage() + .instance() + .get(&DataKey::Dispute(settlement_id)) + .unwrap() + }) + } + + fn read_settlement(ctx: &TestContext, settlement_id: u64) -> Settlement { + ctx.env.as_contract(&ctx.contract_id, || { + ctx.env + .storage() + .instance() + .get(&DataKey::Settlement(settlement_id)) + .unwrap() + }) + } + + #[test] + fn test_raise_dispute_holds_settlement_and_records_dispute() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let dispute = read_dispute(&ctx, settlement_id); + assert_eq!(dispute.status, DisputeStatus::Raised); + assert_eq!(dispute.resolution_weight, 0u64); + assert_eq!(dispute.raised_by, ctx.signer1); + assert_eq!(dispute.reason, 1u32); + assert!(dispute.voters.is_empty()); + + let settlement = read_settlement(&ctx, settlement_id); + assert!(matches!(settlement.status, SettlementStatus::OnHold)); + } + + #[test] + fn test_raise_dispute_twice_fails() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + let result = client.try_raise_dispute(&ctx.signer2, &settlement_id, &2u32); + assert_eq!(result, Err(Ok(TreasuryError::DisputeAlreadyRaised))); + } + + #[test] + fn test_votes_resolve_dispute_in_favour_of_claimant() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + + // First vote: weight 1 < threshold 2, dispute stays Raised. + client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + let dispute = read_dispute(&ctx, settlement_id); + assert_eq!(dispute.status, DisputeStatus::Raised); + assert_eq!(dispute.resolution_weight, 1u64); + + // Second vote reaches the threshold and resolves in favour of the claimant. + client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &true); + let dispute = read_dispute(&ctx, settlement_id); + assert_eq!(dispute.status, DisputeStatus::ResolvedClaimant); + assert_eq!(dispute.resolution_weight, 2u64); + + let settlement = read_settlement(&ctx, settlement_id); + assert!(matches!(settlement.status, SettlementStatus::Cancelled)); + } + + #[test] + fn test_votes_resolve_dispute_in_favour_of_counterparty() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &false); + client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &false); + + let dispute = read_dispute(&ctx, settlement_id); + assert_eq!(dispute.status, DisputeStatus::ResolvedCounterparty); + + let settlement = read_settlement(&ctx, settlement_id); + assert!(matches!(settlement.status, SettlementStatus::Pending)); + } + + #[test] + fn test_signer_cannot_vote_twice() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + + let result = client.try_vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + assert_eq!(result, Err(Ok(TreasuryError::AlreadyVoted))); + } + + #[test] + fn test_non_signer_cannot_vote() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + let outsider = Address::generate(&ctx.env); + + let result = client.try_vote_dispute_resolution(&outsider, &settlement_id, &true); + assert_eq!(result, Err(Ok(TreasuryError::UnauthorizedSigner))); + } + + #[test] + fn test_vote_without_dispute_fails() { + let ctx = setup(); + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + let settlement_id = client.propose_settlement(&ctx.signer1, &ctx.token, &1000u64, &ctx.merchant); + + let result = client.try_vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + assert_eq!(result, Err(Ok(TreasuryError::DisputeNotFound))); + } + + #[test] + fn test_resolve_before_threshold_fails() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + + let result = client.try_resolve_dispute(&ctx.signer2, &settlement_id, &true); + assert_eq!(result, Err(Ok(TreasuryError::ThresholdNotMet))); + } + + #[test] + fn test_vote_after_resolution_fails() { + let ctx = setup(); + let settlement_id = propose_and_raise(&ctx); + + let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); + client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &true); + + let result = client.try_vote_dispute_resolution(&ctx.signer3, &settlement_id, &false); + assert_eq!(result, Err(Ok(TreasuryError::DisputeNotRaised))); + } +} diff --git a/abis/treasury.json b/abis/treasury.json index 9c120f1..b93dacd 100644 --- a/abis/treasury.json +++ b/abis/treasury.json @@ -61,20 +61,14 @@ "settlement_released" ], "errors": { - "1": "AlreadyInitialized", - "2": "ZeroThreshold", - "3": "SettlementNotFound", - "4": "AlreadyExecuted", - "5": "ThresholdNotMet", - "6": "ThresholdNotConfigured", - "7": "InvalidAmount", - "8": "ContractPaused", - "9": "Unauthorized", - "10": "UnauthorizedSigner", - "11": "InvalidTokenContract", - "12": "TokenNotAllowed", - "13": "RotationNotFound", - "14": "RotationAlreadyExecuted", - "15": "SettlementOnHold" + "1": "ContractPaused", + "2": "NotPending", + "3": "InsufficientApprovals", + "4": "DisputeNotFound", + "5": "DisputeAlreadyRaised", + "6": "DisputeNotRaised", + "7": "AlreadyVoted", + "8": "UnauthorizedSigner", + "9": "ThresholdNotMet" } } diff --git a/docs/glossary.md b/docs/glossary.md index 65c4906..1f1d1c8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -102,10 +102,10 @@ An optional list of token contract addresses accepted for settlement. If non-emp ## Dispute Terms **Dispute** -An on-chain record raised by a claimant against a counterparty over a specific settlement. Raising a dispute automatically places the referenced settlement `OnHold`. +An on-chain record raised by a claimant against a counterparty over a specific settlement. Raising a dispute automatically places the referenced settlement `OnHold`. Dispute records — including every resolution vote — are stored in the treasury contract's on-chain storage, so in-flight votes survive process restarts and are shared across backend replicas. **resolution_weight** -Cumulative weight of signers who have voted on the dispute resolution. When it reaches the treasury threshold the dispute transitions to `ResolvedClaimant` or `ResolvedCounterparty`. +Cumulative weight of signers who have voted on the dispute resolution. When it reaches the treasury threshold the dispute transitions to `ResolvedClaimant` or `ResolvedCounterparty`. A dispute resolved in favour of the counterparty (merchant) returns the settlement to `Pending` so it can resume the approval flow; a dispute resolved in favour of the claimant voids the settlement (`Cancelled`). ### DisputeStatus