Skip to content

Commit 2cbfeaa

Browse files
authored
Merge pull request #138 from nottherealalanturing/fix/wire-splitter-dispute-issues-90-91-92-93
feat: wire splitter & dispute modules, harden validation, add real tests
2 parents ed80926 + 0e2208d commit 2cbfeaa

6 files changed

Lines changed: 441 additions & 124 deletions

File tree

veritixpay/contract/token/src/contract.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,21 @@ use crate::balance::{
44
decrease_supply, increase_supply, read_balance, read_total_supply, receive_balance,
55
spend_balance,
66
};
7+
use crate::dispute::{get_dispute as dispute_get, open_dispute, resolve_dispute, DisputeRecord};
8+
use crate::escrow::{
9+
create_escrow as escrow_create, get_escrow as escrow_get, refund_escrow as escrow_refund,
10+
release_escrow as escrow_release, EscrowRecord,
11+
};
712
use crate::freeze::{freeze_account, is_frozen as read_frozen_status, unfreeze_account};
813
use crate::metadata::{
914
read_decimal, read_name, read_symbol, validate_metadata, write_metadata, TokenMetadata,
1015
};
16+
use crate::splitter::{
17+
create_split as split_create, distribute as split_distribute, get_split as split_get,
18+
SplitRecord, SplitRecipient,
19+
};
1120
use crate::validation::{require_not_frozen_account, require_positive_amount};
12-
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String};
21+
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Vec};
1322

1423
#[contract]
1524
pub struct VeritixToken;
@@ -171,4 +180,65 @@ impl VeritixToken {
171180
pub fn symbol(e: Env) -> String {
172181
read_symbol(&e)
173182
}
183+
184+
// --- Escrow ---
185+
186+
pub fn create_escrow(e: Env, depositor: Address, beneficiary: Address, amount: i128) -> u32 {
187+
escrow_create(&e, depositor, beneficiary, amount)
188+
}
189+
190+
pub fn release_escrow(e: Env, caller: Address, escrow_id: u32) {
191+
escrow_release(&e, caller, escrow_id)
192+
}
193+
194+
pub fn refund_escrow(e: Env, caller: Address, escrow_id: u32) {
195+
escrow_refund(&e, caller, escrow_id)
196+
}
197+
198+
pub fn get_escrow(e: Env, escrow_id: u32) -> EscrowRecord {
199+
escrow_get(&e, escrow_id)
200+
}
201+
202+
// --- Dispute ---
203+
204+
pub fn open_dispute(
205+
e: Env,
206+
claimant: Address,
207+
escrow_id: u32,
208+
resolver: Address,
209+
) -> u32 {
210+
open_dispute(&e, claimant, escrow_id, resolver)
211+
}
212+
213+
pub fn resolve_dispute(
214+
e: Env,
215+
resolver: Address,
216+
dispute_id: u32,
217+
release_to_beneficiary: bool,
218+
) {
219+
resolve_dispute(&e, resolver, dispute_id, release_to_beneficiary)
220+
}
221+
222+
pub fn get_dispute(e: Env, dispute_id: u32) -> DisputeRecord {
223+
dispute_get(&e, dispute_id)
224+
}
225+
226+
// --- Splitter ---
227+
228+
pub fn create_split(
229+
e: Env,
230+
sender: Address,
231+
recipients: Vec<SplitRecipient>,
232+
total_amount: i128,
233+
) -> u32 {
234+
split_create(&e, sender, recipients, total_amount)
235+
}
236+
237+
pub fn distribute(e: Env, caller: Address, split_id: u32) {
238+
split_distribute(&e, caller, split_id)
239+
}
240+
241+
pub fn get_split(e: Env, split_id: u32) -> SplitRecord {
242+
split_get(&e, split_id)
243+
}
174244
}

veritixpay/contract/token/src/dispute.rs

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
use crate::escrow::{get_escrow, release_escrow, refund_escrow};
2-
use crate::storage_types::{increment_counter, DataKey};
1+
use crate::balance::{receive_balance, spend_balance};
2+
use crate::escrow::get_escrow;
3+
use crate::storage_types::{increment_counter, write_persistent_record, DataKey};
34
use soroban_sdk::{contracttype, Address, Env, Symbol};
45

