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
36 changes: 36 additions & 0 deletions bindings/hunty-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ export interface ClueAddedEvent {
creator: string;
hunt_id: u64;
is_required: boolean;
difficulty: number;
}

export interface BatchClueInput {
question: string;
answer: string;
points: number;
is_required: boolean;
difficulty: number;
points: u32;
question: string;
}
Expand Down Expand Up @@ -178,6 +187,33 @@ export interface HuntActivatedEvent {
hunt_id: u64;
}

async add_clue({
hunt_id,
question,
answer,
points,
is_required,
difficulty = 1,
}: {
hunt_id: bigint;
question: string;
answer: string;
points: number;
is_required: boolean;
difficulty?: number;
}): Promise<AssembledTransaction<number>> {
return this.call("add_clue", hunt_id, question, answer, points, is_required, difficulty);
}

async add_clues({
hunt_id,
clues,
}: {
hunt_id: bigint;
clues: BatchClueInput[];
}): Promise<AssembledTransaction<number[]>> {
return this.call("add_clues", hunt_id, clues);
}

export interface HuntCancelledEvent {
hunt_id: u64;
Expand Down
117 changes: 115 additions & 2 deletions contracts/hunty-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
use crate::errors::{HuntError, HuntErrorCode};
use crate::storage::Storage;
use crate::types::{
AnswerIncorrectEvent, BatchClueInput, Clue, ClueAddedEvent, ClueCompletedEvent, ClueInfo,
ClueRemovedEvent, Hunt, HuntActivatedEvent, HuntCancelledEvent, HuntCompletedEvent,
HuntCreatedEvent, HuntDeactivatedEvent, HuntStatistics, HuntStatus, LeaderboardEntry,
PlayerProgress, PlayerRegisteredEvent, RewardClaimedEvent, RewardConfig, TimeBonusConfig,
AnswerIncorrectEvent, Clue, ClueAddedEvent, ClueAliasesAddedEvent, ClueCompletedEvent,
ClueInfo, CreatorBlacklistedEvent, CreatorRemovedFromBlacklistEvent, Hunt, HuntActivatedEvent,
HuntCancelledEvent, HuntCompletedEvent, HuntCreatedEvent, HuntDeactivatedEvent,
Expand Down Expand Up @@ -185,7 +189,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 @@ -358,6 +361,52 @@
is_required: bool,
difficulty: Option<u32>,
) -> Result<u32, HuntErrorCode> {
let hunt = Storage::get_hunt_or_error(&env, hunt_id).map_err(HuntErrorCode::from)?;
hunt.creator.require_auth();
if hunt.status != HuntStatus::Draft {
return Err(HuntErrorCode::InvalidHuntStatus);
}
if Storage::get_clue_counter(&env, hunt_id) >= MAX_CLUES_PER_HUNT {
return Err(HuntErrorCode::from(HuntError::TooManyClues {
hunt_id,
limit: MAX_CLUES_PER_HUNT,
}));
}

let clue_id = Self::insert_clue(
&env,
hunt_id,
&hunt.creator,
question,
answer,
points,
is_required,
difficulty,
)?;
let mut updated = hunt;
Self::sync_hunt_clue_counts(&env, hunt_id, &mut updated);
Storage::save_hunt(&env, &updated);

Ok(clue_id)
}

/// Adds multiple clues to a draft hunt in one invocation. Only the hunt creator can add clues.
///
/// The batch is validated against the per-hunt clue cap before writing any new clues,
/// so a request that would exceed the limit fails without partially adding clues.
pub fn add_clues(
env: Env,
hunt_id: u64,
clues: Vec<BatchClueInput>,
) -> Result<Vec<u32>, HuntErrorCode> {
let hunt = Storage::get_hunt_or_error(&env, hunt_id).map_err(HuntErrorCode::from)?;
hunt.creator.require_auth();
if hunt.status != HuntStatus::Draft {
return Err(HuntErrorCode::InvalidHuntStatus);
}

let existing = Storage::get_clue_counter(&env, hunt_id);
if existing.saturating_add(clues.len()) > MAX_CLUES_PER_HUNT {
// Fast validation using instance cache (cheaper than persistent read)
let cache = Self::get_hunt_cache_or_load(&env, hunt_id)?;
if cache.status != HuntStatus::Draft {
Expand All @@ -370,6 +419,55 @@
limit: MAX_CLUES_PER_HUNT,
}));
}

let mut clue_ids = Vec::new(&env);
for i in 0..clues.len() {
let clue = clues.get(i).unwrap();
let clue_id = Self::insert_clue(
&env,
hunt_id,
&hunt.creator,
clue.question,
clue.answer,
clue.points,
clue.is_required,
clue.difficulty,
)?;
clue_ids.push_back(clue_id);
}

let mut updated = hunt;
Self::sync_hunt_clue_counts(&env, hunt_id, &mut updated);
Storage::save_hunt(&env, &updated);

Ok(clue_ids)
}

fn insert_clue(
env: &Env,
hunt_id: u64,
creator: &Address,
question: String,
answer: String,
points: u32,
is_required: bool,
difficulty: u8,
) -> Result<u32, HuntErrorCode> {
if difficulty == 0 || difficulty > 10 {
return Err(HuntErrorCode::InvalidDifficulty);
}

let qlen = question.len();
if qlen == 0 || qlen > MAX_QUESTION_LENGTH {
return Err(HuntErrorCode::InvalidQuestion);
}
if points == 0 {
return Err(HuntErrorCode::InvalidPoints);
}
let answer_hash =
Self::normalize_and_hash_answer(env, &answer).map_err(HuntErrorCode::from)?;
let clue_id = Storage::next_clue_id(env, hunt_id);
let mut answer_hashes: Vec<BytesN<32>> = Vec::new(env);
let question = crate::sanitization::StringSanitizer::sanitize(
&env,
&question,
Expand All @@ -390,6 +488,14 @@
is_required,
difficulty: difficulty.unwrap_or(1),
};
Storage::save_clue(env, hunt_id, &clue);
let event = ClueAddedEvent {
hunt_id,
clue_id,
creator: creator.clone(),
points,
is_required,
difficulty,
Storage::save_clue(&env, hunt_id, &clue);
let mut updated = Storage::get_hunt_or_error(&env, hunt_id).map_err(HuntErrorCode::from)?;
updated.total_clues += 1;
Expand All @@ -407,7 +513,8 @@
difficulty: difficulty.unwrap_or(1),
};
env.events()
.publish((Symbol::new(&env, "ClueAdded"), hunt_id, clue_id), event);
.publish((Symbol::new(env, "ClueAdded"), hunt_id, clue_id), event);

Ok(clue_id)
}

Expand Down Expand Up @@ -782,6 +889,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 +902,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 +2346,4 @@
pub mod types;

#[cfg(test)]
mod test;

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

View workflow job for this annotation

GitHub Actions / Clippy

this file contains an unclosed delimiter

Check failure on line 2349 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 2349 in contracts/hunty-core/src/lib.rs

View workflow job for this annotation

GitHub Actions / Format

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
Loading
Loading