CRYPTO::CROSSWORD

trustless puzzle competitions // EVM L2

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
# this is a simple 3x5 grid

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), commitHours, revealHours, claimHours
# 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 24 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. If you don't reveal in time, you lose your stake!

# 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

# verify it worked
cast call $CONTRACT "getPhase()(string)" --rpc-url $RPC  # should be CLAIMING

claim prize (player)

After creator reveals, prove your answer was correct to win the prize.

How Claiming Works

1. Submit your claim — Prove you had the correct answer by revealing your answer + salt

2. Wait for claim window to close — Other players with earlier commits might also claim

3. Collect prize — The earliest correct claimant wins. Click "Finalize" to transfer the prize.

⚡ New contracts: If you claim after the claim window closes, the prize is auto-transferred if you're the winner.

# Step 1: Submit your claim (during claim window)
cast call $CONTRACT "getPhase()(string)" --rpc-url $RPC  # should be CLAIMING

export ANSWER=$(jq -r '.answer' my-claim.json)
export SALT=$(jq -r '.salt' my-claim.json)

cast send $CONTRACT "claim(string,bytes32)" "$ANSWER" "$SALT" \
  --private-key $PRIVATE_KEY \
  --rpc-url $RPC

# Step 2: Wait for claim window to close, then finalize
cast call $CONTRACT "getPhase()(string)" --rpc-url $RPC  # wait for FINALIZE_READY

# Anyone can call this - prize goes to earliest correct claimant
cast send $CONTRACT "finalizePrize()" \
  --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    → creator reveals solution
CLAIMING (24h)    → players prove their answers
FINALIZE_READY    → anyone calls finalizePrize()
COMPLETED         → winner has prize

If creator doesn't reveal:
REFUND_AVAILABLE  → players call claimRefund()

Contract Functions

# Read
getPhase()                    → current phase
prizePool()                   → prize in wei
entryFee()                    → entry cost in wei
hasCommitted(address)         → did address submit?
getCurrentWinner()            → (address, timestamp)

# Player actions
commit(bytes32 hash) payable  → submit hashed answer
claim(string answer, bytes32 salt) → prove answer
claimRefund()                 → get refund if abandoned

# Creator actions
reveal(string solution)       → reveal the answer
withdrawUnclaimedPrize()      → claim unclaimed prize

# Anyone
finalizePrize()               → send prize to winner

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)
    24                # uint256: commit phase hours
    24                # uint256: reveal grace hours  
    24                # uint256: claim window 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

Save solution.json — Creators need it to reveal

Earliest commit wins — If tied, first timestamp wins

Reveal on time — Or lose your stake forever

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"

~450 lines of Solidity. No external dependencies. No admin keys. No upgrades.