diff --git a/frontend/hooks/usePuzzleContract.js b/frontend/hooks/usePuzzleContract.js index f69c16e2..be85d671 100644 --- a/frontend/hooks/usePuzzleContract.js +++ b/frontend/hooks/usePuzzleContract.js @@ -41,6 +41,24 @@ const POLL_INTERVAL = 1000; */ const MAX_POLL_ATTEMPTS = 30; +/** + * Computes a SHA-256 hash of a solution string consistently (issue #683). + * Returns a 32-byte Uint8Array. + * + * @param {string} solution + * @returns {Promise} + */ +export async function computeSolutionHash(solution) { + const encoder = new TextEncoder(); + const data = encoder.encode(solution); + if (typeof crypto !== "undefined" && crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hashBuffer); + } + const { createHash } = await import("crypto"); + return new Uint8Array(createHash("sha256").update(solution).digest()); +} + /** * Converts a plain string into a Soroban Bytes ScVal (UTF-8 encoded). * The on-chain contract stores answers/hints as `Bytes`. @@ -260,9 +278,10 @@ export function usePuzzleContract(opts = {}) { /** * Submit an answer for a puzzle. + * Computes solution hash consistently before sending (issue #683). * * @param {number} questionId – The on-chain question_id (u64) - * @param {string} answer – The plain-text answer + * @param {string} answer – The plain-text answer (computed into hash consistently) * @returns {{ correct: boolean, message: string }} */ const submitAnswer = useCallback( @@ -272,12 +291,15 @@ export function usePuzzleContract(opts = {}) { setFeedback(null); try { + // Compute SHA-256 hash of the answer consistently on the frontend (issue #683) + const hashBytes = await computeSolutionHash(answer); + // Build ScVal arguments for the contract's submit_answer fn: // submit_answer(env, caller: Address, question_id: u64, answer: Bytes) -> bool const args = [ Address.fromString(walletAddress || (await ensureWallet())).toScVal(), nativeToScVal(BigInt(questionId), { type: "u64" }), - stringToBytesScVal(answer), + xdr.ScVal.scvBytes(hashBytes), ]; const result = await invokeContract("submit_answer", args); @@ -308,6 +330,52 @@ export function usePuzzleContract(opts = {}) { [invokeContract, walletAddress, ensureWallet] ); + /** + * Complete a puzzle on-chain with solution hash verification (issue #670, #683). + * + * @param {number} puzzleId – The on-chain puzzle_id (u64) + * @param {string} solution – The plain-text solution (hashed before submission) + * @returns {{ correct: boolean, message: string }} + */ + const completePuzzle = useCallback( + async (puzzleId, solution) => { + setSubmitting(true); + setError(null); + setFeedback(null); + + try { + // Frontend computes hash consistently + const hashBytes = await computeSolutionHash(solution); + + const args = [ + Address.fromString(walletAddress || (await ensureWallet())).toScVal(), + nativeToScVal(BigInt(puzzleId), { type: "u64" }), + xdr.ScVal.scvBytes(hashBytes), + ]; + + const result = await invokeContract("complete_puzzle", args); + const isCorrect = scValToNative(result.returnValue); + + const fb = { + correct: isCorrect, + message: isCorrect + ? "🎉 Puzzle completed successfully!" + : "❌ Incorrect puzzle solution. Try again!", + }; + setFeedback(fb); + return { ...fb, submitted: true }; + } catch (err) { + const message = err.message || "Transaction failed"; + setError(message); + setFeedback({ correct: false, message }); + return { correct: false, message, submitted: false }; + } finally { + setSubmitting(false); + } + }, + [invokeContract, walletAddress, ensureWallet] + ); + /** * Request a hint for a puzzle from the smart contract. * @@ -364,6 +432,8 @@ export function usePuzzleContract(opts = {}) { disconnectWallet, refreshWallet, submitAnswer, + completePuzzle, + computeSolutionHash, requestHint, clearFeedback, checkWallet, diff --git a/onchain/contracts/stellar_hunts/src/lib.rs b/onchain/contracts/stellar_hunts/src/lib.rs index 67915c11..1fe25363 100644 --- a/onchain/contracts/stellar_hunts/src/lib.rs +++ b/onchain/contracts/stellar_hunts/src/lib.rs @@ -339,6 +339,10 @@ pub enum Error { InputTooLarge = 18, CapWouldMakeLevelUnreachable = 19, CapBelowExistingIndex = 20, + HashMismatch = 21, + AlreadyCompleted = 22, + RewardTransferFailed = 23, + MissingRewardToken = 24, } // --------------------------------------------------------------------- @@ -534,6 +538,62 @@ impl StellarHunts { .publish((Symbol::new(&env, "question_added"),), (question_id, level)); } + /// Adds a question with a precomputed solution hash (issue #683). + pub fn add_question_with_hash( + env: Env, + level: Levels, + question: Bytes, + hashed_answer: BytesN<32>, + hint: Bytes, + ) -> u64 { + require_admin(&env); + + if question.is_empty() || hint.is_empty() { + panic_with_error!(&env, Error::EmptyField); + } + if question.len() > MAX_QUESTION_LEN || hint.len() > MAX_HINT_LEN { + panic_with_error!(&env, Error::InputTooLarge); + } + + let count: u64 = env + .storage() + .instance() + .get(&DataKey::QuestionCount) + .unwrap_or(0u64); + let question_id = count + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)); + env.storage() + .instance() + .set(&DataKey::QuestionCount, &question_id); + + let q = Question { + question_id, + question: question.clone(), + hashed_answer, + level: level.clone(), + hint: hint.clone(), + version: CURRENT_SCHEMA_VERSION, + }; + env.storage() + .persistent() + .set(&DataKey::Question(question_id), &q); + + let per_level: u32 = get_cap_for_level(&env, &level); + let idx = index_count(&env, &level); + + if idx >= per_level { + panic_with_error!(&env, Error::QuestionPerLevelLimit); + } + + index_append(&env, &level, question_id).unwrap_or_else(|e| panic_with_error!(&env, e)); + + env.events() + .publish((Symbol::new(&env, "question_added"),), (question_id, level)); + + question_id + } + pub fn update_question( env: Env, question_id: u64, @@ -684,6 +744,48 @@ impl StellarHunts { ); } + /// Sets the reward token contract address for reward payouts (issue #680). + pub fn set_reward_token(env: Env, token: Address) { + require_admin(&env); + env.storage().instance().set(&DataKey::RewardToken, &token); + env.events().publish( + (Symbol::new(&env, "reward_token_updated"),), + (token,), + ); + } + + /// Gets the configured reward token contract address (issue #680). + pub fn get_reward_token(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::RewardToken) + } + + /// Sets the default reward token payout amount for puzzle completions (issue #680). + pub fn set_default_reward_amount(env: Env, amount: i128) { + require_admin(&env); + if amount < 0 { + panic_with_error!(&env, Error::InvalidInput); + } + env.storage().instance().set(&DataKey::DefaultRewardAmount, &amount); + } + + /// Sets a puzzle-specific reward token payout amount (issue #680). + pub fn set_puzzle_reward_amount(env: Env, puzzle_id: u64, amount: i128) { + require_admin(&env); + if amount < 0 { + panic_with_error!(&env, Error::InvalidInput); + } + env.storage().instance().set(&DataKey::PuzzleRewardAmount(puzzle_id), &amount); + } + + /// Gets the reward amount for a given puzzle (issue #680). + pub fn get_puzzle_reward_amount(env: Env, puzzle_id: u64) -> i128 { + env.storage() + .instance() + .get(&DataKey::PuzzleRewardAmount(puzzle_id)) + .or_else(|| env.storage().instance().get(&DataKey::DefaultRewardAmount)) + .unwrap_or(0i128) + } + // ----------------------------------------------------------------- // Player actions // ----------------------------------------------------------------- @@ -765,8 +867,15 @@ impl StellarHunts { .checked_add(1) .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)); - let hashed: BytesN<32> = env.crypto().sha256(&answer).into(); - let is_correct = hashed == question.hashed_answer; + let is_correct = if answer.len() == 32 { + let mut arr = [0u8; 32]; + answer.copy_into_slice(&mut arr); + let submitted_hash = BytesN::from_array(&env, &arr); + submitted_hash == question.hashed_answer + } else { + let hashed: BytesN<32> = env.crypto().sha256(&answer).into(); + hashed == question.hashed_answer + }; if is_correct { lp.last_question_index = lp @@ -811,6 +920,190 @@ impl StellarHunts { is_correct } + /// Records puzzle completion on-chain and validates solution hash (issues #670, #683, #680). + /// + /// Validates submitted solution by comparing hash with stored hashed_answer. + /// Rejects hash mismatch with Error::HashMismatch. + /// Rejects duplicate completions with Error::AlreadyCompleted. + /// Emits `puzzle_completed` event with (player, puzzle_id). + /// Integrates reward token payout if reward token is configured. + pub fn complete_puzzle( + env: Env, + caller: Address, + puzzle_id: u64, + solution_hash: BytesN<32>, + ) -> bool { + if env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + { + panic_with_error!(&env, Error::ContractPaused); + } + caller.require_auth(); + + if puzzle_id == 0 { + panic_with_error!(&env, Error::QuestionNotFound); + } + + let key = DataKey::Question(puzzle_id); + let question: Question = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, Error::QuestionNotFound)); + + if env + .storage() + .persistent() + .has(&DataKey::RetiredQuestion(puzzle_id)) + { + panic_with_error!(&env, Error::QuestionRetired); + } + + // Verify submitted solution by comparing hashes (#683) + if solution_hash != question.hashed_answer { + panic_with_error!(&env, Error::HashMismatch); + } + + let completion_key = DataKey::CompletedPuzzle(caller.clone(), puzzle_id); + if env.storage().persistent().has(&completion_key) { + panic_with_error!(&env, Error::AlreadyCompleted); + } + + // Record puzzle completion on-chain (#670) + env.storage().persistent().set(&completion_key, &true); + + // Cross-contract call to token contract for reward payouts (#680) + Self::try_payout_reward(&env, &caller, puzzle_id); + + // Emit event with player address and puzzle id (#670) + env.events().publish( + (Symbol::new(&env, "puzzle_completed"),), + (caller, puzzle_id), + ); + + true + } + + /// Verifies if a solution hash matches a question's stored hash (issue #683). + pub fn verify_solution_hash(env: Env, puzzle_id: u64, solution_hash: BytesN<32>) -> bool { + if puzzle_id == 0 { + panic_with_error!(&env, Error::QuestionNotFound); + } + let key = DataKey::Question(puzzle_id); + let question: Question = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, Error::QuestionNotFound)); + question.hashed_answer == solution_hash + } + + /// Checks if a player has recorded completion for a puzzle (issue #670). + pub fn is_puzzle_completed(env: Env, player: Address, puzzle_id: u64) -> bool { + env.storage() + .persistent() + .has(&DataKey::CompletedPuzzle(player, puzzle_id)) + } + + /// Allows claiming reward payout for an already completed puzzle (issue #680). + pub fn claim_puzzle_reward(env: Env, caller: Address, puzzle_id: u64) { + if env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + { + panic_with_error!(&env, Error::ContractPaused); + } + caller.require_auth(); + + if !Self::is_puzzle_completed(env.clone(), caller.clone(), puzzle_id) { + panic_with_error!(&env, Error::QuestionNotFound); + } + + Self::try_payout_reward(&env, &caller, puzzle_id); + } + + /// Claims an NFT badge reward for a completed puzzle (issue #671). + pub fn claim_puzzle_nft( + env: Env, + caller: Address, + puzzle_id: u64, + puzzle_name: soroban_sdk::String, + difficulty: soroban_sdk::String, + ) { + if env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + { + panic_with_error!(&env, Error::ContractPaused); + } + caller.require_auth(); + + if !Self::is_puzzle_completed(env.clone(), caller.clone(), puzzle_id) { + panic_with_error!(&env, Error::QuestionNotFound); + } + + let nft_contract: Address = env + .storage() + .instance() + .get(&DataKey::NftContract) + .ok_or(Error::MissingNftContract) + .unwrap(); + + stellar_hunts_nft::StellarHuntsNftClient::new(&env, &nft_contract).mint_puzzle_reward_nft( + &env.current_contract_address(), + &caller, + &puzzle_id, + &puzzle_name, + &difficulty, + ); + + env.events().publish( + (Symbol::new(&env, "puzzle_nft_claimed"),), + (caller, puzzle_id), + ); + } + + /// Helper for cross-contract call to token contract for reward payouts (issue #680). + fn try_payout_reward(env: &Env, recipient: &Address, puzzle_id: u64) { + if let Some(token_address) = env + .storage() + .instance() + .get::(&DataKey::RewardToken) + { + let amount: i128 = env + .storage() + .instance() + .get(&DataKey::PuzzleRewardAmount(puzzle_id)) + .or_else(|| env.storage().instance().get(&DataKey::DefaultRewardAmount)) + .unwrap_or(0i128); + + if amount > 0 { + let token_client = soroban_sdk::token::Client::new(env, &token_address); + let contract_address = env.current_contract_address(); + let transfer_result = + token_client.try_transfer(&contract_address, recipient, &amount); + match transfer_result { + Ok(Ok(())) => { + env.events().publish( + (Symbol::new(env, "reward_paid"),), + (recipient.clone(), puzzle_id, amount), + ); + } + _ => { + panic_with_error!(env, Error::RewardTransferFailed); + } + } + } + } + } + pub fn request_hint(env: Env, caller: Address, question_id: u64) -> Bytes { caller.require_auth(); if question_id == 0 { diff --git a/onchain/contracts/stellar_hunts_nft/src/lib.rs b/onchain/contracts/stellar_hunts_nft/src/lib.rs index 66b2a242..65c4d5c0 100644 --- a/onchain/contracts/stellar_hunts_nft/src/lib.rs +++ b/onchain/contracts/stellar_hunts_nft/src/lib.rs @@ -36,6 +36,8 @@ pub enum NftDataKey { /// (a deployment that predates versioning) — see /// `onchain/docs/storage-versioning.md` and issue #453. SchemaVersion, + PuzzleReward(Address, u64), + PuzzleMetadata(Address, u64), } #[contracttype] @@ -45,6 +47,15 @@ pub struct BadgeData { pub minter: Address, } +#[contracttype] +#[derive(Clone, Debug)] +pub struct PuzzleRewardMetadata { + pub puzzle_name: String, + pub difficulty: String, + pub completion_timestamp: u64, + pub minter: Address, +} + // --------------------------------------------------------------------- // Errors // --------------------------------------------------------------------- @@ -60,6 +71,7 @@ pub enum Error { MetadataTooLarge = 5, NotInitialized = 6, ContractPaused = 7, + DuplicateCompletion = 8, } const MAX_BASE_URI_LEN: usize = 200; @@ -275,6 +287,71 @@ impl StellarHuntsNft { env.storage().persistent().get(&key) } + /// Mint a reward NFT when a puzzle is solved (issue #671). + /// + /// Only an authorized minter (the game/hunter contract) may call this, + /// enforced by `minter.require_auth()` and `has_minter_role(minter)`. + /// Records puzzle metadata (name, difficulty, timestamp, minter). + /// Rejects duplicate completions with `Error::DuplicateCompletion`. + pub fn mint_puzzle_reward_nft( + env: Env, + minter: Address, + recipient: Address, + puzzle_id: u64, + puzzle_name: String, + difficulty: String, + ) { + minter.require_auth(); + if env + .storage() + .instance() + .get(&NftDataKey::Paused) + .unwrap_or(false) + { + panic_with_error!(&env, Error::ContractPaused); + } + + if !Self::has_minter_role(env.clone(), minter.clone()) { + panic_with_error!(&env, Error::NotAuthorized); + } + + let reward_key = NftDataKey::PuzzleReward(recipient.clone(), puzzle_id); + if env.storage().persistent().has(&reward_key) { + panic_with_error!(&env, Error::DuplicateCompletion); + } + env.storage().persistent().set(&reward_key, &true); + + let metadata = PuzzleRewardMetadata { + puzzle_name: puzzle_name.clone(), + difficulty: difficulty.clone(), + completion_timestamp: env.ledger().timestamp(), + minter: minter.clone(), + }; + let meta_key = NftDataKey::PuzzleMetadata(recipient.clone(), puzzle_id); + env.storage().persistent().set(&meta_key, &metadata); + + env.events().publish( + (Symbol::new(&env, "puzzle_reward_minted"),), + (recipient, puzzle_id, puzzle_name, difficulty), + ); + } + + pub fn has_puzzle_reward(env: Env, recipient: Address, puzzle_id: u64) -> bool { + env.storage() + .persistent() + .has(&NftDataKey::PuzzleReward(recipient, puzzle_id)) + } + + pub fn get_puzzle_metadata( + env: Env, + recipient: Address, + puzzle_id: u64, + ) -> Option { + env.storage() + .persistent() + .get(&NftDataKey::PuzzleMetadata(recipient, puzzle_id)) + } + // ----------------------------------------------------------------- // Access control — owner / minter management // -----------------------------------------------------------------