macOS:
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Stellar CLI
# Follow instructions at https://soroban.stellar.org/docs/getting-started/setupLinux:
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Stellar CLI
# Follow instructions at https://soroban.stellar.org/docs/getting-started/setupWindows:
# Install Rust
# Download from https://rustup.rs/
# Install Stellar CLI
# Follow instructions at https://soroban.stellar.org/docs/getting-started/setuprustc --version
cargo --version
stellar --version- Clone and navigate:
git clone https://github.com/Samuel1-ona/Hunty-contract.git
cd Hunty-contract- Build all contracts:
# Build hunty-core
cd contracts/hunty-core
make build
# Build reward-manager
cd ../reward-manager
make build
# Build nft-reward
cd ../nft-reward
make build- Run tests:
# From each contract directory
make testThis section is required reading before modifying any contract storage or deploying to mainnet. Soroban's rent / TTL model differs fundamentally from EVM storage — data that is not periodically "bumped" will be archived (evicted) automatically by the network after its time-to-live expires. Evicted data is not deleted forever, but restoring it requires a separate
restore_footprinttransaction and a fee; if operators are unaware of this, live hunts can become silently unplayable.
Soroban exposes three storage buckets, each with different TTL defaults and use cases:
| Type | Accessed via | Default TTL (Futurenet / Testnet) | Typical use |
|---|---|---|---|
| Instance | env.storage().instance() |
Tied to the contract instance entry; extended whenever the contract is invoked | Admin config, contract-level counters, flags that must always be available |
| Persistent | env.storage().persistent() |
Network-configured minimum (≈ 120 days on mainnet at time of writing); not auto-bumped on invoke | Per-hunt data, per-player progress, clue answers, reward pools |
| Temporary | env.storage().temporary() |
Short-lived (minutes to hours); auto-deleted when expired, not archivable | Nonces, short-lived session flags — never used for game state |
The exact ledger counts for minimum/maximum TTL are governance-controlled and may change. Always check the Stellar Network Settings for current values before deploying.
The table below documents every storage key used across the three Hunty contracts and which storage type it lives in. Keep this table up-to-date whenever a new key is introduced.
| Storage Key | Type | Description | Eviction risk |
|---|---|---|---|
DataKey::Hunt(hunt_id) |
Persistent | Full Hunt struct (title, description, status, creator, clue count) |
High — must be bumped while hunt is active |
DataKey::Clue(hunt_id, clue_id) |
Persistent | Clue struct including SHA-256 answer hash |
High — unreadable clues silently block answer verification |
DataKey::PlayerProgress(hunt_id, player) |
Persistent | PlayerProgress struct (completed clues, score, timestamps) |
High — lost progress means players cannot complete hunts |
DataKey::HuntPlayers(hunt_id) |
Persistent | Vec<Address> of registered players (used by leaderboard) |
Medium — leaderboard queries degrade gracefully, but re-registration would be blocked |
DataKey::HuntCount |
Instance | Monotonic counter used to generate hunt IDs | Low — bumped automatically on every invocation |
DataKey::RewardManager |
Instance | Address of the registered RewardManager contract |
Low — bumped automatically on every invocation |
| Storage Key | Type | Description | Eviction risk |
|---|---|---|---|
DataKey::RewardPool(hunt_id) |
Persistent | RewardPool struct (balance, min distribution amount, funded flag) |
Critical — eviction causes reward distribution to fail with a storage-miss panic |
DataKey::NftRewardContract |
Instance | Address of the NftReward contract |
Low — bumped automatically on every invocation |
DataKey::Admin |
Instance | Admin Address authorised to configure the contract |
Low — bumped automatically on every invocation |
DataKey::XlmToken |
Instance | SAC address for native XLM | Low — bumped automatically on every invocation |
| Storage Key | Type | Description | Eviction risk |
|---|---|---|---|
DataKey::Nft(nft_id) |
Persistent | NftData struct (owner, hunt_id, player, metadata) |
High — evicted NFTs appear burned to any off-chain indexer |
DataKey::NftCount |
Instance | Monotonic counter for NFT IDs | Low — bumped automatically |
Calling env.storage().persistent().extend_ttl(&key, threshold, extend_to) restores the remaining
TTL of a persistent entry to extend_to ledgers only if its current remaining TTL has fallen
below threshold. This prevents unnecessary writes and keeps fees low.
Define these in a shared ttl.rs (or at the top of storage.rs) per contract:
/// Minimum remaining TTL before we extend (≈ 30 days at 5s/ledger).
pub const TTL_BUMP_THRESHOLD: u32 = 518_400;
/// Target TTL after a bump (≈ 1 year at 5s/ledger).
pub const TTL_BUMP_TARGET: u32 = 6_307_200;Calibration note: Stellar's ledger closes approximately every 5 seconds, giving ~6 307 200 ledgers per year. Adjust
TTL_BUMP_TARGETto match the expected lifetime of the data (e.g. a 90-day hunt needs at minimum 90 days × 17 280 ledgers/day ≈ 1 555 200 ledgers).
Bump persistent storage entries at read time (inside helper getters in storage.rs), not only
at write time. This ensures that data accessed frequently during active hunts keeps its TTL refreshed
without any additional operator intervention.
Pattern — bump-on-read in storage.rs:
pub fn get_hunt(env: &Env, hunt_id: u64) -> Option<Hunt> {
let key = DataKey::Hunt(hunt_id);
let result = env.storage().persistent().get::<DataKey, Hunt>(&key);
if result.is_some() {
env.storage().persistent().extend_ttl(
&key,
TTL_BUMP_THRESHOLD,
TTL_BUMP_TARGET,
);
}
result
}
pub fn get_player_progress(env: &Env, hunt_id: u64, player: &Address) -> Option<PlayerProgress> {
let key = DataKey::PlayerProgress(hunt_id, player.clone());
let result = env.storage().persistent().get::<DataKey, PlayerProgress>(&key);
if result.is_some() {
env.storage().persistent().extend_ttl(
&key,
TTL_BUMP_THRESHOLD,
TTL_BUMP_TARGET,
);
}
result
}Pattern — bump-on-write:
pub fn set_hunt(env: &Env, hunt_id: u64, hunt: &Hunt) {
let key = DataKey::Hunt(hunt_id);
env.storage().persistent().set(&key, hunt);
env.storage().persistent().extend_ttl(
&key,
TTL_BUMP_THRESHOLD,
TTL_BUMP_TARGET,
);
}Instance storage is bumped automatically whenever the contract is invoked, but you may also bump it explicitly at the top of each contract function for extra safety:
env.storage().instance().extend_ttl(TTL_BUMP_THRESHOLD, TTL_BUMP_TARGET);Even with bump-on-read in place, operators should monitor storage TTLs independently, because:
- Inactive hunts (no player activity for weeks) will not trigger bump-on-read.
- Reward pools for future hunts may sit idle for months before the hunt is activated.
# Check the TTL of a specific contract data entry (persistent)
stellar contract data get \
--id <CONTRACT_ID> \
--key '<XDR_KEY>' \
--network mainnet \
--durability persistent
# The response includes `live_until_ledger`. Compare to current ledger:
stellar ledgers --network mainnet | jq '.sequence'If an entry's TTL is dangerously low, bump it using a restore_footprint or extend_footprint_ttl
operation before it expires:
# Extend the contract instance (covers all instance-storage keys)
stellar contract extend \
--id <CONTRACT_ID> \
--ledgers-to-extend 6307200 \
--source <ADMIN_KEYPAIR> \
--network mainnet \
--durability persistentThe
stellar contract extendcommand targets the contract instance entry. To bump individual persistent data entries (e.g. a specificRewardPool), you currently need a dedicated contract invocation that callsextend_ttlinternally, or use the Stellar SDK to construct aBumpFootprintExpirationOpdirectly.
| Data category | Check frequency | Alert threshold |
|---|---|---|
| Active hunt data (Hunt, Clue, PlayerProgress) | Daily during active hunts | < 7 days remaining TTL |
| Reward pools for scheduled hunts | Weekly | < 30 days remaining TTL |
| NFT records | Monthly | < 60 days remaining TTL |
| Instance storage (all contracts) | Monthly | < 30 days remaining TTL |
| Risk | Scenario | Mitigation |
|---|---|---|
| Silent answer lock-out | Clue entries expire mid-hunt; players receive a storage-miss error on submit_answer |
Bump clue entries in add_clue and in get_clue; alert if TTL < 7 days |
| Lost player progress | PlayerProgress entry expires before complete_hunt is called |
Bump in register_player, submit_answer, and complete_hunt |
| Reward pool eviction | RewardPool expires before hunt is completed and rewards are distributed; distribute_rewards panics |
Bump RewardPool when pool is created and funded; set TTL to at least hunt_end_date + 90 days |
| Orphaned NFT | Nft(nft_id) entry expires; NFT appears burned to off-chain tools but is actually archivable |
Bump on mint; schedule periodic operator bump for all NFT IDs |
| Hunt-count desync | HuntCount (instance) expires and resets; new hunts get IDs that collide with old data |
Instance storage is bumped on every invocation — this risk is negligible in practice |
- Soroban Storage & State Archival Documentation
- Stellar Network Settings (live TTL values)
- BumpFootprintExpirationOp XDR reference
- Create a feature branch:
git checkout -b feature/your-feature-name-
Make changes:
- Edit source files
- Add tests
- Update documentation
-
Test your changes:
make test
make build- Format code:
make fmt- Commit and push:
git add .
git commit -m "feat: description of changes"
git push origin feature/your-feature-nameIndividual contract tests:
cd contracts/hunty-core
cargo testAll tests:
cargo test --workspaceWith output:
cargo test -- --nocaptureBuild a single contract:
cd contracts/hunty-core
make buildBuild all contracts:
# From project root
for dir in contracts/*/; do
cd "$dir" && make build && cd ../..
doneCheck build output:
ls -lh target/wasm32-unknown-unknown/release/*.wasmFile Structure:
lib.rs- Main contract implementationtypes.rs- Data structures (Hunt, Clue, PlayerProgress)storage.rs- Storage access patternserrors.rs- Custom error typestest.rs- Test suite
Key Functions to Implement:
create_hunt()- Create new huntadd_clue()- Add clue to huntregister_player()- Register player for huntsubmit_answer()- Submit and verify answercomplete_hunt()- Mark hunt complete
- Never forward
Hunt.descriptioninto NFT metadata during reward distribution. - The cross-contract NFT path must only pass public hunt fields such as title and pre-approved metadata.
- Keep this rule covered by tests, because the code comment alone is advisory and can be missed during refactors.
File Structure:
lib.rs- Main reward distribution logicxlm_handler.rs- XLM token handlingnft_handler.rs- NFT coordinationtest.rs- Test suite
Key Functions to Implement:
distribute_rewards()- Main distribution entryhandle_xlm_rewards()- XLM transfer logichandle_nft_rewards()- NFT minting coordination
File Structure:
lib.rs- NFT contract implementationtest.rs- Test suite
Key Functions to Implement:
mint_reward_nft()- Mint NFT for rewardtransfer_nft()- Transfer NFT to playerget_nft_metadata()- Retrieve NFT info
Test individual functions:
#[test]
fn test_create_hunt() {
let env = Env::default();
// Test implementation
}Test cross-contract interactions:
#[test]
fn test_reward_distribution() {
// Test HuntyCore -> RewardManager -> NftReward flow
}Aim for >80% code coverage. Run:
cargo test --workspace -- --nocapture-
Build errors:
- Check Rust version:
rustc --version - Clean and rebuild:
make clean && make build
- Check Rust version:
-
Test failures:
- Run with output:
cargo test -- --nocapture - Check error messages carefully
- Run with output:
-
Storage issues:
- Verify storage keys are unique
- Check data serialization
- Check TTL — see Storage Architecture & TTL Management
Print debugging:
env.logs().add("Debug message", &value);Check storage:
// In tests
let stored_value = env.storage().get(&key);Always format before committing:
make fmt
# or
cargo fmt --all- Functions:
snake_case - Types:
PascalCase - Constants:
UPPER_SNAKE_CASE - Storage keys:
snake_case
Add doc comments:
/// Creates a new hunt with the given parameters.
///
/// # Arguments
/// * `env` - The environment
/// * `creator` - Address of the hunt creator
///
/// # Returns
/// Hunt ID
pub fn create_hunt(env: Env, creator: Address) -> u64 {
// Implementation
}Hunty requires deploying three contracts in the correct order and wiring them together. The steps below cover both testnet and mainnet. Replace --network testnet with --network mainnet (and use a funded mainnet key) for production deployments.
- Stellar CLI installed and on your PATH (
stellar --version). - A funded deployer keypair. On testnet, use the friendbot:
stellar keys generate deployer --network testnet
stellar keys fund deployer --network testnet- All contracts built (
.wasmfiles present):
cargo build --target wasm32-unknown-unknown --release
ls target/wasm32-unknown-unknown/release/*.wasmNftReward has no initializer, so it can be deployed and used immediately.
NFT_CONTRACT=$(stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/nft_reward.wasm \
--source deployer \
--network testnet)
echo "NftReward: $NFT_CONTRACT"REWARD_MANAGER=$(stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/reward_manager.wasm \
--source deployer \
--network testnet)
echo "RewardManager: $REWARD_MANAGER"The XLM Stellar Asset Contract address differs by network.
| Network | XLM SAC address |
|---|---|
| Testnet | CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC |
| Mainnet | CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA |
Tip: verify with
stellar contract id asset --asset native --network testnet.
# Testnet
XLM_SAC="CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"initialize sets the admin keypair and the XLM SAC. It can only be called once.
DEPLOYER_ADDRESS=$(stellar keys address deployer)
stellar contract invoke \
--id "$REWARD_MANAGER" \
--source deployer \
--network testnet \
-- initialize \
--admin "$DEPLOYER_ADDRESS" \
--xlm_token "$XLM_SAC"stellar contract invoke \
--id "$REWARD_MANAGER" \
--source deployer \
--network testnet \
-- set_nft_reward_contract \
--admin "$DEPLOYER_ADDRESS" \
--nft_contract "$NFT_CONTRACT"HUNTY_CORE=$(stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/hunty_core.wasm \
--source deployer \
--network testnet)
echo "HuntyCore: $HUNTY_CORE"set_reward_manager tells HuntyCore where to send reward-distribution calls.
stellar contract invoke \
--id "$HUNTY_CORE" \
--source deployer \
--network testnet \
-- set_reward_manager \
--reward_manager "$REWARD_MANAGER"Save the addresses so they can be reused across sessions and by your frontend.
cat << EOF > .env.testnet
HUNTY_CORE=$HUNTY_CORE
REWARD_MANAGER=$REWARD_MANAGER
NFT_CONTRACT=$NFT_CONTRACT
XLM_SAC=$XLM_SAC
NETWORK=testnet
EOFBefore a hunt can pay out XLM rewards, its pool must be created and funded. Amounts are in stroops (1 XLM = 10 000 000 stroops).
HUNT_ID=1 # replace with your hunt ID after create_hunt
AMOUNT=100000000 # 10 XLM in stroops
# Create the pool
stellar contract invoke \
--id "$REWARD_MANAGER" \
--source deployer \
--network testnet \
-- create_reward_pool \
--creator "$DEPLOYER_ADDRESS" \
--hunt_id "$HUNT_ID" \
--min_distribution_amount 0
# Fund the pool (transfers XLM from the creator's account)
stellar contract invoke \
--id "$REWARD_MANAGER" \
--source deployer \
--network testnet \
-- fund_reward_pool \
--funder "$DEPLOYER_ADDRESS" \
--hunt_id "$HUNT_ID" \
--amount "$AMOUNT"# Check reward pool status
stellar contract invoke \
--id "$REWARD_MANAGER" \
--source deployer \
--network testnet \
-- get_reward_pool \
--hunt_id "$HUNT_ID"
# Check NftReward supply (should be 0 before any completions)
stellar contract invoke \
--id "$NFT_CONTRACT" \
--source deployer \
--network testnet \
-- total_supply- Use a hardware wallet or a dedicated deployment keypair; never use a hot key holding user funds.
- Replace
--network testnetwith--network mainnetin every command above. - Use the mainnet XLM SAC:
CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA. - Verify each contract ID with
stellar contract info --id <ID> --network mainnetbefore callinginitialize. - Keep
.env.mainnetout of version control (add it to.gitignore). - Review the Storage Architecture & TTL Management section and confirm bump-on-read is implemented before deploying.
- Check ARCHITECTURE.md for system design
- Review CONTRIBUTING.md for contribution guidelines
- Open an issue on GitHub for questions