Skip to content

Commit 8bb22e8

Browse files
authored
Merge pull request #20 from Marvy247/feat/escrow-dispute-4
feat: add escrow dispute mechanism with optional arbitrator
2 parents b6b9d4e + 6e3c008 commit 8bb22e8

4 files changed

Lines changed: 228 additions & 10 deletions

File tree

contracts/sharpy/src/events.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,26 @@ pub fn invoice_refunded(env: &Env, id: u64) {
5454
pub fn payer_refunded(env: &Env, invoice_id: u64, payer: &Address, amount: i128) {
5555
env.events().publish((symbol_short!("pyr"),), PayerRefundedEvent { invoice_id, payer: payer.clone(), amount });
5656
}
57+
58+
#[contracttype]
59+
#[derive(Clone)]
60+
pub struct DisputeRaisedEvent {
61+
pub invoice_id: u64,
62+
pub creator: Address,
63+
}
64+
65+
#[contracttype]
66+
#[derive(Clone)]
67+
pub struct DisputeResolvedEvent {
68+
pub invoice_id: u64,
69+
pub resolver: Address,
70+
pub release: bool,
71+
}
72+
73+
pub fn dispute_raised(env: &Env, invoice_id: u64, creator: &Address) {
74+
env.events().publish((symbol_short!("dispute"),), DisputeRaisedEvent { invoice_id, creator: creator.clone() });
75+
}
76+
77+
pub fn dispute_resolved(env: &Env, invoice_id: u64, resolver: &Address, release: bool) {
78+
env.events().publish((symbol_short!("dsprslv"),), DisputeResolvedEvent { invoice_id, resolver: resolver.clone(), release });
79+
}

contracts/sharpy/src/lib.rs

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ mod test;
1010

1111
use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Map, Symbol, Vec};
1212
use types::{
13-
AuditEntry, CreateInvoiceParams, Invoice, InvoiceOptions, InvoicePayment,
13+
AuditEntry, CreateInvoiceParams, DisputeState, Invoice, InvoiceOptions, InvoicePayment,
1414
InvoiceStatus, Payment, SplitRule, SubscriptionParams,
1515
};
1616

