diff --git a/stellargrant-contracts/contracts/stellar-grants/src/events.rs b/stellargrant-contracts/contracts/stellar-grants/src/events.rs index ba204857..64ff8bb9 100644 --- a/stellargrant-contracts/contracts/stellar-grants/src/events.rs +++ b/stellargrant-contracts/contracts/stellar-grants/src/events.rs @@ -91,6 +91,15 @@ pub struct MilestoneSubmitted { pub timestamp: u64, } +#[contractevent] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MilestoneProofSubmitted { + pub grant_id: u64, + pub milestone_idx: u32, + pub proof_hash: soroban_sdk::BytesN<32>, + pub timestamp: u64, +} + #[contractevent] #[derive(Clone, Debug, PartialEq, Eq)] pub struct GrantFunded { @@ -188,6 +197,21 @@ impl Events { event.publish(env); } + pub fn emit_milestone_proof_submitted( + env: &Env, + grant_id: u64, + milestone_idx: u32, + proof_hash: soroban_sdk::BytesN<32>, + ) { + let event = MilestoneProofSubmitted { + grant_id, + milestone_idx, + proof_hash, + timestamp: env.ledger().timestamp(), + }; + event.publish(env); + } + pub fn emit_grant_funded( env: &Env, grant_id: u64, diff --git a/stellargrant-contracts/contracts/stellar-grants/src/lib.rs b/stellargrant-contracts/contracts/stellar-grants/src/lib.rs index 9d37d6fa..87527584 100644 --- a/stellargrant-contracts/contracts/stellar-grants/src/lib.rs +++ b/stellargrant-contracts/contracts/stellar-grants/src/lib.rs @@ -12,7 +12,7 @@ pub use types::{ Milestone, MilestoneState, }; -use soroban_sdk::{contract, contractimpl, token, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String, Vec}; #[contract] pub struct StellarGrantsContract; @@ -631,13 +631,22 @@ impl StellarGrantsContract { /// * `recipient` - The address of the grant recipient submitting the milestone. /// * `description` - A human-readable description of work completed for this milestone. /// * `proof_url` - A URL pointing to proof of completion (e.g. GitHub PR, report link). + /// * `proof_hash` - A 32-byte cryptographic proof hash (mandatory, not all zero bytes). /// /// # Errors /// * [`ContractError::GrantNotFound`] – if no grant exists with the given `grant_id`. /// * [`ContractError::InvalidState`] – if the grant is not in `Active` status. /// * [`ContractError::InvalidInput`] – if `milestone_idx` is out of bounds. /// * [`ContractError::Unauthorized`] – if `recipient` is not the grant owner. - /// * [`ContractError::MilestoneAlreadySubmitted`] – if the milestone is already submitted or approved. + /// * [`ContractError::MilestoneAlreadySubmitted`] – if the milestone has already been approved. + fn validate_proof_hash(env: &Env, proof_hash: &BytesN<32>) -> Result<(), ContractError> { + let zero_hash = BytesN::<32>::from_array(env, &[0u8; 32]); + if proof_hash == &zero_hash { + return Err(ContractError::InvalidInput); + } + Ok(()) + } + pub fn milestone_submit( env: Env, grant_id: u64, @@ -645,6 +654,7 @@ impl StellarGrantsContract { recipient: Address, description: String, proof_url: String, + proof_hash: BytesN<32>, ) -> Result<(), ContractError> { recipient.require_auth(); @@ -666,11 +676,12 @@ impl StellarGrantsContract { return Err(ContractError::Unauthorized); } - // 5. Milestone must not already be submitted or approved + // 5. Validate proof hash format + Self::validate_proof_hash(&env, &proof_hash)?; + + // 6. Milestone must not be approved (allow resubmit for Pending/Submitted/Rejected) if let Some(existing) = Storage::get_milestone(&env, grant_id, milestone_idx) { - if existing.state == MilestoneState::Submitted - || existing.state == MilestoneState::Approved - { + if existing.state == MilestoneState::Approved { return Err(ContractError::MilestoneAlreadySubmitted); } } @@ -687,13 +698,15 @@ impl StellarGrantsContract { reasons: soroban_sdk::Map::new(&env), status_updated_at: 0, proof_url: Some(proof_url), + proof_hash: proof_hash.clone(), submission_timestamp: env.ledger().timestamp(), }; Storage::set_milestone(&env, grant_id, milestone_idx, &milestone); - // Emit submission event + // Emit submission events Events::emit_milestone_submitted(&env, grant_id, milestone_idx, description); + Events::emit_milestone_proof_submitted(&env, grant_id, milestone_idx, proof_hash); Ok(()) } diff --git a/stellargrant-contracts/contracts/stellar-grants/src/test.rs b/stellargrant-contracts/contracts/stellar-grants/src/test.rs index 77d6986a..7021175b 100644 --- a/stellargrant-contracts/contracts/stellar-grants/src/test.rs +++ b/stellargrant-contracts/contracts/stellar-grants/src/test.rs @@ -5,7 +5,7 @@ mod tests { use crate::StellarGrantsContract; use crate::StellarGrantsContractClient; use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, token, Address, Env, Map, + contract, contractimpl, contracttype, testutils::Address as _, token, Address, BytesN, Env, Map, String, Vec, }; @@ -165,6 +165,8 @@ mod tests { reasons: Map::new(env), status_updated_at: 0, proof_url: Some(String::from_str(env, "https://proof.url")), + proof_hash: BytesN::from_array(env, &[0u8; 32]), + proof_hash: BytesN::from_array(env, &[0u8; 32]), submission_timestamp: env.ledger().timestamp(), }; Storage::set_milestone(env, grant_id, milestone_idx, &milestone); @@ -584,6 +586,7 @@ mod tests { reasons: Map::new(&env), status_updated_at: 0, proof_url: None, + proof_hash: BytesN::from_array(&env, &[0u8; 32]), submission_timestamp: 0, }; Storage::set_milestone(&env, grant_id, i, &milestone); @@ -651,6 +654,7 @@ mod tests { reasons: Map::new(&env), status_updated_at: 0, proof_url: None, + proof_hash: BytesN::from_array(&env, &[0u8; 32]), submission_timestamp: 0, }; Storage::set_milestone(&env, grant_id, 0, &m1); @@ -666,6 +670,7 @@ mod tests { reasons: Map::new(&env), status_updated_at: 0, proof_url: None, + proof_hash: BytesN::from_array(&env, &[0u8; 32]), submission_timestamp: 0, }; Storage::set_milestone(&env, grant_id, 1, &m2); @@ -725,6 +730,7 @@ mod tests { reasons: Map::new(&env), status_updated_at: 0, proof_url: None, + proof_hash: BytesN::from_array(&env, &[0u8; 32]), submission_timestamp: 0, }; Storage::set_milestone(&env, grant_id, 0, &m1); @@ -786,6 +792,7 @@ mod tests { reasons: Map::new(&env), status_updated_at: 0, proof_url: None, + proof_hash: BytesN::from_array(&env, &[0u8; 32]), submission_timestamp: 0, }; Storage::set_milestone(&env, grant_id, i, &milestone); @@ -849,6 +856,7 @@ mod tests { reasons: Map::new(&env), status_updated_at: 0, proof_url: None, + proof_hash: BytesN::from_array(&env, &[0u8; 32]), submission_timestamp: 0, }; Storage::set_milestone(&env, grant_id, i, &milestone); @@ -1056,7 +1064,8 @@ mod tests { let description = String::from_str(&env, "Completed smart contract implementation"); let proof_url = String::from_str(&env, "https://github.com/org/repo/pull/42"); - client.milestone_submit(&grant_id, &milestone_idx, &owner, &description, &proof_url); + let proof_hash = BytesN::from_array(&env, &[1u8; 32]); + client.milestone_submit(&grant_id, &milestone_idx, &owner, &description, &proof_url, &proof_hash); // Verify the milestone was stored correctly env.as_contract(&contract_id, || { @@ -1073,6 +1082,7 @@ mod tests { "https://github.com/org/repo/pull/42" )) ); + assert_eq!(milestone.proof_hash, BytesN::from_array(&env, &[1u8; 32])); assert_eq!(milestone.idx, milestone_idx); }); } @@ -1087,8 +1097,15 @@ mod tests { let description = String::from_str(&env, "Work done"); let proof_url = String::from_str(&env, "https://proof.url"); - let result = - client.try_milestone_submit(&999u64, &0u32, &recipient, &description, &proof_url); + let invalid_hash = BytesN::from_array(&env, &[0u8; 32]); + let result = client.try_milestone_submit( + &999u64, + &0u32, + &recipient, + &description, + &proof_url, + &invalid_hash, + ); assert_eq!(result, Err(Ok(ContractError::GrantNotFound.into()))); } @@ -1115,8 +1132,15 @@ mod tests { let proof_url = String::from_str(&env, "https://proof.url"); // The grant has total_milestones = 1, so index 1 is out of bounds - let result = - client.try_milestone_submit(&grant_id, &1u32, &owner, &description, &proof_url); + let invalid_hash = BytesN::from_array(&env, &[0u8; 32]); + let result = client.try_milestone_submit( + &grant_id, + &1u32, + &owner, + &description, + &proof_url, + &invalid_hash, + ); assert_eq!(result, Err(Ok(ContractError::InvalidInput.into()))); } @@ -1151,17 +1175,93 @@ mod tests { let description = String::from_str(&env, "Work done"); let proof_url = String::from_str(&env, "https://proof.url"); + let new_hash = BytesN::from_array(&env, &[2u8; 32]); let result = client.try_milestone_submit( &grant_id, &milestone_idx, &owner, &description, &proof_url, + &new_hash, ); - assert_eq!( - result, - Err(Ok(ContractError::MilestoneAlreadySubmitted.into())) + assert_eq!(result, Ok(())); + + env.as_contract(&contract_id, || { + let milestone = Storage::get_milestone(&env, grant_id, milestone_idx).unwrap(); + assert_eq!(milestone.proof_hash, new_hash); + }); + } + + #[test] + fn test_milestone_submit_invalid_hash() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _, contract_id) = setup_test(&env); + let owner = Address::generate(&env); + let token = Address::generate(&env); + let grant_id = 1u64; + + create_grant(&env, &contract_id, grant_id, owner.clone(), token, Vec::new(&env)); + + let description = String::from_str(&env, "Work done"); + let proof_url = String::from_str(&env, "https://proof.url"); + let invalid_proof_hash = BytesN::from_array(&env, &[0u8; 32]); + + let result = client.try_milestone_submit( + &grant_id, + &0u32, + &owner, + &description, + &proof_url, + &invalid_proof_hash, ); + + assert_eq!(result, Err(Ok(ContractError::InvalidInput.into()))); + } + + #[test] + fn test_milestone_submit_resubmit_with_correct_hash() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _, contract_id) = setup_test(&env); + let owner = Address::generate(&env); + let token = Address::generate(&env); + let grant_id = 1u64; + let milestone_idx = 0u32; + + create_grant(&env, &contract_id, grant_id, owner.clone(), token, Vec::new(&env)); + + let description = String::from_str(&env, "Initial submission"); + let proof_url = String::from_str(&env, "https://proof.url"); + let first_hash = BytesN::from_array(&env, &[0u8; 32]); + + let failed = client.try_milestone_submit( + &grant_id, + &milestone_idx, + &owner, + &description, + &proof_url, + &first_hash, + ); + assert_eq!(failed, Err(Ok(ContractError::InvalidInput.into()))); + + let good_hash = BytesN::from_array(&env, &[1u8; 32]); + let ok = client.milestone_submit( + &grant_id, + &milestone_idx, + &owner, + &description, + &proof_url, + &good_hash, + ); + assert!(ok.is_ok()); + + env.as_contract(&contract_id, || { + let milestone = Storage::get_milestone(&env, grant_id, milestone_idx).unwrap(); + assert_eq!(milestone.proof_hash, good_hash); + }); } #[test] @@ -1181,8 +1281,15 @@ mod tests { let proof_url = String::from_str(&env, "https://proof.url"); // attacker is not the grant owner - let result = - client.try_milestone_submit(&grant_id, &0u32, &attacker, &description, &proof_url); + let invalid_hash = BytesN::from_array(&env, &[0u8; 32]); + let result = client.try_milestone_submit( + &grant_id, + &0u32, + &attacker, + &description, + &proof_url, + &invalid_hash, + ); assert_eq!(result, Err(Ok(ContractError::Unauthorized.into()))); } @@ -1220,8 +1327,15 @@ mod tests { let description = String::from_str(&env, "Work done"); let proof_url = String::from_str(&env, "https://proof.url"); - let result = - client.try_milestone_submit(&grant_id, &0u32, &owner, &description, &proof_url); + let invalid_hash = BytesN::from_array(&env, &[0u8; 32]); + let result = client.try_milestone_submit( + &grant_id, + &0u32, + &owner, + &description, + &proof_url, + &invalid_hash, + ); assert_eq!(result, Err(Ok(ContractError::InvalidState.into()))); } diff --git a/stellargrant-contracts/contracts/stellar-grants/src/types.rs b/stellargrant-contracts/contracts/stellar-grants/src/types.rs index 7eadacf4..a43f1161 100644 --- a/stellargrant-contracts/contracts/stellar-grants/src/types.rs +++ b/stellargrant-contracts/contracts/stellar-grants/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracterror, contracttype, Address, Map, String, Vec}; +use soroban_sdk::{contracterror, contracttype, Address, BytesN, Map, String, Vec}; /// Contract error types #[contracterror] @@ -80,6 +80,7 @@ pub struct Milestone { pub reasons: Map, pub status_updated_at: u64, pub proof_url: Option, + pub proof_hash: BytesN<32>, pub submission_timestamp: u64, }