Skip to content
Merged

done #168

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
20 changes: 5 additions & 15 deletions contracts/wave_milestone/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,6 @@ impl WaveMilestoneContract {
/// - WaveGuard `is_maintainer` check passes.
///
/// # Errors
/// - [`Error::InvalidDeveloper`] — `developer` is a zero-like address.
/// - [`Error::BountyAlreadyClaimed`] — the `(repo_hash, issue_id)` pair was
/// already paid out.
/// - [`Error::InsufficientPoolBalance`] — `amount` exceeds remaining funds.
Expand Down Expand Up @@ -312,20 +311,11 @@ impl WaveMilestoneContract {
// ── WaveGuard validation ──
ensure_is_maintainer(&env, &pool.guard_contract, &maintainer)?;

// ── Developer address validation (issue #109) ──
// Reject a payout directed back to this contract — tokens sent to the
// contract vault are not recoverable through normal bounty claims.
if developer == env.current_contract_address() {
return Err(Error::InvalidDeveloper);
}

// ── Duplicate-claim guard (CM-01: reads Persistent storage) ──
// SECURITY: Must use Persistent storage here. Temporary storage entries
// expire after their TTL; a lapsed entry returns None, bypassing this
// guard and allowing a maintainer to re-claim the same issue bounty.
// Uniqueness is enforced by key existence alone — the key IS the claim.
let claim_key = DataKey::IssueClaim(repo_hash.clone(), issue_id);
if env.storage().persistent().has(&claim_key) {
// ── Issue claim status validation (CM-01) ──
// Delegates to the canonical is_claimed view so claim-status logic
// is defined in one place. See is_claimed for the Persistent-storage
// and TTL-durability notes (CM-01 / TMP-02).
if Self::is_claimed(env.clone(), repo_hash.clone(), issue_id) {
return Err(Error::BountyAlreadyClaimed);
}
let claim_key = DataKey::IssueClaim(repo_hash.clone(), issue_id);
Expand Down
76 changes: 75 additions & 1 deletion contracts/wave_milestone/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ fn test_revoked_maintainer_cannot_clawback() {
.clawback_expired_funds(&t.maintainer);
let after = MockTokenClient::new(&t.env, &t.token_id).balance(&t.maintainer);

assert_eq!(WaveMilestoneContractClient::new(&t.env, &t.contract_id).milestone_balance(), 0);
assert_eq!(result.err().unwrap(), Ok(Error::UnauthorizedMaintainer));
}

/// A second, separately-authorized maintainer (a colluding or rogue
Expand Down Expand Up @@ -864,3 +864,77 @@ fn test_release_bounty_accepts_nonzero_repo_hash() {

assert!(WaveMilestoneContractClient::new(&t.env, &t.contract_id).is_claimed(&t.repo_hash, &1u32));
}

// ─────────────────────────────────────────────────────────────
// Pool Creation Validation (Issues: expiry, maintainer, guard)
// ─────────────────────────────────────────────────────────────

/// Expiry at exactly the current ledger timestamp (not strictly in the future)
/// must be rejected.
#[test]
fn test_create_pool_rejects_expiry_at_current_time() {
let t = setup();
let now = t.env.ledger().timestamp();
MockTokenClient::new(&t.env, &t.token_id).mint(&t.maintainer, &1_000_000_000u128);

let result = WaveMilestoneContractClient::new(&t.env, &t.contract_id).try_create_milestone_pool(
&t.maintainer,
&t.guard_id,
&t.token_id,
&1_000_000_000u128,
&now,
);

assert_eq!(result.err().unwrap(), Ok(Error::ExpiryInPast));
}

/// Expiry strictly in the past must be rejected.
#[test]
fn test_create_pool_rejects_expiry_in_past() {
let t = setup();
let past = t.env.ledger().timestamp().saturating_sub(1);
MockTokenClient::new(&t.env, &t.token_id).mint(&t.maintainer, &1_000_000_000u128);

let result = WaveMilestoneContractClient::new(&t.env, &t.contract_id).try_create_milestone_pool(
&t.maintainer,
&t.guard_id,
&t.token_id,
&1_000_000_000u128,
&past,
);

assert_eq!(result.err().unwrap(), Ok(Error::ExpiryInPast));
}

/// Passing the WaveMilestone contract's own address as `guard_contract` must
/// be rejected to prevent self-referential authorization loops.
#[test]
fn test_create_pool_rejects_self_as_guard() {
let t = setup();
MockTokenClient::new(&t.env, &t.token_id).mint(&t.maintainer, &1_000_000_000u128);

let result = WaveMilestoneContractClient::new(&t.env, &t.contract_id).try_create_milestone_pool(
&t.maintainer,
&t.contract_id, // guard_contract == self
&t.token_id,
&1_000_000_000u128,
&t.expiry,
);

assert_eq!(result.err().unwrap(), Ok(Error::InvalidGuard));
}

/// The pool must store the exact `maintainer` address passed at creation.
/// (Covers "validate pool creation preserves maintainer address".)
#[test]
fn test_create_pool_preserves_maintainer_address() {
let t = setup();
fund_pool(&t, 5_000_000_000);

let pool = WaveMilestoneContractClient::new(&t.env, &t.contract_id)
.milestone_info()
.unwrap();

assert_eq!(pool.maintainer, t.maintainer);
assert_eq!(pool.guard_contract, t.guard_id);
}
Loading
Loading