56
#[contracttype]
@@ -27,95 +28,109 @@ pub fn open_dispute(
2728
escrow_id: u32,
2829
resolver: Address,
2930
) -> u32 {
30-
// 1. Authorization: Only the claimant can initiate this call
3131
claimant.require_auth();
3232

33-
// 2. Fetch escrow and validate current state
3433
let escrow = get_escrow(e, escrow_id);
35-
36-
// Check if the escrow is already finalized
34+
3735
if escrow.released || escrow.refunded {
3836
panic!("InvalidState: Cannot open dispute on a settled escrow");
3937
}
4038

41-
// 3. Authorization check: Claimant must be a party involved in the escrow
4239
if claimant != escrow.depositor && claimant != escrow.beneficiary {
4340
panic!("Unauthorized: Only depositor or beneficiary can open a dispute");
4441
}
4542

46-
// 4. Generate a new Dispute ID using the counter in storage
4743
let count = increment_counter(e, &DataKey::DisputeCount);
4844

49-
// 5. Create and store the dispute record
5045
let record = DisputeRecord {
5146
id: count,
5247
escrow_id,
5348
claimant: claimant.clone(),
5449
resolver,
5550
status: DisputeStatus::Open,
5651
};
57-
58-
// Store in persistent storage as disputes may last longer than instance TTL
52+
5953
e.storage().persistent().set(&DataKey::Dispute(count), &record);
6054

61-
// 6. Emit Observability Event
6255
e.events().publish(
6356
(Symbol::new(e, "dispute"), Symbol::new(e, "opened"), escrow_id),
64-
claimant
57+
claimant,
6558
);
6659

6760
count
6861
}
6962

70-
/// Resolves an open dispute.
63+
/// Private helper: settle an escrow by outcome without requiring depositor/beneficiary auth.
64+
/// The resolver has already been authenticated by `resolve_dispute`.
65+
fn settle_escrow_by_outcome(e: &Env, escrow_id: u32, release_to_beneficiary: bool) {
66+
let mut escrow = get_escrow(e, escrow_id);
67+
68+
if escrow.released || escrow.refunded {
69+
panic!("AlreadySettled: escrow is already settled");
70+
}
71+
72+
if release_to_beneficiary {
73+
escrow.released = true;
74+
write_persistent_record(e, &DataKey::Escrow(escrow_id), &escrow);
75+
spend_balance(e, e.current_contract_address(), escrow.amount);
76+
receive_balance(e, escrow.beneficiary.clone(), escrow.amount);
77+
e.events().publish(
78+
(Symbol::new(e, "escrow"), Symbol::new(e, "released"), escrow_id),
79+
escrow.beneficiary,
80+
);
81+
} else {
82+
escrow.refunded = true;
83+
write_persistent_record(e, &DataKey::Escrow(escrow_id), &escrow);
84+
spend_balance(e, e.current_contract_address(), escrow.amount);
85+
receive_balance(e, escrow.depositor.clone(), escrow.amount);
86+
e.events().publish(
87+
(Symbol::new(e, "escrow"), Symbol::new(e, "refunded"), escrow_id),
88+
escrow.depositor,
89+
);
90+
}
91+
}
92+
93+
/// Resolves an open dispute. Only the designated resolver can call this.
94+
/// Settlement does not require beneficiary/depositor auth.
7195
pub fn resolve_dispute(
7296
e: &Env,
7397
resolver: Address,
7498
dispute_id: u32,
7599
release_to_beneficiary: bool,
76100
) {
77-
// 1. Authorization: Only the designated resolver can resolve the dispute
78101
resolver.require_auth();
79102

80-
// 2. Fetch the dispute record
81103
let mut dispute: DisputeRecord = e
82104
.storage()
83105
.persistent()
84106
.get(&DataKey::Dispute(dispute_id))
85107
.expect("Dispute not found");
86108

87-
// 3. Validation: Check if already resolved (Double-resolution panic)
88109
if dispute.status != DisputeStatus::Open {
89110
panic!("AlreadyResolved: This dispute has already been resolved");
90111
}
91112

92-
// 4. Validation: Verify the resolver matches the record
93113
if dispute.resolver != resolver {
94114
panic!("UnauthorizedResolver: Only the designated resolver can resolve this");
95115
}
96116

97-
// 5. Execute resolution by calling the core escrow logic
98-
if release_to_beneficiary {
99-
// Triggers the standard release logic from escrow.rs
100-
release_escrow(e, dispute.escrow_id);
101-
dispute.status = DisputeStatus::ResolvedForBeneficiary;
117+
settle_escrow_by_outcome(e, dispute.escrow_id, release_to_beneficiary);
118+
119+
dispute.status = if release_to_beneficiary {
120+
DisputeStatus::ResolvedForBeneficiary
102121
} else {
103-
// Triggers the standard refund logic from escrow.rs
104-
refund_escrow(e, dispute.escrow_id);
105-
dispute.status = DisputeStatus::ResolvedForDepositor;
106-
}
122+
DisputeStatus::ResolvedForDepositor
123+
};
107124

108-
// 6. Persist the updated dispute status
109125
e.storage().persistent().set(&DataKey::Dispute(dispute_id), &dispute);
110126

111-
// 7. Emit Observability Event
112127
e.events().publish(
113128
(Symbol::new(e, "dispute"), Symbol::new(e, "resolved"), dispute_id),
114-
release_to_beneficiary
129+
release_to_beneficiary,
115130
);
116131
}
117132

