Commit-reveal схема
Как скрыть действие в Solidity через commit-reveal: сначала записать hash, потом раскрыть значение и salt для проверки.
Commit-reveal скрывает выбор до reveal-фазы
Commit-reveal — это двухфазная схема. Сначала пользователь отправляет commitment: hash от секретных данных. Позже он раскрывает исходные данные, а контракт проверяет, что они дают тот же hash.
Так можно скрыть ставку, голос, ход в игре или другое действие от mempool до момента раскрытия.
Пишем скрытое голосование
Создайте файл contracts/CommitRevealVote.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CommitRevealVote {
struct Commit {
bytes32 commitment;
bool revealed;
}
uint256 public immutable commitDeadline;
uint256 public immutable revealDeadline;
uint256 public yesVotes;
uint256 public noVotes;
mapping(address => Commit) public commits;
event Committed(address indexed voter, bytes32 commitment);
event Revealed(address indexed voter, bool support);
error InvalidPhase();
error AlreadyCommitted(address voter);
error NoCommitment(address voter);
error AlreadyRevealed(address voter);
error EmptyCommitment();
error InvalidReveal();
error InvalidDuration();
constructor(uint256 commitDuration, uint256 revealDuration) {
if (commitDuration == 0 || revealDuration == 0) {
revert InvalidDuration();
}
commitDeadline = block.timestamp + commitDuration;
revealDeadline = commitDeadline + revealDuration;
}
function commit(bytes32 commitment) external {
if (block.timestamp >= commitDeadline) {
revert InvalidPhase();
}
if (commitment == bytes32(0)) {
revert EmptyCommitment();
}
if (commits[msg.sender].commitment != bytes32(0)) {
revert AlreadyCommitted(msg.sender);
}
commits[msg.sender] = Commit({
commitment: commitment,
revealed: false
});
emit Committed(msg.sender, commitment);
}
function reveal(bool support, bytes32 salt) external {
if (block.timestamp < commitDeadline || block.timestamp >= revealDeadline) {
revert InvalidPhase();
}
Commit storage userCommit = commits[msg.sender];
if (userCommit.commitment == bytes32(0)) {
revert NoCommitment(msg.sender);
}
if (userCommit.revealed) {
revert AlreadyRevealed(msg.sender);
}
bytes32 expectedCommitment = hashVote(msg.sender, support, salt);
if (userCommit.commitment != expectedCommitment) {
revert InvalidReveal();
}
userCommit.revealed = true;
if (support) {
yesVotes += 1;
} else {
noVotes += 1;
}
emit Revealed(msg.sender, support);
}
function hashVote(address voter, bool support, bytes32 salt) public view returns (bytes32) {
return keccak256(
abi.encode(
address(this),
voter,
support,
salt
)
);
}
function result() external view returns (bool passed) {
return yesVotes > noVotes;
}
}Этот контракт проводит голосование в две фазы. Во время commit-фазы он видит только hash, а во время reveal-фазы проверяет голос и salt.
Как это работает
commit(bytes32 commitment) — сохраняет hash выбора пользователя. По hash нельзя понять, голосовал пользователь “за” или “против”.
reveal(bool support, bytes32 salt) — раскрывает исходные данные. Контракт заново считает hash и сравнивает его с сохранённым commitment.
hashVote(msg.sender, support, salt) — связывает commitment с конкретным адресом. Другой пользователь не сможет раскрыть чужой commitment.
address(this) внутри hash — привязывает commitment к конкретному контракту. Такой же голос и salt нельзя безопасно переиспользовать в другом голосовании.
userCommit.revealed = true — защита от повторного reveal. Без неё один пользователь мог бы посчитать голос несколько раз.
commitDeadline и revealDeadline — разделяют фазы. Во время commit нельзя раскрывать, а после commit-фазы нельзя добавлять новые commitments.
Как посчитать commitment
Frontend должен посчитать hash до вызова commit:
import { ethers } from "ethers";
const voteContract = "0x1111111111111111111111111111111111111111";
const voter = "0x2222222222222222222222222222222222222222";
const support = true;
const salt = ethers.randomBytes(32);
const commitment = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["address", "address", "bool", "bytes32"],
[voteContract, voter, support, salt],
),
);
console.log("Commitment:", commitment);
console.log("Salt:", ethers.hexlify(salt));Сначала пользователь отправляет commit(commitment). После окончания commit-фазы он вызывает reveal(support, salt).
Почему нужен salt
Если hash строить только от support, вариантов всего два: true и false. Любой наблюдатель легко переберёт оба значения и узнает голос.
salt добавляет случайность:
hash(true) -> легко перебрать
hash(true, random bytes32) -> практически невозможно угадатьSalt должен быть случайным и храниться у пользователя до reveal-фазы. Если пользователь потеряет salt, он не сможет раскрыть commitment.
Где применять commit-reveal
Commit-reveal подходит для скрытых ставок, sealed-bid аукционов, голосований, игр с ходами и защиты от фронтраннинга.
Схема не делает данные приватными навсегда. Она только откладывает раскрытие до reveal-фазы. После reveal все исходные параметры становятся публичными.
Для денежных сценариев добавляют deposit или штраф: если пользователь сделал commit, но не раскрыл reveal, его депозит можно частично или полностью забрать.
Частые ошибки
Не добавлять salt → маленькое пространство вариантов легко перебрать, и commitment перестаёт скрывать выбор.
Не включать msg.sender в hash → другой адрес сможет раскрыть чужой commitment с теми же данными.
Разрешить commit и reveal одновременно → наблюдатель сможет увидеть reveal и попытаться вставить свой commit до него. Фазы должны быть разделены.
Что дальше
- Фронтраннинг — как работает и защита — посмотрите, как commit-reveal защищает от mempool-копирования
- Кастомные ошибки — Custom Errors — возвращайте точные причины невалидного reveal
- Events — объявление и emit — записывайте commit и reveal в logs для frontend