Commit-Reveal Scheme in Solidity
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:
keccak256
creates 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!
Want to learn more about blockchain security? Read our Flash Loan Exploits Guide!