diff --git a/apps/contracts/src/lib.rs b/apps/contracts/src/lib.rs index 651dc97..36a5163 100644 --- a/apps/contracts/src/lib.rs +++ b/apps/contracts/src/lib.rs @@ -9,6 +9,7 @@ pub enum BountyStatus { Funded, InProgress, UnderReview, + Disputed, Completed, Cancelled, } @@ -18,12 +19,13 @@ pub struct EscrowContract; #[contractimpl] impl EscrowContract { - /// Initialize a bounty. Sets owner, amount, token address, and status to Created. - pub fn initialize(env: Env, owner: Address, amount: i128, token_address: Address) { + /// Initialize a bounty. Sets owner, amount, token address, arbitrator, and status to Created. + pub fn initialize(env: Env, owner: Address, amount: i128, token_address: Address, arbitrator: Address) { owner.require_auth(); env.storage().instance().set(&symbol_short!("OWNER"), &owner); env.storage().instance().set(&symbol_short!("AMOUNT"), &amount); env.storage().instance().set(&symbol_short!("TOKEN"), &token_address); + env.storage().instance().set(&symbol_short!("ARBITRATR"), &arbitrator); env.storage() .instance() .set(&symbol_short!("STATUS"), &BountyStatus::Created); @@ -110,6 +112,52 @@ impl EscrowContract { .set(&symbol_short!("STATUS"), &BountyStatus::Cancelled); } + /// Raise a dispute. Callable by owner or contributor when status is UnderReview. + /// Transitions UnderReview → Disputed. + pub fn dispute(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_status(&env, BountyStatus::UnderReview, "dispute requires UnderReview status"); + + let owner: Address = env.storage().instance().get(&symbol_short!("OWNER")).unwrap(); + let contributor: Address = env.storage().instance().get(&symbol_short!("CONTRIB")).unwrap(); + assert!( + caller == owner || caller == contributor, + "only owner or contributor can dispute" + ); + + env.storage() + .instance() + .set(&symbol_short!("STATUS"), &BountyStatus::Disputed); + + env.events().publish((symbol_short!("dispute"), caller), ()); + } + + /// Arbitrator resolves the dispute by choosing a winner. + /// Pays out to `winner` and transitions Disputed → Completed. + pub fn resolve(env: Env, arbitrator: Address, winner: Address) { + arbitrator.require_auth(); + Self::assert_arbitrator(&env, &arbitrator); + Self::assert_status(&env, BountyStatus::Disputed, "resolve requires Disputed status"); + + let owner: Address = env.storage().instance().get(&symbol_short!("OWNER")).unwrap(); + let contributor: Address = env.storage().instance().get(&symbol_short!("CONTRIB")).unwrap(); + assert!( + winner == owner || winner == contributor, + "winner must be owner or contributor" + ); + + let amount: i128 = env.storage().instance().get(&symbol_short!("AMOUNT")).unwrap(); + let token_address: Address = env.storage().instance().get(&symbol_short!("TOKEN")).unwrap(); + let token = token::Client::new(&env, &token_address); + token.transfer(&env.current_contract_address(), &winner, &amount); + + env.storage() + .instance() + .set(&symbol_short!("STATUS"), &BountyStatus::Completed); + + env.events().publish((symbol_short!("resolve"), winner), ()); + } + pub fn get_owner(env: Env) -> Address { env.storage().instance().get(&symbol_short!("OWNER")).unwrap() } @@ -130,6 +178,10 @@ impl EscrowContract { env.storage().instance().get(&symbol_short!("TOKEN")).unwrap() } + pub fn get_arbitrator(env: Env) -> Address { + env.storage().instance().get(&symbol_short!("ARBITRATR")).unwrap() + } + // --- helpers --- fn assert_owner(env: &Env, caller: &Address) { @@ -142,6 +194,11 @@ impl EscrowContract { assert!(caller == &contributor, "only contributor can call this"); } + fn assert_arbitrator(env: &Env, caller: &Address) { + let arbitrator: Address = env.storage().instance().get(&symbol_short!("ARBITRATR")).unwrap(); + assert!(caller == &arbitrator, "only arbitrator can call this"); + } + fn assert_status(env: &Env, expected: BountyStatus, msg: &'static str) { let status: BountyStatus = env.storage().instance().get(&symbol_short!("STATUS")).unwrap(); assert!(status == expected, "{}", msg); @@ -157,7 +214,15 @@ mod tests { Address, Env, }; - fn setup() -> (Env, EscrowContractClient<'static>, Address, Address, Address, i128) { + fn setup() -> ( + Env, + EscrowContractClient<'static>, + Address, + Address, + Address, + Address, + i128, + ) { let env = Env::default(); env.mock_all_auths(); @@ -170,6 +235,7 @@ mod tests { let client = EscrowContractClient::new(&env, &contract_id); let owner = Address::generate(&env); + let arbitrator = Address::generate(&env); let amount: i128 = 1000; token_admin_client.mint(&owner, &amount); @@ -177,23 +243,52 @@ mod tests { let token_client = TokenClient::new(&env, &token_address); token_client.approve(&owner, &contract_id, &amount, &200); - (env, client, owner, token_address, contract_id, amount) + (env, client, owner, token_address, contract_id, arbitrator, amount) + } + + fn setup_under_review() -> ( + Env, + EscrowContractClient<'static>, + Address, + Address, + Address, + Address, + Address, + i128, + ) { + let (env, client, owner, token_address, contract_id, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); + client.fund(&owner); + let contributor = Address::generate(&env); + client.start_work(&contributor); + client.submit(&contributor); + ( + env, + client, + owner, + token_address, + contract_id, + arbitrator, + contributor, + amount, + ) } #[test] fn test_initialize_stores_fields() { - let (_, client, owner, token_address, _, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (_, client, owner, token_address, _, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); assert_eq!(client.get_owner(), owner); assert_eq!(client.get_amount(), amount); assert_eq!(client.get_token(), token_address); + assert_eq!(client.get_arbitrator(), arbitrator); assert_eq!(client.get_status(), BountyStatus::Created); } #[test] fn test_fund_transfers_tokens_and_transitions() { - let (env, client, owner, token_address, contract_id, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (env, client, owner, token_address, contract_id, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); let token = TokenClient::new(&env, &token_address); assert_eq!(token.balance(&owner), amount); @@ -207,13 +302,7 @@ mod tests { #[test] fn test_approve_pays_contributor() { - let (env, client, owner, token_address, contract_id, amount) = setup(); - client.initialize(&owner, &amount, &token_address); - client.fund(&owner); - - let contributor = Address::generate(&env); - client.start_work(&contributor); - client.submit(&contributor); + let (env, client, owner, token_address, contract_id, _arbitrator, contributor, amount) = setup_under_review(); let token = TokenClient::new(&env, &token_address); assert_eq!(token.balance(&contract_id), amount); @@ -227,8 +316,8 @@ mod tests { #[test] fn test_cancel_from_funded_refunds_owner() { - let (env, client, owner, token_address, contract_id, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (env, client, owner, token_address, contract_id, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); client.fund(&owner); let token = TokenClient::new(&env, &token_address); @@ -244,8 +333,8 @@ mod tests { #[test] fn test_cancel_from_created_no_transfer() { - let (env, client, owner, token_address, _, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (env, client, owner, token_address, _, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); let token = TokenClient::new(&env, &token_address); let owner_balance_before = token.balance(&owner); @@ -258,8 +347,8 @@ mod tests { #[test] fn test_start_work_transitions_to_in_progress() { - let (env, client, owner, token_address, _, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (env, client, owner, token_address, _, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); client.fund(&owner); let contributor = Address::generate(&env); client.start_work(&contributor); @@ -269,8 +358,8 @@ mod tests { #[test] fn test_submit_transitions_to_under_review() { - let (env, client, owner, token_address, _, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (env, client, owner, token_address, _, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); client.fund(&owner); let contributor = Address::generate(&env); client.start_work(&contributor); @@ -279,14 +368,89 @@ mod tests { } #[test] - #[should_panic(expected = "only owner can call this")] - fn test_approve_unauthorized_panics() { - let (env, client, owner, token_address, _, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + fn test_dispute_by_owner_transitions_to_disputed() { + let (_, client, owner, _, _, _, _, _) = setup_under_review(); + client.dispute(&owner); + assert_eq!(client.get_status(), BountyStatus::Disputed); + } + + #[test] + fn test_dispute_by_contributor_transitions_to_disputed() { + let (_, client, _, _, _, _, contributor, _) = setup_under_review(); + client.dispute(&contributor); + assert_eq!(client.get_status(), BountyStatus::Disputed); + } + + #[test] + fn test_resolve_pays_contributor_and_completes() { + let (env, client, _, token_address, contract_id, arbitrator, contributor, amount) = setup_under_review(); + client.dispute(&contributor); + + let token = TokenClient::new(&env, &token_address); + assert_eq!(token.balance(&contract_id), amount); + + client.resolve(&arbitrator, &contributor); + + assert_eq!(client.get_status(), BountyStatus::Completed); + assert_eq!(token.balance(&contributor), amount); + assert_eq!(token.balance(&contract_id), 0); + } + + #[test] + fn test_resolve_pays_owner_and_completes() { + let (env, client, owner, token_address, contract_id, arbitrator, contributor, amount) = setup_under_review(); + client.dispute(&contributor); + + let token = TokenClient::new(&env, &token_address); + client.resolve(&arbitrator, &owner); + + assert_eq!(client.get_status(), BountyStatus::Completed); + assert_eq!(token.balance(&owner), amount); + assert_eq!(token.balance(&contract_id), 0); + } + + #[test] + #[should_panic(expected = "only owner or contributor can dispute")] + fn test_dispute_by_stranger_panics() { + let (env, client, _, _, _, _, _, _) = setup_under_review(); + let stranger = Address::generate(&env); + client.dispute(&stranger); + } + + #[test] + #[should_panic(expected = "dispute requires UnderReview status")] + fn test_dispute_wrong_status_panics() { + let (env, client, owner, token_address, _, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); client.fund(&owner); let contributor = Address::generate(&env); client.start_work(&contributor); - client.submit(&contributor); + // Still InProgress, not UnderReview + client.dispute(&owner); + } + + #[test] + #[should_panic(expected = "only arbitrator can call this")] + fn test_resolve_by_non_arbitrator_panics() { + let (env, client, _, _, _, _, contributor, _) = setup_under_review(); + client.dispute(&contributor); + let stranger = Address::generate(&env); + client.resolve(&stranger, &contributor); + } + + #[test] + #[should_panic(expected = "winner must be owner or contributor")] + fn test_resolve_with_invalid_winner_panics() { + let (env, client, _, _, _, arbitrator, contributor, _) = setup_under_review(); + client.dispute(&contributor); + let stranger = Address::generate(&env); + client.resolve(&arbitrator, &stranger); + } + + #[test] + #[should_panic(expected = "only owner can call this")] + fn test_approve_unauthorized_panics() { + let (env, client, _, _, _, _, _, _) = setup_under_review(); let not_owner = Address::generate(&env); client.approve(¬_owner); } @@ -294,8 +458,8 @@ mod tests { #[test] #[should_panic(expected = "cancel only allowed from Created or Funded")] fn test_cancel_from_in_progress_panics() { - let (env, client, owner, token_address, _, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (env, client, owner, token_address, _, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); client.fund(&owner); let contributor = Address::generate(&env); client.start_work(&contributor); @@ -305,8 +469,8 @@ mod tests { #[test] #[should_panic(expected = "fund requires Created status")] fn test_double_fund_panics() { - let (_, client, owner, token_address, _, amount) = setup(); - client.initialize(&owner, &amount, &token_address); + let (_, client, owner, token_address, _, arbitrator, amount) = setup(); + client.initialize(&owner, &amount, &token_address, &arbitrator); client.fund(&owner); client.fund(&owner); } diff --git a/package-lock.json b/package-lock.json index 6a69ad5..d72c19d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,10 +51,13 @@ "@types/node": "^20.14.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", "autoprefixer": "^10.4.19", "eslint": "^8.57.0", "eslint-config-next": "14.2.35", "postcss": "^8.4.38", + "prettier": "^3.3.2", "tailwindcss": "^3.4.4", "typescript": "^5.5.4" } @@ -8357,6 +8360,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",