Commit-reveal: keeping a secret on a public chain
Everything in storage is public forever. This is how you run a secret ballot anyway.
The voting lesson ended on a warning: a ballot in contract storage is readable by anyone, forever. If people can see the running tally, late voters vote strategically and early voters get targeted. This is the fix.
Commit-reveal splits one action into two transactions:
Phase 1 — commit. You publish only a digest:
digest = sha3_256(choice + secret)
Nobody can invert it, so your choice stays private. But you are bound by it: you cannot later claim a different choice, because no other input produces that same digest.
Phase 2 — reveal. After commits close, you publish the choice and the secret. The contract recomputes the digest and checks it matches what you committed. Only then does your vote count.
The secret is what makes it secret. Without one, a commitment to a choice from a small set is trivially broken: there are only two possible digests for a yes/no vote, so anyone can hash both and read every "hidden" ballot instantly.
The secret must be long and unpredictable — 32 random bytes. And it must be different for every voter, or one revealed secret unmasks everyone who reused it.
Commit-reveal binds; it does not compel. A voter who dislikes the way the reveal phase is going can simply never reveal. Their vote does not count, but they got to make that decision after seeing partial results.
Real systems fix this with a deposit that is forfeited by non-revealers. Know that the plain pattern has this hole before you rely on it for anything where selective abstention is valuable.
Your turn
Build commit-reveal voting. commit(digest) stores one commitment per address. reveal(choice, secret) must recompute sha3_256(choice + secret), reject a mismatch with b"bad_reveal", reject a second reveal, and count the vote. tally() returns yes*1000 + no.
Hints
One way to do it
from stdlib import abi, events, hash, storage
def _uget(key: bytes) -> int:
raw = storage.get(key, b"")
return 0 if raw == b"" else int.from_bytes(raw, "big")
def _uset(key: bytes, value: int) -> None:
v = int(value)
abi.require(v >= 0, b"negative")
if v == 0:
storage.delete(key)
return
storage.set(key, v.to_bytes(max(1, (v.bit_length() + 7) // 8), "big"))
def _flag(key: bytes) -> bool:
return storage.get(key, b"") == b"\x01"
def _set_flag(key: bytes) -> None:
storage.set(key, b"\x01")
K_YES = b"cr:yes"
K_NO = b"cr:no"
def _k_commit(addr: bytes) -> bytes:
return b"cr:commit:" + addr
def _k_revealed(addr: bytes) -> bytes:
return b"cr:revealed:" + addr
def tally() -> int:
return _uget(K_YES) * 1000 + _uget(K_NO)
def commitment_of(addr: bytes) -> bytes:
return storage.get(_k_commit(bytes(addr)), b"")
def commit(digest: bytes) -> bytes:
d = bytes(digest)
abi.require(len(d) == 32, b"bad_digest")
who = abi.caller()
# Overwriting a commitment would let someone re-decide after seeing others.
abi.require(storage.get(_k_commit(who), b"") == b"", b"already_committed")
storage.set(_k_commit(who), d)
events.emit(b"Committed", {"who": who})
return storage.get(_k_commit(who), b"")
def reveal(choice: bytes, secret: bytes) -> int:
who = abi.caller()
stored = storage.get(_k_commit(who), b"")
abi.require(stored != b"", b"no_commitment")
abi.require(not _flag(_k_revealed(who)), b"already_revealed")
# The commitment is only binding if we recompute it exactly as promised.
got = hash.sha3_256(bytes(choice) + bytes(secret))
abi.require(got == stored, b"bad_reveal")
_set_flag(_k_revealed(who))
if bytes(choice) == b"yes":
_uset(K_YES, _uget(K_YES) + 1)
else:
_uset(K_NO, _uget(K_NO) + 1)
events.emit(b"Revealed", {"who": who, "choice": bytes(choice)})
return _uget(K_YES) * 1000 + _uget(K_NO)
Claim your 10 ANM
Finish this lesson and claim 10 ANM, once per address. Paid from the Animica treasury in batches — allow a few minutes.
Stuck? Ask
Answered by Animica's own free inference network. It is donated GPU capacity, so give it 20-30 seconds.