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
6 changes: 6 additions & 0 deletions contracts/hunty-core/src/lib.rs
Original file line number Diff line number Diff line change
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 / 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 / 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 / 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);
}
}
86 changes: 84 additions & 2 deletions contracts/hunty-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,27 +133,52 @@ pub struct Location {
}


/// Internal storage representation of player progress.
/// Internal compact storage representation of player progress.
/// Does not store `player` or `hunt_id` — those are already the storage key.
///
/// ## Compact encoding
/// - Timestamps are delta-encoded as `u32` offsets from the hunt's `activated_at`,
/// saving 4 bytes each vs full `u64` UNIX timestamps. The max delta (~136 years)
/// far exceeds any realistic hunt duration.
/// - Boolean fields (`is_completed`, `reward_claimed`) are packed into `flags`.
/// - `clue_attempts` values use `u32` (Soroban's smallest XDR integer).
#[contracttype]
#[derive(Clone, Debug)]
pub struct StoredPlayerProgress {
pub completed_clues: Vec<u32>,
pub total_score: u32,
pub required_completed_count: u32,

/// Seconds elapsed from hunt `activated_at` to player registration.
/// Reconstruct absolute: `activated_at + started_at_delta`.
pub started_at_delta: u32,

/// Seconds elapsed from player registration to hunt completion, or 0 if not completed.
/// Reconstruct absolute: `activated_at + started_at_delta + completed_at_delta`.
pub completed_at_delta: u32,

/// Bit flags for boolean fields to reduce storage footprint.
/// BIT0 (1): is_completed
/// BIT1 (2): reward_claimed
/// BIT2–BIT7: reserved for future use
pub flags: u8,
pub started_at: u64,
pub completed_at: u64,
/// Packed boolean flags: bit 0 = is_completed, bit 1 = reward_claimed
pub flags: u32,
pub recent_submissions: Vec<u64>,
}



/// Public view of player progress, with `player` and `hunt_id` reconstructed from the key.
#[contracttype]
#[derive(Clone, Debug)]
pub struct PlayerProgress {
pub player: Address,
pub hunt_id: u64,
pub completed_clues: Vec<u32>,
pub completed_clue_index: Map<u32, bool>,
pub total_score: u32,
pub started_at: u64,
pub completed_at: u64,
Expand All @@ -168,6 +193,7 @@ impl PlayerProgress {
player,
hunt_id,
completed_clues: Vec::new(env),
completed_clue_index: Map::new(env),
total_score: 0,
started_at: current_time,
completed_at: 0,
Expand Down Expand Up @@ -200,23 +226,79 @@ impl PlayerProgress {
}

/// Convert to the compact form stored on-chain (drops redundant key fields).
pub fn to_stored(&self) -> StoredPlayerProgress {
///
/// `activated_at` is the hunt's activation timestamp, used to delta-encode
/// `started_at` and `completed_at` into compact `u32` offsets.
pub fn to_stored(&self, activated_at: u64) -> StoredPlayerProgress {
let mut flags: u8 = 0;
if self.is_completed {
flags |= 0b0000_0001;
}
if self.reward_claimed {
flags |= 0b0000_0010;
}

// Delta-encode timestamps relative to hunt activation.
let started_at_delta = self.started_at.saturating_sub(activated_at) as u32;
let completed_at_delta = if self.completed_at == 0 {
0u32
} else {
self.completed_at.saturating_sub(self.started_at) as u32
};

StoredPlayerProgress {
completed_clues: self.completed_clues.clone(),
total_score: self.total_score,
required_completed_count: self.required_completed_count,
started_at_delta,
completed_at_delta,
flags,
started_at: self.started_at,
completed_at: self.completed_at,
flags: Self::bools_to_flags(self.is_completed, self.reward_claimed),
recent_submissions: self.recent_submissions.clone(),
}
}


/// Reconstruct from stored form plus the key fields.
///
/// `activated_at` is the hunt's activation timestamp, used to reconstruct
/// absolute timestamps from the stored deltas.
pub fn from_stored(
env: &Env,
stored: StoredPlayerProgress,
player: Address,
hunt_id: u64,
activated_at: u64,
) -> Self {
let mut completed_clue_index = Map::new(env);
for i in 0..stored.completed_clues.len() {
let clue_id = stored.completed_clues.get(i).unwrap();
completed_clue_index.set(clue_id, true);
}

// Reconstruct absolute timestamps from deltas.
let started_at = activated_at + (stored.started_at_delta as u64);
let completed_at = if stored.completed_at_delta == 0 {
0u64
} else {
started_at + (stored.completed_at_delta as u64)
};

pub fn from_stored(stored: StoredPlayerProgress, player: Address, hunt_id: u64) -> Self {
Self {
player,
hunt_id,
completed_clues: stored.completed_clues,
completed_clue_index,
total_score: stored.total_score,
required_completed_count: stored.required_completed_count,
started_at,
completed_at,
is_completed: (stored.flags & 0b0000_0001) != 0,
reward_claimed: (stored.flags & 0b0000_0010) != 0,
clue_attempts: stored.clue_attempts,
total_score: stored.total_score,
started_at: stored.started_at,
completed_at: stored.completed_at,
Expand Down
Loading
Loading