Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions stellargrant-contracts/contracts/stellar-grants/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 20 additions & 7 deletions stellargrant-contracts/contracts/stellar-grants/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -631,20 +631,30 @@ 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,
milestone_idx: u32,
recipient: Address,
description: String,
proof_url: String,
proof_hash: BytesN<32>,
) -> Result<(), ContractError> {
recipient.require_auth();

Expand All @@ -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);
}
}
Expand All @@ -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(())
}
Expand Down
140 changes: 127 additions & 13 deletions stellargrant-contracts/contracts/stellar-grants/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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, || {
Expand All @@ -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);
});
}
Expand All @@ -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())));
}

Expand All @@ -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())));
}

Expand Down Expand Up @@ -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]
Expand All @@ -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())));
}

Expand Down Expand Up @@ -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())));
}

Expand Down
3 changes: 2 additions & 1 deletion stellargrant-contracts/contracts/stellar-grants/src/types.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -80,6 +80,7 @@ pub struct Milestone {
pub reasons: Map<Address, String>,
pub status_updated_at: u64,
pub proof_url: Option<String>,
pub proof_hash: BytesN<32>,
pub submission_timestamp: u64,
}

Expand Down