|
| 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