@@ -70,6 +70,7 @@ fn build_invoice(
7070
escrow_enabled: bool,
7171
escrow_release_delay: u64,
7272
split_rules: Vec<SplitRule>,
73+
arbitrator: Option<Address>,
7374
) -> Invoice {
7475
let mut claimed: Vec<i128> = Vec::new(env);
7576
for _ in recipients.iter() {
@@ -92,6 +93,7 @@ fn build_invoice(
9293
escrow_release_delay,
9394
split_rules,
9495
auto_resolve_rules: Vec::new(env),
96+
arbitrator,
9597
}
9698
}
9799

@@ -140,6 +142,7 @@ impl SharpyContract {
140142
let invoice = build_invoice(
141143
&env, creator.clone(), recipients, amounts, tokens, deadline,
142144
options.escrow_enabled, options.escrow_release_delay.unwrap_or(0), options.split_rules,
145+
options.arbitrator,
143146
);
144147
save_invoice(&env, id, &invoice);
145148
events::invoice_created(&env, id, &creator);
@@ -157,7 +160,7 @@ impl SharpyContract {
157160
let id = bump_counter(&env);
158161
let invoice = build_invoice(
159162
&env, creator.clone(), params.recipients.clone(), params.amounts.clone(),
160-
params.tokens.clone(), params.deadline, false, 0, Vec::new(&env),
163+
params.tokens.clone(), params.deadline, false, 0, Vec::new(&env), None,
161164
);
162165
save_invoice(&env, id, &invoice);
163166
events::invoice_created(&env, id, &creator);
@@ -185,7 +188,7 @@ impl SharpyContract {
185188
let id = bump_counter(&env);
186189
let invoice = build_invoice(
187190
&env, creator.clone(), recipients.clone(), amounts.clone(),
188-
tokens.clone(), deadline, false, 0, Vec::new(&env),
191+
tokens.clone(), deadline, false, 0, Vec::new(&env), None,
189192
);
190193
save_invoice(&env, id, &invoice);
191194

@@ -226,7 +229,8 @@ impl SharpyContract {
226229
if invoice.funded >= total {
227230
if invoice.escrow_enabled {
228231
let release_at = env.ledger().timestamp() + invoice.escrow_release_delay;
229-
env.storage().persistent().set(&escrow_state_key(invoice_id), &release_at);
232+
let state = DisputeState { release_at, disputed: false, disputed_at: 0 };
233+
env.storage().persistent().set(&escrow_state_key(invoice_id), &state);
230234
save_invoice(&env, invoice_id, &invoice);
231235
} else {
232236
Self::_release(&env, invoice_id, &mut invoice, &payer);
@@ -262,8 +266,15 @@ impl SharpyContract {
262266
append_audit(&env, p.invoice_id, symbol_short!("pool_pay"), &payer);
263267
events::payment_received(&env, p.invoice_id, &payer, p.amount);
264268
let inv_total: i128 = inv.amounts.iter().sum();
265-
if inv.funded >= inv_total && !inv.escrow_enabled {
266-
Self::_release(&env, p.invoice_id, &mut inv, &payer);
269+
if inv.funded >= inv_total {
270+
if inv.escrow_enabled {
271+
let release_at = env.ledger().timestamp() + inv.escrow_release_delay;
272+
let state = DisputeState { release_at, disputed: false, disputed_at: 0 };
273+
env.storage().persistent().set(&escrow_state_key(p.invoice_id), &state);
274+
save_invoice(&env, p.invoice_id, &inv);
275+
} else {
276+
Self::_release(&env, p.invoice_id, &mut inv, &payer);
277+
}
267278
} else {
268279
save_invoice(&env, p.invoice_id, &inv);
269280
}
@@ -274,14 +285,71 @@ impl SharpyContract {
274285
require_not_paused(&env);
275286
let mut invoice = load_invoice(&env, invoice_id);
276287
assert!(invoice.escrow_enabled, "escrow not enabled on this invoice");
277-
let release_at: u64 = env.storage().persistent()
288+
let state: DisputeState = env.storage().persistent()
278289
.get(&escrow_state_key(invoice_id)).expect("escrow not found");
279-
assert!(env.ledger().timestamp() >= release_at, "escrow delay not yet met");
290+
assert!(!state.disputed, "release is disputed, use resolve_dispute");
291+
assert!(env.ledger().timestamp() >= state.release_at, "escrow delay not yet met");
280292
let caller = env.current_contract_address();
281293
Self::_release(&env, invoice_id, &mut invoice, &caller);
282294
env.storage().persistent().remove(&escrow_state_key(invoice_id));
283295
}
284296

297+
pub fn dispute_release(env: Env, invoice_id: u64) {
298+
require_not_paused(&env);
299+
let invoice = load_invoice(&env, invoice_id);
300+
assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending");
301+
assert!(invoice.escrow_enabled, "escrow not enabled on this invoice");
302+
invoice.creator.require_auth();
303+
304+
let state: DisputeState = env.storage().persistent()
305+
.get(&escrow_state_key(invoice_id)).expect("escrow not found");
306+
assert!(!state.disputed, "dispute already raised");
307+
assert!(env.ledger().timestamp() < state.release_at, "escrow delay has passed, cannot dispute");
308+
309+
let new_state = DisputeState { disputed: true, disputed_at: env.ledger().timestamp(), ..state };
310+
env.storage().persistent().set(&escrow_state_key(invoice_id), &new_state);
311+
append_audit(&env, invoice_id, symbol_short!("dispute"), &invoice.creator);
312+
events::dispute_raised(&env, invoice_id, &invoice.creator);
313+
}
314+
315+
pub fn resolve_dispute(env: Env, invoice_id: u64, release: bool) {
316+
require_not_paused(&env);
317+
let mut invoice = load_invoice(&env, invoice_id);
318+
assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending");
319+
320+
let state: DisputeState = env.storage().persistent()
321+
.get(&escrow_state_key(invoice_id)).expect("escrow not found");
322+
assert!(state.disputed, "no active dispute");
323+
324+
let resolver = invoice.arbitrator.clone().unwrap_or_else(|| invoice.creator.clone());
325+
resolver.require_auth();
326+
327+
env.storage().persistent().remove(&escrow_state_key(invoice_id));
328+
329+
if release {
330+
Self::_release(&env, invoice_id, &mut invoice, &resolver);
331+
} else {
332+
let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token"));
333+
let mut totals: Map<Address, i128> = Map::new(&env);
334+
for payment in invoice.payments.iter() {
335+
let prev = totals.get(payment.payer.clone()).unwrap_or(0);
336+
totals.set(payment.payer.clone(), prev + payment.amount);
337+
}
338+
for (payer, amount) in totals.iter() {
339+
token_client.transfer(&env.current_contract_address(), &payer, &amount);
340+
events::payer_refunded(&env, invoice_id, &payer, amount);
341+
}
342+
343+
invoice.status = InvoiceStatus::Refunded;
344+
invoice.completion_time = Some(env.ledger().timestamp());
345+
save_invoice(&env, invoice_id, &invoice);
346+
append_audit(&env, invoice_id, symbol_short!("resolve"), &resolver);
347+
events::invoice_refunded(&env, invoice_id);
348+
}
349+
350+
events::dispute_resolved(&env, invoice_id, &resolver, release);
351+
}
352+
285353
fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) {
286354
assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending");
287355

@@ -336,7 +404,7 @@ impl SharpyContract {
336404

337405
let next_invoice = build_invoice(
338406
env, params.creator.clone(), params.recipients.clone(),
339-
params.amounts.clone(), params.tokens.clone(), next_deadline, false, 0, Vec::new(env),
407+
params.amounts.clone(), params.tokens.clone(), next_deadline, false, 0, Vec::new(env), None,
340408
);
341409
save_invoice(env, next_id, &next_invoice);
342410

@@ -422,4 +490,8 @@ impl SharpyContract {
422490
pub fn get_next_recurring(env: Env, invoice_id: u64) -> Option<u64> {
423491
env.storage().persistent().get(&next_invoice_key(invoice_id))
424492
}
493+
494+
pub fn get_escrow_state(env: Env, invoice_id: u64) -> Option<DisputeState> {
495+
env.storage().persistent().get(&escrow_state_key(invoice_id))
496+
}
425497
}

contracts/sharpy/src/test.rs

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#[cfg(test)]
22
mod tests {
3-
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};
3+
use soroban_sdk::{testutils::Address as _, token, Address, Env, Vec};
44
use soroban_sdk::testutils::Ledger as _;
55
use crate::{
66
types::{CreateInvoiceParams, InvoiceOptions, InvoicePayment, InvoiceStatus, SplitRule},
@@ -24,6 +24,7 @@ mod tests {
2424
escrow_release_delay: None,
2525
split_rules: Vec::new(env),
2626
auto_resolve_rules: Vec::new(env),
27+
arbitrator: None,
2728
}
2829
}
2930

@@ -268,6 +269,7 @@ mod tests {
268269
escrow_release_delay: Some(3600u64),
269270
split_rules: Vec::new(&env),
270271
auto_resolve_rules: Vec::new(&env),
272+
arbitrator: None,
271273
};
272274

273275
let id = client.create_invoice(&creator, &Vec::from_array(&env, [recipient]),
@@ -390,4 +392,115 @@ mod tests {
390392
&default_options(&env),
391393
);
392394
}
395+
396+
// -----------------------------------------------------------------------
397+
// Escrow dispute tests
398+
// -----------------------------------------------------------------------
399+
400+
fn create_escrow_invoice(
401+
env: &Env,
402+
client: &SharpyContractClient<'static>,
403+
creator: &Address,
404+
payer: &Address,
405+
recipient: &Address,
406+
arbitrator: Option<Address>,
407+
) -> (u64, Address) {
408+
let admin = Address::generate(env);
409+
let token = env.register_stellar_asset_contract(admin.clone());
410+
let sac = soroban_sdk::token::StellarAssetClient::new(env, &token);
411+
sac.mint(payer, &1000i128);
412+
413+
let deadline = env.ledger().timestamp() + 86400;
414+
let options = InvoiceOptions {
415+
escrow_enabled: true,
416+
escrow_release_delay: Some(3600u64),
417+
split_rules: Vec::new(env),
418+
auto_resolve_rules: Vec::new(env),
419+
arbitrator,
420+
};
421+
422+
let id = client.create_invoice(creator, &Vec::from_array(env, [recipient.clone()]),
423+
&Vec::from_array(env, [500i128]), &Vec::from_array(env, [token.clone()]),
424+
&deadline, &options);
425+
(id, token)
426+
}
427+
428+
#[test]
429+
fn test_dispute_release_and_resolve_release() {
430+
let (env, client) = setup();
431+
let creator = Address::generate(&env);
432+
let payer = Address::generate(&env);
433+
let recipient = Address::generate(&env);
434+
435+
let (id, _) = create_escrow_invoice(&env, &client, &creator, &payer, &recipient, None);
436+
437+
client.pay(&payer, &id, &500i128);
438+
439+
let state = client.get_escrow_state(&id).unwrap();
440+
assert!(!state.disputed);
441+
442+
client.dispute_release(&id);
443+
let state = client.get_escrow_state(&id).unwrap();
444+
assert!(state.disputed);
445+
446+
client.resolve_dispute(&id, &true);
447+
let invoice = client.get_invoice(&id);
448+
assert_eq!(invoice.status, InvoiceStatus::Released);
449+
}
450+
451+
#[test]
452+
fn test_dispute_release_and_resolve_refund() {
453+
let (env, client) = setup();
454+
let creator = Address::generate(&env);
455+
let payer = Address::generate(&env);
456+
let recipient = Address::generate(&env);
457+
458+
let (id, _) = create_escrow_invoice(&env, &client, &creator, &payer, &recipient, None);
459+
460+
client.pay(&payer, &id, &500i128);
461+
client.dispute_release(&id);
462+
463+
client.resolve_dispute(&id, &false);
464+
let invoice = client.get_invoice(&id);
465+
assert_eq!(invoice.status, InvoiceStatus::Refunded);
466+
}
467+
468+
#[test]
469+
fn test_arbitrator_resolves_dispute() {
470+
let (env, client) = setup();
471+
let creator = Address::generate(&env);
472+
let arbitrator = Address::generate(&env);
473+
let payer = Address::generate(&env);
474+
let recipient = Address::generate(&env);
475+
476+
let (id, _) = create_escrow_invoice(&env, &client, &creator, &payer, &recipient, Some(arbitrator.clone()));
477+
478+
let invoice = client.get_invoice(&id);
479+
assert_eq!(invoice.arbitrator, Some(arbitrator.clone()));
480+
481+
client.pay(&payer, &id, &500i128);
482+
client.dispute_release(&id);
483+
484+
client.resolve_dispute(&id, &true);
485+
let invoice = client.get_invoice(&id);
486+
assert_eq!(invoice.status, InvoiceStatus::Released);
487+
}
488+
489+
#[test]
490+
#[should_panic]
491+
fn test_release_escrow_rejects_disputed() {
492+
let (env, client) = setup();
493+
let creator = Address::generate(&env);
494+
let payer = Address::generate(&env);
495+
let recipient = Address::generate(&env);
496+
497+
let (id, _) = create_escrow_invoice(&env, &client, &creator, &payer, &recipient, None);
498+
499+
client.pay(&payer, &id, &500i128);
500+
client.dispute_release(&id);
501+
502+
env.ledger().set_timestamp(env.ledger().timestamp() + 7200);
503+
504+
client.release_escrow(&id);
505+
}
393506
}

contracts/sharpy/src/types.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,22 @@ pub struct InvoicePayment {
7373
pub amount: i128,
7474
}
7575

76+
#[contracttype]
77+
#[derive(Clone, Debug)]
78+
pub struct DisputeState {
79+
pub release_at: u64,
80+
pub disputed: bool,
81+
pub disputed_at: u64,
82+
}
83+
7684
#[contracttype]
7785
#[derive(Clone, Debug)]
7886
pub struct InvoiceOptions {
7987
pub escrow_enabled: bool,
8088
pub escrow_release_delay: Option<u64>,
8189
pub split_rules: Vec<SplitRule>,
8290
pub auto_resolve_rules: Vec<ResolveRule>,
91+
pub arbitrator: Option<Address>,
8392
}
8493

8594
#[contracttype]
@@ -110,4 +119,5 @@ pub struct Invoice {
110119
pub escrow_release_delay: u64,
111120
pub split_rules: Vec<SplitRule>,
112121
pub auto_resolve_rules: Vec<ResolveRule>,
122+
pub arbitrator: Option<Address>,
113123
}

0 commit comments

Comments
 (0)