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
1 change: 1 addition & 0 deletions .github/workflows/bindings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
targets: wasm32-unknown-unknown,wasm32v1-none

- name: Install Stellar CLI
run: cargo install --locked stellar-cli
uses: stellar/stellar-cli@v27.0.0

- name: Build contracts
Expand Down
25 changes: 25 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# TODO - Compact player registration storage (hunty-core)

## Step 1: Implement bit flags for boolean fields

- File: `contracts/hunty-core/src/types.rs`
- Change `StoredPlayerProgress`:
- remove `is_completed: bool`, `reward_claimed: bool`
- add `flags: u8` with bits for both
- Update `PlayerProgress::to_stored()` and `PlayerProgress::from_stored()` accordingly
- ✅ Done (in this PR)

## Step 2: Ensure compilation/test fixes

- File: `contracts/hunty-core/src/test.rs`
- Fix any tests/uses that directly access stored boolean fields (public view should remain unchanged)

## Step 3: Add benchmark-style test harness (CI)

- Add a lightweight test that repeatedly registers/saves player progress and asserts functional correctness
- Optionally compare/record gas usage by measuring test execution pattern (best-effort)

## Step 4: Document results

- Note that timestamp packing was not changed (safety), only boolean flags were packed
- Provide expected/observed footprint reduction plan
8 changes: 7 additions & 1 deletion contracts/hunty-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@
let event = HuntCreatedEvent {
hunt_id,
creator: creator.clone(),
title: title.clone(),
};
env.events()
.publish((Symbol::new(&env, "HuntCreated"), hunt_id), event);
Expand Down Expand Up @@ -404,6 +403,7 @@
question,
points,
is_required,
difficulty,
difficulty: difficulty.unwrap_or(1),
};
env.events()
Expand Down Expand Up @@ -782,6 +782,8 @@
}

