Commit-reveal authorization for an EVM account using one-time hash secrets under a Merkle root. This document is the normative reference for both the Solidity contract and the Rust tooling; the two implementations must produce byte-identical hashes for every structure defined here.
H(x)is keccak256.enc(...)is Solidityabi.encodeof the listed values in order. Neverabi.encodePacked:abi.encodepads every value to 32-byte words, so the encoding is injective for the fixed-arity tuples used here.- All domain tags are 32-byte constants,
TAG_x = keccak256("QCA/v1/<name>"). Every hash in the protocol has a distinct tag as its first field, so a hash computed in one role can never be replayed in another (leaf vs node vs commitment).
| Tag | Preimage string | Used for |
|---|---|---|
TAG_SECRET |
QCA/v1/secret |
off-chain leaf secret derivation |
TAG_LEAF |
QCA/v1/leaf |
leaf hashing |
TAG_NODE |
QCA/v1/node |
Merkle inner nodes |
TAG_ACTION |
QCA/v1/action |
action tuple hashing |
TAG_COMMIT |
QCA/v1/commit |
commitments |
TAG_BURN |
QCA/v1/burn |
burn commitments (sits in the action slot) |
The wallet holds one 32-byte seed S drawn from a CSPRNG.
secret_i = H(enc(TAG_SECRET, S, i)) i in [0, 2^d)
leaf_i = H(enc(TAG_LEAF, secret_i))
root = Merkle root over leaf_0 .. leaf_{2^d - 1}
node = H(enc(TAG_NODE, left, right))
The tree is a complete binary tree of depth d. secret_i is one-time: it is exposed at reveal and must never authorize anything again. TAG_SECRET never appears on-chain; the contract only ever sees secret_i, from which neither S nor any sibling secret is derivable (keccak256 preimage resistance).
The account contract stores:
root(bytes32) anddepth(uint256): the active authorization tree.usedLeaves:mapping(bytes32 => bool), keyed by leaf hashH(TAG_LEAF, secret). This is the nullifier set. Keying by leaf hash rather than by leaf index is deliberate: each secret is derived from the seed and a global index, so a leaf hash is unique across every tree the account ever uses, and a rotated tree's fresh secrets get fresh nullifier space with no index bookkeeping. The cost is one storage slot per consumed leaf instead of one packed bit; the earlier index-bitmap design saved gas but made rotation reuse an old index's nullifier bit, so it was dropped. Entries are never cleared.commitments:mapping(bytes32 => uint256), commitment hash to the block number it was posted in. Zero means absent.- Immutable parameters
minCommitAgeandcommitTTL, both in blocks.
The constructor sets the initial root and depth. The deployment transaction is classical-crypto-signed; this bootstrap trust window is a documented assumption, not a solved problem (see threat model).
action = (target, value, data)
actionHash = H(enc(TAG_ACTION, target, value, H(data)))
c = H(enc(TAG_COMMIT, chainid, account, actionHash, leafIndex, secret_leafIndex, callGasLimit))
callGasLimit is the execution-gas budget the reveal will forward to the action, bound into the commitment. It closes a gas-starvation grief: without it, anyone who copied a pending reveal's public calldata could front-run it under a constrained outer gas limit so the action ran out of gas (its failure is swallowed, see Reveal step 10) while the leaf was still consumed, a one-transaction forced leaf burn against the victim's own aged commitment. Binding the budget and checking it is available before the leaf is consumed means a starved copy reverts and the leaf survives; only a caller who forwards the committed budget consumes the leaf, and the action then receives exactly that budget.
commit(c) stores commitments[c] = block.number. Rules:
- Reverts if
commitments[c]is nonzero. Re-committing an existing hash would reset its age; allowing that lets an observer refresh a victim's commitment forever and hold their reveal belowminCommitAge. First write wins. - Anyone may post a commitment and pay for it. The committer's identity is irrelevant:
cbinds the account, the exact action and the secret, so a copied or front-run commit is either identical (harmless, the victim reveals against it) or useless (the copier does not know a secret for any other action).
The commitment hides actionHash and leafIndex because secret_i is a 32-byte high-entropy value inside the hash.
reveal(target, value, data, leafIndex, secret, callGasLimit, proof[]) verifies, in order:
proof.length == depthandleafIndex < 2^depth.- Membership: fold
leafHash = H(enc(TAG_LEAF, secret))up the path usingTAG_NODE, taking left/right from the bits ofleafIndex; result must equalroot. - Leaf unused:
usedLeaves[leafHash]is false. - Commitment: recompute
cfromblock.chainid,address(this), the action tuple,leafIndex,secret,callGasLimit; requirecommitments[c] != 0. - Age:
block.number >= commitBlock + minCommitAge. A commitment can never be revealed in its own block; without a minimum age the anti-front-running property is void, because an attacker who sees a reveal in the mempool could commit and reveal a competing action in the same block. See the parameter note:minCommitAgealso governs how reorg-safe the commit is when the secret is exposed, and those are two different budgets. - Freshness:
block.number <= commitBlock + commitTTL. Expired commitments are dead. Expiry bounds how long secret-bound state can linger and forces an attacker who steals a mempool-observed secret to race a live window instead of banking commitments indefinitely. - Gas: enough gas remains to forward the committed
callGasLimitto the action under EIP-150 (gasleftcoverscallGasLimit * 64/63plus the effects and event). This is checked before any state mutation, so a reveal that cannot fund the committed budget reverts without consuming the leaf. It is the anti-starvation gate: a copied reveal submitted with a throttled outer gas limit fails here, leaving the leaf live.
Effects, strictly before the external call (checks-effects-interactions):
- Set
usedLeaves[leafHash] = true. - Delete
commitments[c]. - Execute
target.call{gas: callGasLimit, value: value}(data); return the success flag without reverting. A reverted action still consumes the leaf and the commitment: the secret was published in calldata the moment the reveal transaction hit the mempool, so it must never be reusable. "Executed" throughout this document means the reveal reached this call and consumed the leaf, regardless of the callee's success flag.
burn(leafIndex, secret, proof) proves membership exactly as reveal does and, after opening an aged burn commitment, sets usedLeaves[leafHash] = true without executing any action. The burn commitment is domain-separated from action commitments by TAG_BURN sitting in the action-hash slot:
c_burn = H(enc(TAG_COMMIT, chainid, account, TAG_BURN, leafIndex, secret))
and burn checks it exactly as reveal checks its commitment: present, aged by minCommitAge, not past commitTTL. It is the defensive move for a leaked secret: a reveal that was reorged out or expired unincluded leaves the secret public while the leaf is still live on-chain, and burning nullifies it.
Burn is age-gated deliberately, and this is a correction to an earlier design. The first version let anyone burn with just a secret and proof, no commitment. Because a burn's inputs are a subset of a reveal's calldata, that made burn a one-transaction denial-of-service on every reveal: an observer copies the secret and proof out of a pending reveal in the mempool, submits an immediate burn, orders it ahead of the reveal, and kills the leaf, no censorship required. Requiring an aged burn commitment removes this: an attacker who only learns the secret when the reveal is broadcast cannot already hold an aged burn commitment, so the victim's already-aged reveal wins. See GAME.md Section 6. The cost is that recovery from a genuinely leaked leaf is no longer race-free: the holder must itself commit-to-burn and age, a symmetric race with any thief. That is not a missing optimization; GAME.md Theorem 2' proves race-free recovery is impossible, because in the leaked state nothing on-chain distinguishes the holder from an attacker (both merely hold the secret). A hostile burn still moves no funds; after the fix it is bounded by the same aged-commitment race as theft.
Root rotation is a normal revealed action whose target is the account itself calling rotate(newRoot, newDepth) (guarded onlySelf). Semantics:
- Outstanding commitments made under the old root become unrevealable if their leaves are not in the new tree; commitment validity is checked against the current root at reveal time. Rotating to a fresh tree therefore cancels all in-flight commitments. This is deliberate: rotation is the break-glass response to suspected seed exposure.
- Nullifiers are keyed by leaf hash, so a new tree built from fresh secrets gets fresh nullifier space automatically. A leaf consumed under the old tree stays consumed, which is harmless because the new tree's leaves have different hashes. There is no index bookkeeping and no capacity lost to rotation. (The tooling's
index_offsetexists only so that rotating within the same seed still produces distinct secrets; it is not required for correctness once nullifiers are hash-keyed.)
prune(c) deletes an expired commitment (block.number > commitBlock + commitTTL) for the gas refund. Anyone may call it. Live commitments cannot be pruned.
minCommitAge and commitTTL are set at deployment. minCommitAge carries two distinct safety budgets that must not be conflated:
- Anti-front-running margin. An attacker who learns a secret from a pending reveal needs
minCommitAgeblocks between their commit and their reveal, so the victim's already-aged reveal only loses if it stays unincluded for longer than that. Larger values buy censorship margin and cost latency. - Commit reorg-safety. Broadcasting a reveal exposes the secret. If the commit it opens is not yet final, a reorg can drop the commit while the secret is now public, which is a theft window, not just a liveness loss (see the threat model). The operational rule is therefore stronger than "reveal once aged": do not broadcast a reveal until its commit is final. A small
minCommitAge(the test default is 4) is fine for budget 1 but is far below Ethereum finality (~64 to 95 slots), so a high-value account must either wait for commit finality before revealing or run aminCommitAgesized to its own reorg tolerance.
Defaults used in tests: minCommitAge = 4, commitTTL = 256. These are placeholders until the adversarial simulation produces measured guidance; treat them as parameters under study, not recommendations. In particular, minCommitAge should be sized against the builder concentration the account actually faces (censoring the reveal for the whole window against a coalition of block share p succeeds with probability ~p^minCommitAge), and the safe reveal window is narrower than the nominal [commit + minCommitAge, commit + commitTTL] once commit finality and the expiry edge are accounted for.
Stated here so they can be attacked and, later, mechanized. Each is conditional on the stated secrecy premise; the premise, not the mechanism, is what fails in the reorg and expiry cases.
Secrecy premise: a leaf secret is known only to the honest owner until the moment that leaf's reveal becomes public. The contract cannot enforce this (commit is permissionless and binds the secret but authenticates no one); the properties below hold relative to it.
- AUTH: an action executes only against a matching aged, unexpired commitment binding
(chainid, account, action, leafIndex, secret)whose leaf verifies under the current root. Under the secrecy premise the committer is the owner; without it, the committer is whoever holds the secret. "Holder" is not a claim the contract makes, only "a party in possession of the secret." - ONCE: a leaf authorizes at most one execution per canonical chain, counting reverted actions as executions. This is not a cross-history statement: a reorg deeper than the reveal's inclusion depth resurrects the leaf and its commitment together (the EVM cannot see orphaned branches), and combined with the now-public secret that degrades the leaf to a race with no aged-commitment advantage. High-value actions must await finality.
- NO-REBIND: a commitment cannot be opened to any action tuple other than the one hashed into it. This is second-preimage resistance on a fixed published
c(one preimage is the victim's), not collision resistance. - NO-RESURRECT: consumed, expired or rotated-away authorizations cannot execute later, per canonical chain, subject to the same reorg caveat as ONCE.
- HIDE: before reveal, the commitment discloses nothing computationally useful for constructing a valid reveal. Recovering the action or index from
cis a preimage search over the 256-bit secret (~2^128 under Grover). The low-entropy fields (actionHash,leafIndex) are hidden only computationally and only while the secret is secret, not information-theoretically.
Assumptions, per property:
- NO-REBIND, AUTH membership soundness, and HIDE rest on keccak256 (second-)preimage resistance: ~2^128 against a Grover-equipped quantum adversary at 256-bit output.
- No property rests on quantum collision resistance. Generic quantum collision on a 256-bit hash is only ~2^85 (Brassard-Hoyer-Tapp), well below the 2^128 floor, but it does not bite here because in every hash one preimage is fixed by the honest owner or by the honestly chosen root. Any future change that lets an adversary choose a root (guardian recovery, adversary-influenced rotation) would introduce a ~2^85 dependency and must be analyzed separately.
- Deployment invariant (one tree per account). Each Merkle tree MUST back exactly one account. Cross-account isolation rests on distinct roots, not on the commitment's account field: a permissionless committer can always re-commit an observed secret on any account whose current root contains that leaf, and nullifier sets are per-contract, so the same secret revealed on one account opens that leaf independently on every other account sharing the root. Deploying two accounts (including the same account type on two chains, or the base / 4337 / zkSync variants) from one seed and tree, or rotating back to a prior root, breaks isolation and is a theft vector (SECURITY.md F-2026-06). Tooling MUST give each account a disjoint leaf set (a distinct
index_offsetrange, or an entirely separate seed). As defense in depth against commitment-format collision, each account variant also binds an environment domain tag into its commitment (e.g.TAG_ENV_ZKSYNC), so separation is by design, not by field arity; the tag does not substitute for the one-tree-per-account rule, which is what actually prevents shared-root leaf replay. - Chain finality as delivered by the underlying consensus. No assumption of ECDSA security anywhere in the authorization path.
- The reveal transaction's outer envelope is ECDSA-signed on present-day Ethereum. An attacker cannot alter the revealed action (NO-REBIND), and the envelope is irrelevant to the reveal since anyone can submit their own, but full end-to-end PQ security needs account abstraction or protocol support for the envelope itself.
- A reveal is a non-cancellable, submitter-independent bearer instrument. The recomputed commitment does not bind
msg.sender, so once a reveal is public anyone can land the committed action from any account, and the committer cannot cancel it by replace-by-fee. The action is immutable (NO-REBIND), but its block and intra-block placement within the reveal window are handed to the market, which matters for MEV-sensitive actions. - Relayed or bundled reveals hand the secret to the relayer before it is public. A relayer can commit a theft action and withhold the victim's reveal, defeating the aged-commitment defense with no chain censorship required. Reveals must be self-submitted or sent only through a relayer trusted with the account's funds.
- Burn-on-expiry depends on off-chain state. Whether a leaf's secret was ever broadcast in an unincluded reveal leaves no on-chain trace, so a wallet that rebuilds state by scanning events cannot reconstruct it. Wallets must record "leaf i exposed" durably before broadcasting a reveal (write-ahead) and treat any leaf with unknown reveal status as burned (fail closed). The on-chain
burngives such a leaf a nullification path, but not a race-free one: burn is age-gated (see the Burn section), so recovering a leaked leaf is a symmetric race against any thief, and race-free recovery is provably impossible (GAME.md Theorem 2'). The real defense is not entering the leaked state: do not broadcast a reveal until its commit is final, and self-submit. - Nullifier state and commitment records grow with use; prune reclaims expired commitments, spent-leaf entries are permanent by design.
- Censorship of a reveal through the commit window is a liveness attack whose cost is bounded only when the victim actively fee-escalates the reveal across the whole window; a passive, near-basefee reveal reduces security to the builder lottery. Quantified in the threat model and the planned simulation.
committedAtmust always be read from on-chain state, never assumed from a local commit receipt: an identical front-run commit can make the sender's own commit transaction revert while the commitment still exists at a slightly earlier block.