118-
/// Helper to read a dispute record
133+
/// Helper to read a dispute record.
119134
pub fn get_dispute(e: &Env, dispute_id: u32) -> DisputeRecord {
120135
e.storage()
121136
.persistent()
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use soroban_sdk::{testutils::Address as _, Address, Env};
2+
3+
use crate::balance::read_balance;
4+
use crate::contract::VeritixToken;
5+
use crate::dispute::{get_dispute, open_dispute, resolve_dispute, DisputeStatus};
6+
use crate::escrow::{create_escrow, get_escrow};
7+
8+
fn setup_env() -> Env {
9+
let e = Env::default();
10+
e.mock_all_auths();
11+
e
12+
}
13+
14+
fn setup_escrow(e: &Env, contract_id: &Address) -> (Address, Address, u32) {
15+
let depositor = Address::generate(e);
16+
let beneficiary = Address::generate(e);
17+
let amount = 1_000i128;
18+
let mut escrow_id = 0u32;
19+
e.as_contract(contract_id, || {
20+
crate::balance::receive_balance(e, depositor.clone(), amount);
21+
escrow_id = create_escrow(e, depositor.clone(), beneficiary.clone(), amount);
22+
});
23+
(depositor, beneficiary, escrow_id)
24+
}
25+
26+
#[test]
27+
fn test_open_dispute_stores_record() {
28+
let e = setup_env();
29+
let contract_id = e.register_contract(None, VeritixToken);
30+
let resolver = Address::generate(&e);
31+
let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id);
32+
33+
e.as_contract(&contract_id, || {
34+
let dispute_id = open_dispute(&e, depositor.clone(), escrow_id, resolver.clone());
35+
let record = get_dispute(&e, dispute_id);
36+
assert_eq!(record.escrow_id, escrow_id);
37+
assert_eq!(record.claimant, depositor);
38+
assert_eq!(record.resolver, resolver);
39+
assert_eq!(record.status, DisputeStatus::Open);
40+
});
41+
}
42+
43+
#[test]
44+
fn test_resolve_dispute_for_beneficiary() {
45+
let e = setup_env();
46+
let contract_id = e.register_contract(None, VeritixToken);
47+
let resolver = Address::generate(&e);
48+
let (_depositor, beneficiary, escrow_id) = setup_escrow(&e, &contract_id);
49+
50+
e.as_contract(&contract_id, || {
51+
let dispute_id =
52+
open_dispute(&e, beneficiary.clone(), escrow_id, resolver.clone());
53+
resolve_dispute(&e, resolver.clone(), dispute_id, true);
54+
55+
let record = get_dispute(&e, dispute_id);
56+
assert_eq!(record.status, DisputeStatus::ResolvedForBeneficiary);
57+
58+
let escrow = get_escrow(&e, escrow_id);
59+
assert!(escrow.released);
60+
61+
assert_eq!(read_balance(&e, beneficiary.clone()), 1_000);
62+
});
63+
}
64+
65+
#[test]
66+
fn test_resolve_dispute_for_depositor() {
67+
let e = setup_env();
68+
let contract_id = e.register_contract(None, VeritixToken);
69+
let resolver = Address::generate(&e);
70+
let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id);
71+
72+
e.as_contract(&contract_id, || {
73+
let dispute_id =
74+
open_dispute(&e, depositor.clone(), escrow_id, resolver.clone());
75+
resolve_dispute(&e, resolver.clone(), dispute_id, false);
76+
77+
let record = get_dispute(&e, dispute_id);
78+
assert_eq!(record.status, DisputeStatus::ResolvedForDepositor);
79+
80+
let escrow = get_escrow(&e, escrow_id);
81+
assert!(escrow.refunded);
82+
83+
assert_eq!(read_balance(&e, depositor.clone()), 1_000);
84+
});
85+
}
86+
87+
#[test]
88+
#[should_panic(expected = "UnauthorizedResolver")]
89+
fn test_resolve_dispute_wrong_resolver_panics() {
90+
let e = setup_env();
91+
let contract_id = e.register_contract(None, VeritixToken);
92+
let resolver = Address::generate(&e);
93+
let impostor = Address::generate(&e);
94+
let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id);
95+
96+
e.as_contract(&contract_id, || {
97+
let dispute_id = open_dispute(&e, depositor.clone(), escrow_id, resolver.clone());
98+
resolve_dispute(&e, impostor.clone(), dispute_id, true);
99+
});
100+
}
101+
102+
#[test]
103+
#[should_panic(expected = "AlreadyResolved")]
104+
fn test_double_resolve_panics() {
105+
let e = setup_env();
106+
let contract_id = e.register_contract(None, VeritixToken);
107+
let resolver = Address::generate(&e);
108+
let (depositor, _beneficiary, escrow_id) = setup_escrow(&e, &contract_id);
109+
110+
e.as_contract(&contract_id, || {
111+
let dispute_id = open_dispute(&e, depositor.clone(), escrow_id, resolver.clone());
112+
resolve_dispute(&e, resolver.clone(), dispute_id, true);
113+
resolve_dispute(&e, resolver.clone(), dispute_id, false);
114+
});
115+
}
116+
117+
#[test]
118+
#[should_panic(expected = "InvalidState")]
119+
fn test_open_dispute_on_settled_escrow_panics() {
120+
let e = setup_env();
121+
let contract_id = e.register_contract(None, VeritixToken);
122+
let resolver = Address::generate(&e);
123+
let (_depositor, beneficiary, escrow_id) = setup_escrow(&e, &contract_id);
124+
125+
e.as_contract(&contract_id, || {
126+
crate::escrow::release_escrow(&e, beneficiary.clone(), escrow_id);
127+
open_dispute(&e, beneficiary.clone(), escrow_id, resolver.clone());
128+
});
129+
}

veritixpay/contract/token/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
pub mod admin;
88
pub mod allowance;
99
pub mod balance;
10+
pub mod dispute;
1011
pub mod escrow;
1112
pub mod freeze;
1213
pub mod metadata;
14+
pub mod splitter;
1315
pub mod storage_types;
1416
pub mod validation;
1517

@@ -24,4 +26,10 @@ mod escrow_test;
2426
#[cfg(test)]
2527
mod admin_test;
2628

29+
#[cfg(test)]
30+
mod splitter_test;
31+
32+
#[cfg(test)]
33+
mod dispute_test;
34+
2735
pub use crate::contract::VeritixToken;

0 commit comments

Comments
 (0)