CRYPTO::CROSSWORD

trustless puzzle competitions // EVM L2

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"
}
EOF

4. 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 .clues

3. 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 back

Contract 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 order

Constructor 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.