Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions COMEBACKHERE-contracts/contracts/treasury/src/events.rs
Original file line number Diff line number Diff line change
@@ -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),
);
}
236 changes: 236 additions & 0 deletions COMEBACKHERE-contracts/contracts/treasury/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,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);
}
}

#[cfg(test)]
Expand Down Expand Up @@ -1108,3 +1143,204 @@ mod tests {
assert_eq!(c.try_withdraw(&non_admin, &non_admin, &100u64), Err(Ok(TreasuryError::Unauthorized)));
}
}

#[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)));
}
}
24 changes: 9 additions & 15 deletions abis/treasury.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
4 changes: 2 additions & 2 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ A settlement whose status is `OnHold`, meaning execution is blocked pending revi
## 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`).

**Dispute Quorum**
The minimum cumulative signer weight required to finalise a dispute resolution. This is the same value as the treasury's `threshold` — when the `resolution_weight` of signers who have voted reaches this threshold, the dispute transitions from `Raised` to `ResolvedClaimant` or `ResolvedCounterparty`.
Expand Down
Loading