TL;DR: Commit-Reveal Scheme in Solidity
- Front-running attacks exploit the public nature of blockchain mempools to see and copy profitable transactions before they're executed.
- Commit-reveal scheme uses cryptographic hashing to hide player moves during a commit phase, then reveals them in a separate phase.
- Two-phase process: Players submit
keccak256(move + secret)during commit, then reveal the actual move and secret for verification. - Essential for fair games: Prevents attackers from seeing moves in advance and ensures all players compete on equal terms.
- Best practices: Use strong random secrets, enforce time deadlines, and follow Checks-Effects-Interactions pattern.
1. The Front-Running Problem: When Transparency Becomes a Weakness
Blockchain's transparency is both its greatest strength and a critical vulnerability. When you submit for example a transaction to play a dice game or make a move in rock-paper-scissors, that transaction sits in the public mempool before being mined.
This creates an exploit opportunity called front-running:
- Alice submits:
play(4)to guess 4 in a dice game - Bob monitors mempool: Sees Alice's transaction with guess = 4
- Bob simulates: Runs the game logic locally to see if 4 wins
- If Alice wins: Bob copies her transaction with a higher gas fee
- Bob gets priority: Miners process Bob's transaction first, Bob wins the prize
The core issue: Alice's "hidden information" isn't actually hidden. The moment her transaction enters the mempool, it becomes public knowledge.
This breaks any game requiring hidden information:
- Dice games: Attackers copy winning guesses
- Rock-Paper-Scissors: See opponent's move before playing
- Sealed auctions: Copy and slightly outbid others
- Trivia contests: Copy correct answers with higher gas
2. The Commit-Reveal Solution: Cryptographic Hide and Seek
The commit-reveal scheme solves front-running by creating temporary privacy on a public blockchain. It works in two phases:
Phase 1: Commit (Hide Your Move)
Instead of submitting your actual move, you submit a cryptographic commitment:
// Don't submit this:
play(4) // Everyone can see you guessed 4
// Submit this instead:
bytes32 commitment = keccak256(abi.encodePacked(4, secret));
commit(commitment) // Nobody knows what's inside the hash
Key components:
- Your move: The actual guess/choice (e.g., 4)
- Secret salt: A random value only you know
- Hash function:
keccak256creates an irreversible commitment
Phase 2: Reveal (Show Your Hand)
After the commit phase ends, you reveal your original inputs:
reveal(4, secret) // Now submit the actual move and secret
The contract verifies your reveal matches your commitment:
bytes32 reconstructed = keccak256(abi.encodePacked(4, secret));
require(reconstructed == storedCommitment, "Invalid reveal");
Why this works: By the time moves are revealed, it's too late for attackers to submit their own commitments. The commit phase is over.
3. Core Implementation Pattern
The commit-reveal scheme follows a standard two-phase contract structure that can be adapted for various applications.
Basic Contract Structure
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CommitRevealBase {
uint256 public commitDeadline;
uint256 public revealDeadline;
uint256 public constant STAKE_AMOUNT = 0.01 ether;
uint256 public constant PRIZE_AMOUNT = 0.02 ether;
struct Commitment {
bytes32 commitmentHash;
uint256 commitTime;
bool revealed;
}
mapping(address => Commitment) public commitments;
event Committed(address indexed user, bytes32 commitment);
event Revealed(address indexed user, uint256 guess, uint256 result, bool won);
// Custom errors for gas efficiency
error CommitPhaseClosed();
error AlreadyCommitted();
error InsufficientStake();
error TransferFailed();
constructor(uint256 commitDuration, uint256 revealDuration) {
commitDeadline = block.timestamp + commitDuration;
revealDeadline = commitDeadline + revealDuration;
}
function commit(bytes32 _commitment) external payable {
// === CHECKS ===
// All validations happen first
if (block.timestamp >= commitDeadline) revert CommitPhaseClosed();
if (commitments[msg.sender].commitTime != 0) revert AlreadyCommitted();
if (msg.value != STAKE_AMOUNT) revert InsufficientStake();
// === EFFECTS ===
// State changes happen only after all checks pass
commitments[msg.sender] = Commitment({
commitmentHash: _commitment,
commitTime: block.timestamp,
revealed: false
});
// === INTERACTIONS ===
// External calls and events happen last
emit Committed(msg.sender, _commitment);
}
}
Why CEI matters: This pattern prevents reentrancy attacks by ensuring state changes occur before external interactions that could call back into the contract.
Why custom errors matter: Using if + revert + custom errors instead of require with strings provides significant gas savings (~200-300 gas per revert) and better error handling. Custom errors are also more descriptive and can include parameters for debugging.
โ ๏ธ Critical Security Note: The commitment hash must be generated off-chain (we'll show how in Section 4). If you generate the commitment on-chain, both the secret and guess will be immediately visible in the transaction data, defeating the entire purpose of the commit-reveal scheme!
Generic Reveal Function
// Additional custom errors for reveal function
error CommitPhaseNotOver();
error RevealPhaseOver();
error NoCommitmentFound();
error AlreadyRevealed();
error InvalidReveal();
function reveal(uint256 _guess, bytes32 _secret) external {
// === CHECKS ===
if (block.timestamp < commitDeadline) revert CommitPhaseNotOver();
if (block.timestamp >= revealDeadline) revert RevealPhaseOver();
Commitment storage commitment = commitments[msg.sender];
if (commitment.commitTime == 0) revert NoCommitmentFound();
if (commitment.revealed) revert AlreadyRevealed();
// Verify the reveal matches the commitment
bytes32 hash = keccak256(abi.encodePacked(_guess, _secret));
if (hash != commitment.commitmentHash) revert InvalidReveal();
// === EFFECTS ===
// Critical: Mark as revealed FIRST to prevent reentrancy
commitment.revealed = true;
// โ ๏ธ RANDOMNESS WARNING โ ๏ธ
// This randomness method is NOT secure for production!
// Miners can manipulate block.timestamp to influence outcomes
uint256 result = (uint256(keccak256(abi.encodePacked(
block.timestamp, msg.sender, _secret
))) % 6) + 1;
bool won = (_guess == result);
// === INTERACTIONS ===
if (won) {
// Use .call for Ether transfers (safer than .transfer)
(bool sent, ) = msg.sender.call{value: PRIZE_AMOUNT}("");
if (!sent) revert TransferFailed();
}
emit Revealed(msg.sender, _guess, result, won);
}
๐ฅ Critical Security Note: The randomness generation shown above is vulnerable to miner manipulation. For production applications handling real value, use Chainlink VRF or similar verifiable random functions.
4. Frontend Integration: Managing Secrets Securely
The client-side application must handle secret generation and storage:
Generating Cryptographically Strong Secrets
// DON'T use Math.random() for production!
function generateSecret() {
// Use cryptographically secure random generation
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
return "0x" + Array.from(array, byte => byte.toString(16).padStart(2, "0")).join("");
}
// Create commitment
function createCommitment(guess, secret) {
return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "bytes32"], [guess, secret]));
}
Managing User State
// Store user's commitment data
function saveCommitment(guess, secret, commitment) {
const data = {
guess,
secret,
commitment,
timestamp: Date.now(),
};
localStorage.setItem("diceCommitment", JSON.stringify(data));
// โ ๏ธ Security warning: localStorage is vulnerable to XSS attacks
// For production: consider encrypted storage or warn users about browser data
}
// Retrieve for reveal phase
function getCommitment() {
const data = localStorage.getItem("diceCommitment");
return data ? JSON.parse(data) : null;
}
// UX Enhancement: Auto-reveal with user confirmation
async function autoReveal() {
const commitment = getCommitment();
if (!commitment) return;
// Check if reveal phase is active
const now = Date.now() / 1000;
const contract = getContract();
const revealDeadline = await contract.revealDeadline();
if (now < revealDeadline) {
// Prompt user to complete reveal
const shouldReveal = confirm("Ready to reveal your move? You have a pending commitment.");
if (shouldReveal) {
await revealMove(commitment.guess, commitment.secret);
}
}
}
UX Challenges & Solutions
The Two-Transaction Problem: Commit-reveal inherently requires users to make two separate transactions, creating friction:
Problems:
- Higher gas costs (2x transactions)
- Increased cognitive load
- Risk of users forgetting to reveal
Solutions:
- Clear UI guidance: Step-by-step progress indicators
- Automated reminders: Browser notifications when reveal window opens
- State persistence: Save commitment data to guide users back
- Gas estimation: Show total cost upfront (commit + reveal)
5. Security Best Practices & Common Pitfalls
Time-Lock Enforcement is Critical
// Always enforce deadlines strictly
if (block.timestamp >= commitDeadline) revert CommitPhaseClosed();
if (block.timestamp < commitDeadline) revert CommitPhaseNotOver();
if (block.timestamp >= revealDeadline) revert RevealPhaseOver();
Handle Unrevealed Commitments
Players might commit but never reveal. Design for this systematically:
// Track game state and handle edge cases
enum GameState { Committing, Revealing, Finished, Cancelled }
GameState public gameState;
mapping(address => bool) public hasRevealed;
address[] public allPlayers;
uint256 public revealedCount;
function handleUnrevealedCommitments() external {
require(block.timestamp > revealDeadline, "Reveal period active");
require(gameState == GameState.Revealing, "Not in reveal phase");
if (revealedCount == 0) {
// Nobody revealed - refund all stakes
gameState = GameState.Cancelled;
for (uint i = 0; i < allPlayers.length; i++) {
_refundStake(allPlayers[i]);
}
} else if (revealedCount < allPlayers.length) {
// Some didn't reveal - forfeit their stakes to prize pool
gameState = GameState.Finished;
_distributePrizes();
}
}
// Internal helper functions
function _refundStake(address player) internal {
(bool sent, ) = player.call{value: STAKE_AMOUNT}("");
require(sent, "Refund failed");
}
function _distributePrizes() internal {
// Prize distribution logic - implementation depends on game rules
// Example: distribute to revealed players proportionally
}
// Emergency functions for edge cases
function emergencyWithdraw() external {
require(block.timestamp > revealDeadline + 7 days, "Too early");
require(!hasRevealed[msg.sender], "Already revealed");
// Allow unrevealed players to claim stakes after extended period
Commitment storage commitment = commitments[msg.sender];
require(commitment.commitTime != 0, "No commitment");
// Partial refund (minus penalty)
uint256 refund = STAKE_AMOUNT * 80 / 100; // 80% refund
commitment.commitTime = 0; // Mark as withdrawn
(bool sent, ) = msg.sender.call{value: refund}("");
require(sent, "Transfer failed");
}
Error Handling Strategies
// Custom errors with parameters for better debugging
error InsufficientStake(uint256 provided, uint256 required);
error ExcessPayment(uint256 excess);
function commit(bytes32 _commitment) external payable {
if (block.timestamp >= commitDeadline) revert CommitPhaseClosed();
if (commitments[msg.sender].commitTime != 0) revert AlreadyCommitted();
// Handle payment validation with detailed error information
if (msg.value < STAKE_AMOUNT) {
revert InsufficientStake(msg.value, STAKE_AMOUNT);
} else if (msg.value > STAKE_AMOUNT) {
// Refund excess payment
uint256 excess = msg.value - STAKE_AMOUNT;
(bool sent, ) = msg.sender.call{value: excess}("");
if (!sent) revert TransferFailed();
revert ExcessPayment(excess);
}
// Continue with commit logic...
}
Salt Security Requirements
- Strong randomness: Use
window.crypto.getRandomValues()in browsers - Sufficient entropy: At least 32 bytes of random data
- Unique per commitment: Never reuse the same secret
- Secure storage: Protect secrets between commit and reveal phases
Gas Optimization Tips
// Pack struct efficiently to save storage slots
struct Commitment {
bytes32 solutionHash; // 32 bytes (slot 1)
uint32 commitTime; // 4 bytes
bool revealed; // 1 byte
// Total: 37 bytes fits in 2 storage slots vs 3 with uint256
}
// Use constants for repeated values
uint256 public constant STAKE_AMOUNT = 0.01 ether;
uint256 public constant MIN_COMMIT_DURATION = 1 hours;
mapping(address => Commitment) public commitments;
// Batch operations when possible
// Additional custom errors for batch operations
error LengthMismatch();
error IncorrectTotalStake();
function batchCommit(bytes32[] calldata commitmentHashes, address[] calldata players)
external payable
{
if (commitmentHashes.length != players.length) revert LengthMismatch();
if (msg.value != STAKE_AMOUNT * players.length) revert IncorrectTotalStake();
for (uint256 i = 0; i < commitmentHashes.length; i++) {
// Individual commit logic without redundant checks
commitments[players[i]] = Commitment({
solutionHash: commitmentHashes[i],
commitTime: uint32(block.timestamp),
revealed: false
});
}
}
Storage Optimization Benefits:
- 37 bytes in optimized struct vs 65 bytes in naive version
- Saves ~43% storage costs per commitment
- Example (Ethereum mainnet): For 1000 players it could represent ~$125โ250 saved (at 5โ10 gwei). Actual savings vary with gas and ETH price.
6. Advanced Patterns & Variations
Multi-Player Games
For games with multiple players, wait for all commits before revealing:
uint256 public playerCount;
uint256 public commitCount;
mapping(address => bool) public hasCommitted;
function commit(bytes32 _commitment) external payable {
if (hasCommitted[msg.sender]) revert AlreadyCommitted();
// ... standard commit logic ...
commitCount++;
hasCommitted[msg.sender] = true;
// Auto-advance phase when all players commit
if (commitCount == playerCount) {
commitDeadline = block.timestamp;
}
}
Batch Processing
For large-scale applications, consider batch reveal processing:
function batchReveal(
uint256[] calldata guesses,
bytes32[] calldata secrets,
address[] calldata players
) external {
require(guesses.length == secrets.length, "Array length mismatch");
require(guesses.length == players.length, "Array length mismatch");
for (uint256 i = 0; i < guesses.length; i++) {
_processReveal(players[i], guesses[i], secrets[i]);
}
}
7. Development Environment Setup
Quick Start: Browser-Based Development
Remix IDE (recommended for beginners):
- Zero installation required
- Built-in compiler and debugger
- Deploy directly to testnets
- Perfect for learning and prototyping
// Always lock pragma version for security
pragma solidity ^0.8.20; // โ
Good: specific version
// pragma solidity >=0.8.0; // โ Bad: floating pragma
Production Development
Hardhat (JavaScript/TypeScript ecosystem):
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
Foundry (Rust-based, faster compilation):
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init my-commit-reveal-game
Scaffold-ETH 2 (Modern full-stack dapp toolkit):
npx create-eth@latest commit-reveal-game
cd commit-reveal-game
And then the 3 magic commands to start the chain, deploy the contract and start the frontend (in 3 different terminals):
yarn chain
yarn deploy
yarn start
Key benefits for this guide:
- Out-of-the-box local chain, deploy scripts, and UI components
- First-class custom hooks for reading/writing to contracts (wagmi/viem wrappers)
- Easy place to add your contract at
packages/hardhat/contracts/CommitReveal.sol - Next.js frontend ready to wire
commit()andreveal()in Debug page for quick iteration.
Key differences:
- Remix: Browser-based, instant setup, great for learning
- Hardhat: Mature JS ecosystem, extensive plugin support
- Foundry: Faster compilation, Solidity-native testing, gas optimization tools
- Scaffold-ETH 2: An open-source, up-to-date toolkit for building dapps on EVM chains with different extensions (starter kits)
8. Real-World Applications Beyond Games
Sealed-Bid Auctions
// Bidders commit to bids without revealing amounts
function commitBid(bytes32 _bidCommitment) external payable {
// Require deposit to prevent spam
require(msg.value >= minimumDeposit, "Insufficient deposit");
commitments[msg.sender] = _bidCommitment;
}
function revealBid(uint256 _bidAmount, bytes32 _secret) external {
bytes32 hash = keccak256(abi.encodePacked(_bidAmount, _secret));
require(hash == commitments[msg.sender], "Invalid bid reveal");
// Process bid logic...
}
Voting Systems
// Voters commit to choices without revealing preferences
function commitVote(bytes32 _voteCommitment) external {
require(isEligibleVoter[msg.sender], "Not eligible to vote");
commitments[msg.sender] = _voteCommitment;
}
function revealVote(uint256 _choice, bytes32 _secret) external {
bytes32 hash = keccak256(abi.encodePacked(_choice, _secret));
require(hash == commitments[msg.sender], "Invalid vote reveal");
votes[_choice]++;
}
9. Testing Your Implementation
Key Test Scenarios
contract CommitRevealTest {
function testBasicFlow() public {
// 1. Multiple players commit
// 2. Verify commits are stored correctly
// 3. Reveal phase processes correctly
// 4. Winners receive prizes
}
function testFrontRunningPrevention() public {
// 1. Player A commits winning move
// 2. Player B tries to copy (should be impossible)
// 3. Verify Player B cannot determine Player A's move
}
function testTimeEnforcement() public {
// 1. Test commits rejected after deadline
// 2. Test reveals rejected before commit deadline
// 3. Test reveals rejected after reveal deadline
}
function testInvalidReveals() public {
// 1. Test wrong guess/secret combination
// 2. Test revealing without committing
// 3. Test double reveals
}
}
10. Computer Science Context: Two-Phase Commit Connection
The commit-reveal scheme shares conceptual DNA with the Two-Phase Commit (2PC) protocol from distributed systems:
Distributed Systems Parallel
Traditional 2PC (Database Systems):
- Prepare Phase: Coordinator asks all nodes "Are you ready to commit?"
- Commit Phase: If all agree, coordinator sends "Commit!" to all nodes
Blockchain Commit-Reveal:
- Commit Phase: All players submit cryptographic "promises" to their moves
- Reveal Phase: Players execute their promised moves simultaneously
Why This Matters
Understanding this connection helps developers:
- Reason about coordination: Both solve consensus problems in distributed environments
- Handle failure modes: What happens when participants don't respond?
- Design atomic operations: Ensure all-or-nothing execution across multiple parties
// Atomic game state transition - all reveals or none
function batchReveal(RevealData[] calldata reveals) external {
// Verify ALL reveals before changing ANY state
for (uint i = 0; i < reveals.length; i++) {
require(verifyReveal(reveals[i]), "Invalid reveal");
}
// Only after all verifications pass, update state
for (uint i = 0; i < reveals.length; i++) {
processReveal(reveals[i]);
}
}
This isn't just a "Solidity trick" - it's applying fundamental distributed systems principles to blockchain coordination problems.
11. Alternatives to Commit-Reveal
While commit-reveal is the most common solution, other approaches exist:
Private Mempools
Services like Flashbots allow transactions to bypass the public mempool:
- Pros: Simpler than commit-reveal, no two-phase requirement
- Cons: Introduces centralization, requires external service
Zero-Knowledge Proofs
Advanced cryptographic techniques for hiding information:
- Pros: Can hide complex game state, not just single moves
- Cons: Much more complex to implement, higher gas costs
Threshold Cryptography
Multi-party computation for generating randomness:
- Pros: Decentralized randomness generation
- Cons: Requires multiple participants, complex coordination
12. Conclusion: Building Fair Blockchain Games
The commit-reveal scheme is essential for any blockchain application requiring hidden information. While it adds complexity with its two-phase approach, it's the most practical solution for preventing front-running attacks.
Key takeaways:
- Always use cryptographically strong secrets
- Enforce time deadlines strictly
- Design for unrevealed commitments
- Test thoroughly with adversarial scenarios
- Consider user experience in the two-transaction flow
The path to mastery: Theory is just the beginning. The best way to understand commit-reveal is to implement it. Start with a simple dice game, then expand to more complex applications.
Ready to build? Try the Dice Game Challenge on Speedrun Ethereum!
Want to learn more about blockchain security? Read our Flash Loan Exploits Guide!