From 3f2a22d01dad8884b7dfb0874dba14b8f75d5dc1 Mon Sep 17 00:00:00 2001 From: Florencia Date: Tue, 28 Apr 2026 07:54:56 -0600 Subject: [PATCH] feat(escrow): add material/labor tranche split with release_materials() --- contracts/escrow/src/lib.rs | 180 +++++++++++++++++++++++++++++---- contracts/escrow/src/test.rs | 191 ++++++++++++++++++++++++++++++++++- 2 files changed, 347 insertions(+), 24 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index b223b3a..057d075 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -14,6 +14,9 @@ pub struct Escrow { pub client: Address, pub artisan: Address, pub amount: i128, + pub material_amount: i128, + pub labor_amount: i128, + pub materials_released: bool, pub status: Status, pub deadline: u64, } @@ -69,6 +72,15 @@ pub struct DisputeResolvedEvent { pub timestamp: u64, } +// Event emitted when the material tranche is released to the artisan +#[contracttype] +pub struct MaterialsReleasedEvent { + pub id: u64, + pub artisan: Address, + pub amount: i128, + pub timestamp: u64, +} + #[contract] pub struct EscrowContract; @@ -80,7 +92,8 @@ impl EscrowContract { env: Env, client: Address, artisan: Address, - amount: i128, + material_amount: i128, + labor_amount: i128, deadline: u64, ) -> u64 { // Validation: client cannot be the same as artisan @@ -88,11 +101,16 @@ impl EscrowContract { panic!("Client and artisan cannot be the same address"); } - // Validation: amount must be positive - if amount <= 0 { + // Validation: both amounts must be positive + if material_amount <= 0 { + panic!("Amount must be greater than zero"); + } + if labor_amount <= 0 { panic!("Amount must be greater than zero"); } + let amount = material_amount + labor_amount; + // Generate unique engagement ID let next_id = env .storage() @@ -114,6 +132,9 @@ impl EscrowContract { client: client.clone(), artisan: artisan.clone(), amount, + material_amount, + labor_amount, + materials_released: false, status: Status::Pending, deadline, }; @@ -183,6 +204,62 @@ impl EscrowContract { .extend_ttl(&key, TTL_THRESHOLD, ESCROW_TTL); } + /// Release the material tranche to the artisan immediately upon job acceptance. + /// Can only be called by the client when the escrow is Funded. + /// The labor tranche remains locked in Funded state until the job is complete. + pub fn release_materials(env: Env, engagement_id: u64, token: Address) { + let key = DataKey::Escrow(engagement_id); + let mut escrow: Escrow = env + .storage() + .persistent() + .get(&key) + .expect("Escrow not found"); + + // Auth: Require the client's signature + escrow.client.require_auth(); + + // State check: must be Funded + if escrow.status != Status::Funded { + panic!("Escrow is not funded"); + } + + // Guard against double release of materials + if escrow.materials_released { + panic!("Materials have already been released"); + } + + // Deadline check + let current_time = env.ledger().timestamp(); + if current_time > escrow.deadline { + panic!("Deadline has passed; cannot release materials"); + } + + // Transfer material amount to artisan + let token_client = token::Client::new(&env, &token); + token_client.transfer( + &env.current_contract_address(), + &escrow.artisan, + &escrow.material_amount, + ); + + // Mark materials as released; labor remains locked (status stays Funded) + escrow.materials_released = true; + env.storage().persistent().set(&key, &escrow); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, ESCROW_TTL); + + env.events().publish( + (), + MaterialsReleasedEvent { + id: engagement_id, + artisan: escrow.artisan, + amount: escrow.material_amount, + timestamp: current_time, + }, + ); + } + /// Release funds from escrow to the artisan /// Can only be called by the client and only when escrow is funded. /// Also verifies that the deadline has not passed; after the deadline the client @@ -209,12 +286,17 @@ impl EscrowContract { panic!("Escrow is not funded"); } - // Logic: Transfer the stored escrow amount from the contract address to the artisan's address + // Logic: Transfer remaining labor amount (or full amount if materials were not pre-released) + let release_amount = if escrow.materials_released { + escrow.labor_amount + } else { + escrow.amount + }; let token_client = token::Client::new(&env, &token); token_client.transfer( &env.current_contract_address(), &escrow.artisan, - &escrow.amount, + &release_amount, ); // State: Update the escrow status to Released @@ -251,12 +333,17 @@ impl EscrowContract { panic!("Deadline has not passed; cannot reclaim yet"); } - // Transfer funds back to client + // Transfer only the locked portion back to client (labor if materials were pre-released) + let reclaim_amount = if escrow.materials_released { + escrow.labor_amount + } else { + escrow.amount + }; let token_client = token::Client::new(&env, &token); token_client.transfer( &env.current_contract_address(), &escrow.client, - &escrow.amount, + &reclaim_amount, ); // Update state to Refunded @@ -272,7 +359,7 @@ impl EscrowContract { ReclaimedEvent { id: engagement_id, client: escrow.client.clone(), - amount: escrow.amount, + amount: reclaim_amount, timestamp: current_time, }, ); @@ -370,12 +457,17 @@ impl EscrowContract { panic!("Winner must be either client or artisan"); } - // Transfer funds to the winner + // Transfer only the locked portion to winner (labor if materials were pre-released) + let arbitrate_amount = if escrow.materials_released { + escrow.labor_amount + } else { + escrow.amount + }; let token_client = token::Client::new(&env, &token); token_client.transfer( &env.current_contract_address(), &winner, - &escrow.amount, + &arbitrate_amount, ); // Update status based on winner @@ -398,7 +490,7 @@ impl EscrowContract { DisputeResolvedEvent { id: engagement_id, winner, - amount: escrow.amount, + amount: arbitrate_amount, timestamp: current_time, }, ); @@ -423,12 +515,18 @@ mod test_legacy { // Create test addresses let client_address = Address::generate(&env); let artisan_address = Address::generate(&env); - let amount: i128 = 1000; + let material_amount: i128 = 600; + let labor_amount: i128 = 400; let deadline = env.ledger().timestamp() + 86400; // 24 hours from now // Initialize engagement - let engagement_id = - client.initialize(&client_address, &artisan_address, &amount, &deadline); + let engagement_id = client.initialize( + &client_address, + &artisan_address, + &material_amount, + &labor_amount, + &deadline, + ); // Verify the returned ID is valid (should be 1 for first engagement) assert_eq!(engagement_id, 1); @@ -443,7 +541,10 @@ mod test_legacy { assert_eq!(stored_escrow.client, client_address); assert_eq!(stored_escrow.artisan, artisan_address); - assert_eq!(stored_escrow.amount, amount); + assert_eq!(stored_escrow.amount, material_amount + labor_amount); + assert_eq!(stored_escrow.material_amount, material_amount); + assert_eq!(stored_escrow.labor_amount, labor_amount); + assert_eq!(stored_escrow.materials_released, false); assert_eq!(stored_escrow.status, Status::Pending); assert_eq!(stored_escrow.deadline, deadline); @@ -465,11 +566,10 @@ mod test_legacy { let client = EscrowContractClient::new(&env, &contract_id); let same_address = Address::generate(&env); - let amount: i128 = 1000; let deadline = env.ledger().timestamp() + 86400; // This should panic because client == artisan - client.initialize(&same_address, &same_address, &amount, &deadline); + client.initialize(&same_address, &same_address, &500i128, &500i128, &deadline); } #[test] @@ -484,8 +584,8 @@ mod test_legacy { let zero_amount: i128 = 0; let deadline = env.ledger().timestamp() + 86400; - // This should panic because amount is zero - client.initialize(&client_address, &artisan_address, &zero_amount, &deadline); + // This should panic because material_amount is zero + client.initialize(&client_address, &artisan_address, &zero_amount, &500i128, &deadline); } #[test] @@ -500,11 +600,12 @@ mod test_legacy { let negative_amount: i128 = -100; let deadline = env.ledger().timestamp() + 86400; - // This should panic because amount is negative + // This should panic because material_amount is negative client.initialize( &client_address, &artisan_address, &negative_amount, + &500i128, &deadline, ); } @@ -548,6 +649,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount: escrow_amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Pending, deadline, }; @@ -615,6 +719,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount: escrow_amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Pending, deadline, }; @@ -665,6 +772,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount: escrow_amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, // Already funded deadline, }; @@ -719,6 +829,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount: 500, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Pending, deadline: env.ledger().timestamp().saturating_sub(1), }; @@ -761,6 +874,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address.clone(), amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, deadline, }; @@ -797,6 +913,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, deadline, }; @@ -841,6 +960,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, deadline, }; @@ -906,6 +1028,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, deadline, }; @@ -956,6 +1081,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Pending, deadline, }; @@ -996,6 +1124,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, deadline, }; @@ -1045,6 +1176,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address.clone(), amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, deadline, }; @@ -1091,6 +1225,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Pending, deadline, }; @@ -1131,6 +1268,9 @@ mod test_legacy { client: client_address.clone(), artisan: artisan_address, amount, + material_amount: 0, + labor_amount: 0, + materials_released: false, status: Status::Funded, deadline, }; diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2ca3684..4b7d5cb 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -53,11 +53,14 @@ mod happy_path_tests { }) } - /// Initialize an engagement + /// Initialize an engagement with a material/labor split fn initialize_engagement(&self, client: &Address, artisan: &Address, amount: i128) -> u64 { + let half = amount / 2; + let material_amount = half; + let labor_amount = amount - half; let deadline = self.env.ledger().timestamp() + 86400; self.client_contract - .initialize(client, artisan, &amount, &deadline) + .initialize(client, artisan, &material_amount, &labor_amount, &deadline) } /// Mint tokens to an address @@ -370,9 +373,10 @@ mod happy_path_tests { // set a short deadline a few seconds in the future let now = ctx.env.ledger().timestamp(); let deadline = now + 10; + let half = amount / 2; let engagement_id = ctx .client_contract - .initialize(&client, &artisan, &amount, &deadline); + .initialize(&client, &artisan, &half, &(amount - half), &deadline); // fund and deposit before the deadline ctx.mint_tokens(&client, amount); @@ -660,4 +664,183 @@ mod happy_path_tests { // Try to arbitrate without arbitrator set ctx.client_contract .arbitrate(&engagement_id, &client, &ctx.token_address); - }} + } + + // ─── Tranche tests (ISSUE-22) ─────────────────────────────────────────── + + /// Test 23: Client deposits combined sum with a single signature (material + labor) + #[test] + fn test_split_deposit_single_sign() { + let ctx = TestContext::new(); + let (client, artisan) = create_addresses(&ctx.env); + let material: i128 = 3000; + let labor: i128 = 7000; + let total = material + labor; + + let deadline = ctx.env.ledger().timestamp() + 86400; + let engagement_id = ctx + .client_contract + .initialize(&client, &artisan, &material, &labor, &deadline); + + // Client signs once to deposit the combined sum + ctx.mint_tokens(&client, total); + ctx.client_contract + .deposit(&engagement_id, &ctx.token_address); + + assert_eq!(ctx.token_client.balance(&ctx.contract_id), total); + assert_eq!(ctx.get_escrow(engagement_id).status, Status::Funded); + } + + /// Test 24: release_materials sends material_amount to artisan; labor stays locked + #[test] + fn test_release_materials_basic() { + let ctx = TestContext::new(); + let (client, artisan) = create_addresses(&ctx.env); + let material: i128 = 3000; + let labor: i128 = 7000; + let total = material + labor; + + let deadline = ctx.env.ledger().timestamp() + 86400; + let engagement_id = ctx + .client_contract + .initialize(&client, &artisan, &material, &labor, &deadline); + + ctx.mint_tokens(&client, total); + ctx.client_contract + .deposit(&engagement_id, &ctx.token_address); + + let artisan_before = ctx.token_client.balance(&artisan); + ctx.client_contract + .release_materials(&engagement_id, &ctx.token_address); + let artisan_after = ctx.token_client.balance(&artisan); + + // Artisan received exactly the material amount + assert_eq!(artisan_after - artisan_before, material); + // Contract still holds the labor amount + assert_eq!(ctx.token_client.balance(&ctx.contract_id), labor); + + let escrow = ctx.get_escrow(engagement_id); + assert!(escrow.materials_released); + // Status stays Funded (labor still locked) + assert_eq!(escrow.status, Status::Funded); + } + + /// Test 25: Full tranche workflow — release_materials then release (labor) + #[test] + fn test_release_materials_then_labor() { + let ctx = TestContext::new(); + let (client, artisan) = create_addresses(&ctx.env); + let material: i128 = 3000; + let labor: i128 = 7000; + let total = material + labor; + + let deadline = ctx.env.ledger().timestamp() + 86400; + let engagement_id = ctx + .client_contract + .initialize(&client, &artisan, &material, &labor, &deadline); + + ctx.mint_tokens(&client, total); + ctx.client_contract + .deposit(&engagement_id, &ctx.token_address); + + // Release materials first + ctx.client_contract + .release_materials(&engagement_id, &ctx.token_address); + assert_eq!(ctx.token_client.balance(&artisan), material); + + // Release labor (only labor_amount should transfer) + ctx.client_contract + .release(&engagement_id, &ctx.token_address); + assert_eq!(ctx.token_client.balance(&artisan), total); + assert_eq!(ctx.token_client.balance(&ctx.contract_id), 0); + + assert_eq!(ctx.get_escrow(engagement_id).status, Status::Released); + } + + /// Test 26: release_materials fails if escrow is not Funded + #[test] + #[should_panic(expected = "Escrow is not funded")] + fn test_release_materials_wrong_state() { + let ctx = TestContext::new(); + let (client, artisan) = create_addresses(&ctx.env); + let material: i128 = 3000; + let labor: i128 = 7000; + + let deadline = ctx.env.ledger().timestamp() + 86400; + let engagement_id = ctx + .client_contract + .initialize(&client, &artisan, &material, &labor, &deadline); + + // Do NOT deposit — escrow stays Pending + ctx.client_contract + .release_materials(&engagement_id, &ctx.token_address); + } + + /// Test 27: release_materials fails on a second call (double release guard) + #[test] + #[should_panic(expected = "Materials have already been released")] + fn test_release_materials_twice() { + let ctx = TestContext::new(); + let (client, artisan) = create_addresses(&ctx.env); + let material: i128 = 3000; + let labor: i128 = 7000; + let total = material + labor; + + let deadline = ctx.env.ledger().timestamp() + 86400; + let engagement_id = ctx + .client_contract + .initialize(&client, &artisan, &material, &labor, &deadline); + + ctx.mint_tokens(&client, total); + ctx.client_contract + .deposit(&engagement_id, &ctx.token_address); + + ctx.client_contract + .release_materials(&engagement_id, &ctx.token_address); + // Second call must panic + ctx.client_contract + .release_materials(&engagement_id, &ctx.token_address); + } + + /// Test 28: reclaim after materials released returns only labor_amount + #[test] + fn test_reclaim_after_materials_released() { + let ctx = TestContext::new(); + let (client, artisan) = create_addresses(&ctx.env); + let material: i128 = 3000; + let labor: i128 = 7000; + let total = material + labor; + + let now = ctx.env.ledger().timestamp(); + let deadline = now + 10; + let engagement_id = ctx + .client_contract + .initialize(&client, &artisan, &material, &labor, &deadline); + + ctx.mint_tokens(&client, total); + ctx.client_contract + .deposit(&engagement_id, &ctx.token_address); + + // Release materials before deadline + ctx.client_contract + .release_materials(&engagement_id, &ctx.token_address); + + // Fast-forward past deadline + ctx.env.ledger().set_timestamp(deadline + 1); + + let client_before = ctx.token_client.balance(&client); + ctx.client_contract + .reclaim(&engagement_id, &ctx.token_address); + let client_after = ctx.token_client.balance(&client); + + // Only labor portion is returned; materials already went to artisan + assert_eq!(client_after - client_before, labor); + assert_eq!(ctx.token_client.balance(&artisan), material); + assert_eq!(ctx.token_client.balance(&ctx.contract_id), 0); + + assert_eq!( + ctx.get_escrow(engagement_id).status, + Status::Refunded + ); + } +}