protocol overview
A trustless crossword puzzle competition with random winner selection.
How It Works
1. Commit Phase — Players solve the puzzle and submit hashed answers with entry fee
2. Reveal — Creator reveals solution, a random player is selected as potential winner
3. Claim — Selected player proves their answer was correct to receive prize
4. Forfeit/Timeout — If selected player had wrong answer, they forfeit or timeout (1h), then another player is randomly selected
5. No Winner — If all players had wrong answers, everyone gets refunded
Key Features
• Random Winner Selection — Prevents Sybil attacks by creator
• Creator Fees on Reveal — Creator only gets paid when they reveal
• Configurable Fee % — 0-50% of entry fees go to creator
• Automatic Refunds — If creator fails to reveal, anyone can trigger refunds
setup
Install required tools. All commands tested on macOS/Linux.
# install foundry (provides cast and forge) curl -L https://foundry.paradigm.xyz | bash source ~/.bashrc # or restart terminal foundryup # verify installation cast --version forge --version # other tools (usually pre-installed) which curl jq openssl # should all exist
Get test ETH: Use a faucet for your chosen testnet:
Base Sepolia: https://faucet.quicknode.com/base/sepolia Polygon Amoy: https://faucet.polygon.technology
create a puzzle
Deploy your own crossword puzzle. Takes ~5 minutes.
1. Set your config
# your wallet private key (keep secret!) export PRIVATE_KEY="0x..." # network RPC (pick one) export RPC="https://sepolia.base.org" # Base testnet (recommended) # export RPC="https://mainnet.base.org" # Base mainnet (real money) # export RPC="https://rpc-amoy.polygon.technology" # Polygon testnet # pinata JWT for IPFS uploads (free at pinata.cloud) export PINATA_JWT="eyJ..."
2. Create your solution
# format: {"slotID": "ANSWER", ...}
# - slotID = number + A (across) or D (down)
# - answers = UPPERCASE only
# - keys MUST be sorted: 1A, 1D, 2A, 2D, 10A...
cat > solution.json << 'EOF'
{"1A":"HELLO","1D":"HI","2D":"ELF","3A":"LOW"}
EOF
# generate solution commitment (this is public)
export SOLUTION=$(cat solution.json)
export COMMITMENT=$(cast keccak "$SOLUTION")
echo "Commitment: $COMMITMENT"3. Create puzzle metadata
# grid format: cells[row][col], isBlack=true for blocked squares
cat > puzzle.json << EOF
{
"title": "My First Puzzle",
"description": "A simple test crossword",
"grid": {
"rows": 3,
"cols": 5,
"cells": [
[{"isBlack":false},{"isBlack":false},{"isBlack":false},{"isBlack":false},{"isBlack":false}],
[{"isBlack":false},{"isBlack":true},{"isBlack":false},{"isBlack":true},{"isBlack":false}],
[{"isBlack":false},{"isBlack":true},{"isBlack":false},{"isBlack":true},{"isBlack":false}]
]
},
"clues": {
"1A": {"clue": "Greeting", "length": 5},
"1D": {"clue": "Short greeting", "length": 2},
"2D": {"clue": "Santa's helper", "length": 3},
"3A": {"clue": "Opposite of high", "length": 3}
},
"solutionCommitment": "$COMMITMENT"
}
EOF4. Upload to IPFS
# upload puzzle metadata to IPFS via Pinata export IPFS_HASH=$(curl -s -X POST "https://api.pinata.cloud/pinning/pinJSONToIPFS" \ -H "Authorization: Bearer $PINATA_JWT" \ -H "Content-Type: application/json" \ -d @puzzle.json | jq -r '.IpfsHash') export IPFS_URI="ipfs://$IPFS_HASH" echo "Uploaded to: $IPFS_URI" # verify it works curl -s "https://gateway.pinata.cloud/ipfs/$IPFS_HASH" | jq .title
5. Deploy the contract
# download contract source curl -sL "https://cryptocrossword.org/CryptoCrossword.sol" > CryptoCrossword.sol # deploy with forge # args: commitment, ipfsURI, entryFee(wei), creatorFee%, commitHours, revealHours # value: your stake (you lose this if you don't reveal!) forge create CryptoCrossword.sol:CryptoCrossword \ --rpc-url "$RPC" \ --private-key "$PRIVATE_KEY" \ --value 0.001ether \ --constructor-args "$COMMITMENT" "$IPFS_URI" 1000000000000000 10 24 24 # note the "Deployed to:" address from output export CONTRACT="0x...paste_deployed_address_here..."
6. Share your puzzle
# players can solve at: echo "https://cryptocrossword.org/solve/$CONTRACT" # IMPORTANT: keep solution.json safe - you need it to reveal! echo "SAVE THIS FILE: solution.json"
play a puzzle
Submit your answer to a puzzle.
1. Set puzzle address
export CONTRACT="0x...puzzle_contract_address..." export RPC="https://sepolia.base.org" export PRIVATE_KEY="0x...your_private_key..."
2. Check puzzle info
# check phase (must be COMMITTING)
cast call $CONTRACT "getPhase()(string)" --rpc-url $RPC
# check entry fee
cast call $CONTRACT "entryFee()(uint256)" --rpc-url $RPC
# check prize pool
cast call $CONTRACT "prizePool()(uint256)" --rpc-url $RPC
# get puzzle clues
IPFS=$(cast call $CONTRACT "puzzleDataURI()(string)" --rpc-url $RPC)
curl -s "https://gateway.pinata.cloud/ipfs/${IPFS#ipfs://}" | jq .clues3. Submit your answer
# your answer (same format as solution - sorted keys, uppercase)
export ANSWER='{"1A":"HELLO","1D":"HI","2D":"ELF","3A":"LOW"}'
# generate random salt (KEEP THIS SAFE!)
export SALT="0x$(openssl rand -hex 32)"
# compute commit hash
export HASH=$(cast keccak "$ANSWER$SALT")
# get entry fee
ENTRY_FEE=$(cast call $CONTRACT "entryFee()(uint256)" --rpc-url $RPC)
# submit answer
cast send $CONTRACT "commit(bytes32)" $HASH \
--value $ENTRY_FEE \
--private-key $PRIVATE_KEY \
--rpc-url $RPC
# SAVE THESE! You need them to claim the prize
cat > my-claim.json << EOF
{
"answer": $ANSWER,
"salt": "$SALT",
"hash": "$HASH",
"contract": "$CONTRACT"
}
EOF
echo "SAVED: my-claim.json - DO NOT LOSE THIS FILE!"reveal solution (creator only)
After commit phase ends, reveal your solution. A random player is selected as the potential winner and your creator fee is paid.
# check phase (must be REVEAL_PENDING) cast call $CONTRACT "getPhase()(string)" --rpc-url $RPC # reveal using your saved solution.json export SOLUTION=$(cat solution.json) cast send $CONTRACT "reveal(string)" "$SOLUTION" \ --private-key $PRIVATE_KEY \ --rpc-url $RPC # check who was selected as potential winner cast call $CONTRACT "getWinner()(address)" --rpc-url $RPC # verify phase is now WINNER_CLAIM cast call $CONTRACT "getPhase()(string)" --rpc-url $RPC
What Happens on Reveal
• Solution is verified against your original commitment
• A random player is selected as the potential winner
• Your creator fee is paid to you immediately
• Selected player must prove their answer was correct to claim
claim prize (selected winner)
If you are selected as the winner, prove your answer was correct to claim the prize.
# check if you are the selected winner cast call $CONTRACT "getWinner()(address)" --rpc-url $RPC # if you are selected, claim with your saved answer and salt export ANSWER=$(jq -r '.answer' my-claim.json) export SALT=$(jq -r '.salt' my-claim.json) cast send $CONTRACT "claimPrize(string,bytes32)" "$ANSWER" "$SALT" \ --private-key $PRIVATE_KEY \ --rpc-url $RPC
What If You Had the Wrong Answer?
If you are selected but your answer was wrong:
• Call forfeitSelection() to immediately allow another player to be selected
• Or do nothing — after 1 hour, anyone can call reselectWinner()
• A new random player will be selected from those who haven't tried yet
• If all players had wrong answers, everyone gets their pool portion refunded
# if you had wrong answer, forfeit your selection cast send $CONTRACT "forfeitSelection()" \ --private-key $PRIVATE_KEY \ --rpc-url $RPC # or anyone can trigger reselection after 1 hour timeout cast send $CONTRACT "reselectWinner()" \ --private-key $PRIVATE_KEY \ --rpc-url $RPC
reference
Networks
# Testnets (free) Base Sepolia: https://sepolia.base.org Polygon Amoy: https://rpc-amoy.polygon.technology # Mainnets (real money) Base: https://mainnet.base.org Polygon: https://polygon-rpc.com Arbitrum: https://arb1.arbitrum.io/rpc
Timeline
COMMITTING (24h) → players submit hashed answers
REVEAL_PENDING → waiting for creator to reveal solution
WINNER_CLAIM → random player selected, must prove answer
• if correct: receives prize → COMPLETED
• if wrong: forfeits or times out (1h)
• new player selected, repeat
• if all wrong: everyone refunded
COMPLETED → prize claimed by correct winner
If creator doesn't reveal:
REFUND_AVAILABLE → anyone calls processRefunds()
REFUNDED → all players received entry fees backContract Functions
# Read getPhase() → current phase prizePool() → prize in wei entryFee() → entry cost in wei creatorFeePercent() → creator's fee % (0-50) hasCommitted(address) → did address submit? getWinner() → selected winner address # Player actions commit(bytes32 hash) payable → submit hashed answer claimPrize(string answer, bytes32 salt) → prove answer & claim forfeitSelection() → forfeit if you're selected but wrong reselectWinner() → trigger new selection (after timeout) # Creator actions reveal(string solution) → reveal & select winner # Anyone processRefunds() → refund all if creator abandoned
Solution Format
# JSON object with slot IDs as keys
# Keys: number + A (across) or D (down)
# Values: UPPERCASE letters only
# Keys MUST be sorted
✓ {"1A":"HELLO","1D":"HI","2A":"WORLD"}
✗ {"1d":"hi","1A":"hello"} # wrong case, wrong orderConstructor Arguments
forge create CryptoCrossword.sol:CryptoCrossword \
--constructor-args \
"0x..." # bytes32: keccak256(solution_json)
"ipfs://Qm..." # string: puzzle data URI
1000000000000000 # uint256: entry fee in wei (0.001 ETH)
10 # uint256: creator fee % (0-50)
24 # uint256: commit phase hours
24 # uint256: reveal grace hours
--value 0.01ether # your stake (required, lost if no reveal)tips
• Test on testnet first — Base Sepolia is free
• Save your salt! — Without it you cannot claim if selected
• Save solution.json — Creators need it to reveal
• Random selection is fair — Each player has equal chance
• Reveal on time — Or lose your stake forever
• Creator fee is paid on reveal — Not before
contract source
The contract is open source (MIT license). Review it before using.
# view/download source curl -sL "https://cryptocrossword.org/CryptoCrossword.sol" # or view in browser open "https://cryptocrossword.org/CryptoCrossword.sol"
~400 lines of Solidity. No external dependencies. No admin keys. No upgrades.