Skip to content

Commit 4607ef6

Browse files
authored
Merge pull request #315 from Praizfotos/feat/escrow-milestone-dispute
Feat/escrow milestone dispute
2 parents 876eace + 8ed40c2 commit 4607ef6

4 files changed

Lines changed: 602 additions & 0 deletions

File tree

soroban-contract/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ members = [
1818
"contracts/dao_governance",
1919
"contracts/merkle_distributor",
2020
"contracts/payment_splitter",
21+
"contracts/escrow",
2122
"contracts/reentrancy_guard",
2223
"contracts/multi_admin",
2324
"contracts/permit_wallet",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "escrow"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[lib]
8+
crate-type = ["cdylib"]
9+
10+
[dependencies]
11+
soroban-sdk = { workspace = true }
12+
upgradeable = { path = "../upgradeable" }
13+
14+
[dev-dependencies]
15+
soroban-sdk = { workspace = true, features = ["testutils"] }
Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
//! Escrow contract with milestone approvals and dispute resolution hooks.
2+
//!
3+
//! # Roles
4+
//! - **depositor** – funds the escrow and approves milestones.
5+
//! - **recipient** – receives funds as milestones are approved.
6+
//! - **arbiter** – resolves disputes; set at creation time.
7+
//!
8+
//! # Lifecycle
9+
//! ```text
10+
//! create_escrow → [fund] → approve_milestone (repeats) → close
11+
//! ↘ open_dispute → resolve_dispute
12+
//! ```
13+
14+
#![no_std]
15+
16+
use soroban_sdk::{
17+
contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, Vec,
18+
};
19+
20+
use upgradeable as upg;
21+
22+
// ── Errors ────────────────────────────────────────────────────────────────────
23+
24+
#[contracterror]
25+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
26+
#[repr(u32)]
27+
pub enum Error {
28+
AlreadyInitialized = 1,
29+
EscrowNotFound = 2,
30+
Unauthorized = 3,
31+
InvalidMilestone = 4,
32+
MilestoneAlreadyApproved = 5,
33+
EscrowClosed = 6,
34+
DisputeAlreadyOpen = 7,
35+
NoOpenDispute = 8,
36+
InsufficientFunds = 9,
37+
InvalidAmounts = 10,
38+
}
39+
40+
// ── Types ─────────────────────────────────────────────────────────────────────
41+
42+
#[contracttype]
43+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
44+
#[repr(u32)]
45+
pub enum EscrowStatus {
46+
Active = 0,
47+
Disputed = 1,
48+
Closed = 2,
49+
}
50+
51+
/// A single milestone: description hash (off-chain) + amount to release on approval.
52+
#[contracttype]
53+
#[derive(Clone, Debug, Eq, PartialEq)]
54+
pub struct Milestone {
55+
pub amount: i128,
56+
pub approved: bool,
57+
}
58+
59+
#[contracttype]
60+
#[derive(Clone, Debug, Eq, PartialEq)]
61+
pub struct Escrow {
62+
pub id: u32,
63+
pub depositor: Address,
64+
pub recipient: Address,
65+
pub arbiter: Address,
66+
pub token: Address,
67+
pub total_amount: i128,
68+
pub released: i128,
69+
pub status: EscrowStatus,
70+
pub milestones: Vec<Milestone>,
71+
}
72+
73+
#[contracttype]
74+
pub enum DataKey {
75+
Counter,
76+
Escrow(u32),
77+
}
78+
79+
// ── Contract ──────────────────────────────────────────────────────────────────
80+
81+
#[contract]
82+
pub struct EscrowContract;
83+
84+
#[contractimpl]
85+
impl EscrowContract {
86+
// ── Admin ─────────────────────────────────────────────────────────────────
87+
88+
pub fn initialize(env: Env, admin: Address) -> Result<(), Error> {
89+
if env.storage().instance().has(&DataKey::Counter) {
90+
return Err(Error::AlreadyInitialized);
91+
}
92+
admin.require_auth();
93+
upg::set_admin(&env, &admin);
94+
upg::init_version(&env);
95+
env.storage().instance().set(&DataKey::Counter, &0u32);
96+
upg::extend_instance_ttl(&env);
97+
Ok(())
98+
}
99+
100+
// ── Create ────────────────────────────────────────────────────────────────
101+
102+
/// Create an escrow and immediately transfer `total_amount` tokens from
103+
/// `depositor` into the contract. `milestone_amounts` must sum to
104+
/// `total_amount`.
105+
pub fn create_escrow(
106+
env: Env,
107+
depositor: Address,
108+
recipient: Address,
109+
arbiter: Address,
110+
token: Address,
111+
milestone_amounts: Vec<i128>,
112+
) -> Result<u32, Error> {
113+
upg::require_not_paused(&env);
114+
depositor.require_auth();
115+
116+
if milestone_amounts.is_empty() {
117+
return Err(Error::InvalidMilestone);
118+
}
119+
120+
let mut total_amount: i128 = 0;
121+
for amount in milestone_amounts.iter() {
122+
total_amount += amount;
123+
}
124+
if total_amount <= 0 {
125+
return Err(Error::InvalidAmounts);
126+
}
127+
128+
// Pull funds from depositor.
129+
token::Client::new(&env, &token).transfer(
130+
&depositor,
131+
&env.current_contract_address(),
132+
&total_amount,
133+
);
134+
135+
let mut milestones: Vec<Milestone> = Vec::new(&env);
136+
for amount in milestone_amounts.iter() {
137+
milestones.push_back(Milestone { amount, approved: false });
138+
}
139+
140+
let id = Self::next_id(&env);
141+
let escrow = Escrow {
142+
id,
143+
depositor: depositor.clone(),
144+
recipient: recipient.clone(),
145+
arbiter: arbiter.clone(),
146+
token,
147+
total_amount,
148+
released: 0,
149+
status: EscrowStatus::Active,
150+
milestones,
151+
};
152+
153+
env.storage().persistent().set(&DataKey::Escrow(id), &escrow);
154+
Self::extend_ttl(&env, id);
155+
upg::extend_instance_ttl(&env);
156+
157+
env.events().publish(
158+
(Symbol::new(&env, "EscrowCreated"),),
159+
(id, depositor, recipient, arbiter, total_amount),
160+
);
161+
162+
Ok(id)
163+
}
164+
165+
// ── Milestone approval ────────────────────────────────────────────────────
166+
167+
/// Depositor approves a milestone; funds are released to the recipient.
168+
pub fn approve_milestone(
169+
env: Env,
170+
escrow_id: u32,
171+
milestone_index: u32,
172+
) -> Result<i128, Error> {
173+
upg::require_not_paused(&env);
174+
175+
let mut escrow = Self::load(&env, escrow_id)?;
176+
Self::require_active(&escrow)?;
177+
escrow.depositor.require_auth();
178+
179+
let idx = milestone_index as usize;
180+
if idx >= escrow.milestones.len() as usize {
181+
return Err(Error::InvalidMilestone);
182+
}
183+
184+
let milestone = escrow.milestones.get(milestone_index).unwrap();
185+
if milestone.approved {
186+
return Err(Error::MilestoneAlreadyApproved);
187+
}
188+
189+
// Rebuild milestones vec with this entry marked approved.
190+
let mut updated: Vec<Milestone> = Vec::new(&env);
191+
for i in 0..escrow.milestones.len() {
192+
let m = escrow.milestones.get(i).unwrap();
193+
if i == milestone_index {
194+
updated.push_back(Milestone { amount: m.amount, approved: true });
195+
} else {
196+
updated.push_back(m);
197+
}
198+
}
199+
escrow.milestones = updated;
200+
escrow.released += milestone.amount;
201+
202+
token::Client::new(&env, &escrow.token).transfer(
203+
&env.current_contract_address(),
204+
&escrow.recipient,
205+
&milestone.amount,
206+
);
207+
208+
// Auto-close when all milestones are approved.
209+
if escrow.released >= escrow.total_amount {
210+
escrow.status = EscrowStatus::Closed;
211+
env.events().publish(
212+
(Symbol::new(&env, "EscrowClosed"),),
213+
(escrow_id,),
214+
);
215+
}
216+
217+
env.storage().persistent().set(&DataKey::Escrow(escrow_id), &escrow);
218+
Self::extend_ttl(&env, escrow_id);
219+
220+
env.events().publish(
221+
(Symbol::new(&env, "MilestoneApproved"),),
222+
(escrow_id, milestone_index, milestone.amount),
223+
);
224+
225+
Ok(milestone.amount)
226+
}
227+
228+
// ── Dispute hooks ─────────────────────────────────────────────────────────
229+
230+
/// Either party opens a dispute; only the arbiter can then resolve it.
231+
pub fn open_dispute(env: Env, escrow_id: u32, caller: Address) -> Result<(), Error> {
232+
upg::require_not_paused(&env);
233+
234+
let mut escrow = Self::load(&env, escrow_id)?;
235+
Self::require_active(&escrow)?;
236+
caller.require_auth();
237+
238+
// Only depositor or recipient may open a dispute.
239+
if caller != escrow.depositor && caller != escrow.recipient {
240+
return Err(Error::Unauthorized);
241+
}
242+
243+
escrow.status = EscrowStatus::Disputed;
244+
env.storage().persistent().set(&DataKey::Escrow(escrow_id), &escrow);
245+
Self::extend_ttl(&env, escrow_id);
246+
247+
env.events().publish(
248+
(Symbol::new(&env, "DisputeOpened"),),
249+
(escrow_id, caller),
250+
);
251+
252+
Ok(())
253+
}
254+
255+
/// Arbiter resolves a dispute by splitting the *remaining* (unreleased)
256+
/// balance between depositor and recipient.
257+
/// `recipient_share` is the fraction going to the recipient (0..=remaining).
258+
pub fn resolve_dispute(
259+
env: Env,
260+
escrow_id: u32,
261+
recipient_share: i128,
262+
) -> Result<(), Error> {
263+
upg::require_not_paused(&env);
264+
265+
let mut escrow = Self::load(&env, escrow_id)?;
266+
if !matches!(escrow.status, EscrowStatus::Disputed) {
267+
return Err(Error::NoOpenDispute);
268+
}
269+
escrow.arbiter.require_auth();
270+
271+
let remaining = escrow.total_amount - escrow.released;
272+
if recipient_share < 0 || recipient_share > remaining {
273+
return Err(Error::InvalidAmounts);
274+
}
275+
let depositor_share = remaining - recipient_share;
276+
277+
let token_client = token::Client::new(&env, &escrow.token);
278+
279+
if recipient_share > 0 {
280+
token_client.transfer(
281+
&env.current_contract_address(),
282+
&escrow.recipient,
283+
&recipient_share,
284+
);
285+
}
286+
if depositor_share > 0 {
287+
token_client.transfer(
288+
&env.current_contract_address(),
289+
&escrow.depositor,
290+
&depositor_share,
291+
);
292+
}
293+
294+
escrow.released = escrow.total_amount;
295+
escrow.status = EscrowStatus::Closed;
296+
297+
env.storage().persistent().set(&DataKey::Escrow(escrow_id), &escrow);
298+
Self::extend_ttl(&env, escrow_id);
299+
300+
env.events().publish(
301+
(Symbol::new(&env, "DisputeResolved"),),
302+
(escrow_id, recipient_share, depositor_share),
303+
);
304+
305+
Ok(())
306+
}
307+
308+
// ── Queries ───────────────────────────────────────────────────────────────
309+
310+
pub fn get_escrow(env: Env, escrow_id: u32) -> Option<Escrow> {
311+
env.storage().persistent().get(&DataKey::Escrow(escrow_id))
312+
}
313+
314+
// ── Upgrade helpers (delegated to upgradeable crate) ──────────────────────
315+
316+
pub fn schedule_upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
317+
upg::schedule_upgrade(&env, new_wasm_hash);
318+
}
319+
320+
pub fn cancel_upgrade(env: Env) {
321+
upg::cancel_upgrade(&env);
322+
}
323+
324+
pub fn commit_upgrade(env: Env) {
325+
upg::commit_upgrade(&env);
326+
}
327+
328+
pub fn pause(env: Env) {
329+
upg::pause(&env);
330+
}
331+
332+
pub fn unpause(env: Env) {
333+
upg::unpause(&env);
334+
}
335+
336+
pub fn transfer_admin(env: Env, new_admin: Address) {
337+
upg::transfer_admin(&env, new_admin);
338+
}
339+
340+
// ── Helpers ───────────────────────────────────────────────────────────────
341+
342+
fn load(env: &Env, id: u32) -> Result<Escrow, Error> {
343+
env.storage()
344+
.persistent()
345+
.get(&DataKey::Escrow(id))
346+
.ok_or(Error::EscrowNotFound)
347+
}
348+
349+
fn require_active(escrow: &Escrow) -> Result<(), Error> {
350+
match escrow.status {
351+
EscrowStatus::Active => Ok(()),
352+
EscrowStatus::Disputed => Err(Error::DisputeAlreadyOpen),
353+
EscrowStatus::Closed => Err(Error::EscrowClosed),
354+
}
355+
}
356+
357+
fn next_id(env: &Env) -> u32 {
358+
let id: u32 = env
359+
.storage()
360+
.instance()
361+
.get(&DataKey::Counter)
362+
.unwrap_or(0);
363+
env.storage().instance().set(&DataKey::Counter, &(id + 1));
364+
id
365+
}
366+
367+
fn extend_ttl(env: &Env, id: u32) {
368+
upg::extend_persistent_ttl(env, &DataKey::Escrow(id));
369+
}
370+
}
371+
372+
#[cfg(test)]
373+
mod test;

0 commit comments

Comments
 (0)