pub fn cancel_hunt(env: Env, hunt_id: u64, caller: Address) -> Result<(), HuntErrorCode> {
// Require the caller to authorize. Without this, an attacker could spoof `caller`
// and cancel hunts by passing the creator address.
caller.require_auth();

// Load hunt
Expand All @@ -793,6 +795,10 @@
if caller != cache.creator {
return Err(HuntErrorCode::Unauthorized);
}


// Cannot cancel a completed hunt
if hunt.status == HuntStatus::Completed {
if cache.status == HuntStatus::Completed {
return Err(HuntErrorCode::InvalidHuntStatus);
}
Expand Down Expand Up @@ -2233,4 +2239,4 @@
pub mod types;

#[cfg(test)]
mod test;

Check failure on line 2242 in contracts/hunty-core/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

this file contains an unclosed delimiter

Check failure on line 2242 in contracts/hunty-core/src/lib.rs

View workflow job for this annotation

GitHub Actions / Format

this file contains an unclosed delimiter

Check failure on line 2242 in contracts/hunty-core/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

this file contains an unclosed delimiter
15 changes: 15 additions & 0 deletions contracts/hunty-core/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,13 @@ impl Storage {
pub fn save_player_progress(env: &Env, progress: &PlayerProgress) {
// Store the progress with composite key (hunt_id + player address)
let key = Self::progress_key(progress.hunt_id, &progress.player);
let activated_at = Self::get_hunt(env, progress.hunt_id)
.map(|h| h.activated_at)
.unwrap_or(0);
env.storage().persistent().set(&key, &progress.to_stored(activated_at));
env.storage()
.persistent()
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
env.storage().persistent().set(&key, progress);
let policy = if progress.is_completed || progress.reward_claimed {
TtlPolicy::Short
Expand Down Expand Up @@ -332,6 +339,14 @@ impl Storage {
player: &Address,
) -> Option<PlayerProgress> {
let key = Self::progress_key(hunt_id, player);
let activated_at = Self::get_hunt(env, hunt_id)
.map(|h| h.activated_at)
.unwrap_or(0);
env
.storage()
.persistent()
.get::<_, StoredPlayerProgress>(&key)
.map(|stored| PlayerProgress::from_stored(env, stored, player.clone(), hunt_id, activated_at))
let raw_val: Option<soroban_sdk::Val> = env.storage().persistent().get(&key);
raw_val.map(|val| {
if let Ok(bytes) = soroban_sdk::Bytes::try_from_val(env, &val) {
Expand Down
155 changes: 155 additions & 0 deletions contracts/hunty-core/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use std::string::ToString;

#[cfg(test)]
mod test {
// Benchmark-style micro tests (best-effort gas/footprint proxy)

use super::*;
use soroban_sdk::{Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec};
// Bring Soroban testutils traits into scope (generate addresses, set ledger info, register contracts).
Expand Down Expand Up @@ -7153,6 +7155,107 @@ fn test_get_hunt_statistics_mixed_completion_states() {
fn test_is_blacklisted_false_by_default() {
let env = Env::default();
let creator = Address::generate(&env);
let player = Address::generate(&env);

let (hunt_id, contract_id) =
setup_completed_hunt_with_rewards(&env, &creator, &player, 5, 1000);

// Try to complete the hunt — should fail with InvalidHuntStatus
env.mock_all_auths();
let result = as_core_contract(&env, &contract_id, |env| {
HuntyCore::complete_hunt(env.clone(), hunt_id, player.clone())
});
assert_eq!(result, Err(HuntErrorCode::InvalidHuntStatus));
}

#[test]
fn test_get_hunt_statistics_mixed_completion_states() {
let env = Env::default();
env.ledger().set_timestamp(1_700_000_000);

let creator = Address::generate(&env);

let player1 = Address::generate(&env);
let player2 = Address::generate(&env);
let player3 = Address::generate(&env);
let question = String::from_str(&env, "Q");
let answer = String::from_str(&env, "a");

// Register contract and create hunt
let contract_id = env.register(HuntyCore, ());
let hunt_id = execute_in_contract(&env, &contract_id, |env| {
HuntyCore::create_hunt(
env.clone(),
creator.clone(),
String::from_str(env, "Mixed Hunt"),
String::from_str(env, "Desc"),
None,
None,
)
.unwrap()
});

// Add a single required clue worth 10 points and activate
env.mock_all_auths();
execute_in_contract(&env, &contract_id, |env| {
HuntyCore::add_clue(
env.clone(),
hunt_id,
question.clone(),
answer.clone(),
10,
true,
)
.unwrap();
HuntyCore::activate_hunt(env.clone(), hunt_id, creator.clone()).unwrap();
});

// Register three players
env.mock_all_auths();
execute_in_contract(&env, &contract_id, |env| {
HuntyCore::register_player(env.clone(), hunt_id, player1.clone()).unwrap();
});
env.mock_all_auths();
execute_in_contract(&env, &contract_id, |env| {
HuntyCore::register_player(env.clone(), hunt_id, player2.clone()).unwrap();
});
env.mock_all_auths();
execute_in_contract(&env, &contract_id, |env| {
HuntyCore::register_player(env.clone(), hunt_id, player3.clone()).unwrap();
});

// Player1 and Player2 solve the required clue
env.mock_all_auths();
execute_in_contract(&env, &contract_id, |env| {
HuntyCore::submit_answer(env.clone(), hunt_id, 1, player1.clone(), answer.clone()).unwrap();
});
env.mock_all_auths();
execute_in_contract(&env, &contract_id, |env| {
HuntyCore::submit_answer(env.clone(), hunt_id, 1, player2.clone(), answer.clone()).unwrap();
});

// Player3 remains incomplete (no submissions)

// Fetch statistics and validate exact invariants
let stats = execute_in_contract(&env, &contract_id, |env| {
HuntyCore::get_hunt_statistics(env.clone(), hunt_id).unwrap()
});

// 3 players total, 2 completed -> floor(2/3*100) == 66
assert_eq!(stats.total_players, 3);
assert_eq!(stats.completed_count, 2);
assert_eq!(stats.completion_rate_percent, 66);

// Two players solved the single 10-point required clue => total 20
// Average must be computed over all 3 participants: floor(20 / 3) == 6
assert_eq!(stats.total_score_sum, 20);
assert_eq!(stats.average_score, 6);
}

// Try to complete the hunt — should fail with InvalidHuntStatus
env.mock_all_auths();
let result = as_core_contract(&env, &contract_id, |env| {
HuntyCore::complete_hunt(env.clone(), hunt_id, player.clone())
let result = with_core_contract(&env, |env, _cid| {
HuntyCore::is_blacklisted(env.clone(), creator.clone())
});
Expand All @@ -7170,4 +7273,56 @@ fn test_get_hunt_statistics_mixed_completion_states() {
progress.complete_clue(&env, 3, 30).unwrap();
assert_eq!(progress.total_score, 60);
}

#[test]
fn test_compact_storage_roundtrip() {
let env = Env::default();
let player = Address::generate(&env);
let hunt_id = 42u64;
let activated_at = 1_700_000_000u64;
let started_at = 1_700_000_600u64; // 10 minutes delta
let completed_at = 1_700_003_600u64; // 50 minutes delta from started_at

// Recreate PlayerProgress structure
let mut progress = crate::types::PlayerProgress::new(&env, player.clone(), hunt_id, started_at);
progress.is_completed = true;
progress.reward_claimed = true;
progress.completed_at = completed_at;
progress.total_score = 1000;
progress.required_completed_count = 5;

// Record some clues and attempts
progress.completed_clues.push_back(1);
progress.completed_clues.push_back(2);
progress.clue_attempts.set(1, 3);
progress.clue_attempts.set(2, 1);

// Convert to compact stored form
let stored = progress.to_stored(activated_at);

// Verify stored compact values
assert_eq!(stored.started_at_delta, 600);
assert_eq!(stored.completed_at_delta, 3000);
assert_eq!(stored.flags, 0b0000_0011);
assert_eq!(stored.total_score, 1000);
assert_eq!(stored.required_completed_count, 5);

// Reconstruct from stored
let restored = crate::types::PlayerProgress::from_stored(&env, stored, player.clone(), hunt_id, activated_at);

// Verify restored matches original
assert_eq!(restored.player, player);
assert_eq!(restored.hunt_id, hunt_id);
assert_eq!(restored.started_at, started_at);
assert_eq!(restored.completed_at, completed_at);
assert_eq!(restored.is_completed, true);
assert_eq!(restored.reward_claimed, true);
assert_eq!(restored.total_score, 1000);
assert_eq!(restored.required_completed_count, 5);
assert_eq!(restored.completed_clues.len(), 2);
assert_eq!(restored.completed_clues.get(0).unwrap(), 1);
assert_eq!(restored.completed_clues.get(1).unwrap(), 2);
assert_eq!(restored.clue_attempts.get(1).unwrap(), 3);
assert_eq!(restored.clue_attempts.get(2).unwrap(), 1);
}
}
Loading
Loading