Skip to content
Merged
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
74 changes: 72 additions & 2 deletions frontend/hooks/usePuzzleContract.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>}
*/
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`.
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -364,6 +432,8 @@ export function usePuzzleContract(opts = {}) {
disconnectWallet,
refreshWallet,
submitAnswer,
completePuzzle,
computeSolutionHash,
requestHint,
clearFeedback,
checkWallet,
Expand Down
Loading
Loading