Security
Randomness and verification
Where the seed comes from, why nobody can re-roll it or resize what it wins, and how anyone recomputes a threshold after the fact.
A draw is only worth anything if a stranger can check it. This page is how.
#Where the seed comes from
One call, inside the transaction that closes a draw:
euint64 seed = FHE.randEuint64();That runs inside Zama's coprocessor. The number is produced by a cryptographically secure generator under the network's FHE key, and what comes back to the contract is a ciphertext handle, not a number. Nobody has seen the value at that moment: not the caller, not us, not the miner.
Two properties of Zama's generator matter here, and both are stated in Zama's own documentation:
- It must run inside a transaction. Generating a random value mutates the on-chain
generator state, so it cannot be done through
eth_call, the read-only way to simulate a call. Nobody can preview a draw off chain to see whether they would win. - It is cryptographically secure and stays encrypted until something explicitly makes it decryptable.
#Why nobody can re-roll it, or resize what it wins
Four things, together.
- Closing succeeds once. The draw state machine allows
closeDraw(p)exactly once per draw. There is no second attempt to buy a better number. - The value is unknown when it is drawn. Since the seed is ciphertext at creation, whoever sends the closing transaction learns nothing from having sent it. There is no point in competing to be the caller.
- The publishing step is one-way. After close, the seed is marked publicly decryptable. That flag is permanent and irrevocable on Zama's access control list, so the number the world sees is the number the contract committed to, not one chosen afterwards.
- The prizes are fixed before the seed exists. Each tier's prize size and the
liquidity it offers are computed at the top of the same closing transaction, before
randEuint64is called. In an earlier draft they were set later, at the award, which left a window in which somebody could read the seed, work out that they had won, and then move liquidity between tiers to make that win worth more. That window is gone.
Compare that with the alternative designs. A draw fed by a block hash can be re-rolled by a validator who does not like the result. A draw fed by an off-chain number can be chosen outright. Neither is possible here, which is the whole reason the randomness is generated on chain under encryption and never by an off-chain generator.
#What becomes public, and when
| Value | Published when | Why it has to be public |
|---|---|---|
The seed R |
At close, readable after the relayer decrypts it | Without it nobody can recompute a threshold |
The scale count, from which the bracket M follows |
At close | Thresholds are relative to the size of the pool |
| Whether the period was non-empty | At close | Distinguishes an empty draw from a real one |
| The harvest for the draw | At close | It is the money that funds later prizes |
| Each tier's prize size and its plaintext offered liquidity | At close | Needed to check what a win pays |
| Each tier's carry | At the finalization of every draw, since every tier reconciles every draw | Needed to check how many prizes the tier paid |
| The unfunded counter | At finalization | Proves the pool funded every credit the vault wrote |
Two things are deliberately not on that list. The pool's exact total time-weighted balance is never published, because doing so let an observer recover a lone mover's deposit amount exactly; the bracket above it is published instead. And no per-saver value is ever marked publicly decryptable.
Everything on the list arrives after the period it decides has already ended. Publishing
R cannot help anyone change a weight, because weights for period p are frozen the
moment period p ends, which is before the draw can even be closed.
Each of those numbers reaches the contract with a signature from Zama's key management
service, verified on chain by FHE.checkSignatures. The proof is bound to the handles in
a fixed order: [seed, scaleCount, nonEmpty, harvested] at award, and one carry handle per
reconciliation. Nothing can be shuffled between slots or replayed against a different
draw. The draw's state machine is the replay guard: each step succeeds once per draw, and
reconciliation once per tier.
#The bracket, and how the vault tracks it
The pool's total time-weighted balance for a period, W, stays encrypted. The number the
draw runs against is M = 2^m, the smallest power of two at or above W.
The vault tracks m from draw to draw rather than computing it from scratch. At each
close it compares W under encryption against the five powers of two around the previous
draw's m, adds the five results into one small encrypted count, and marks that count
publicly decryptable. The pool reads the verified count and works out the new m, which
can move by at most three steps per draw. A separate encrypted comparison against 1 gives
the non-empty flag.
So the public record per draw is one small integer, and it changes only when the pool
crosses a power of two. scaleBits() on the pool reads the current m; the deployment
seeds it with initialScaleBits, the expected bit length of the first period's total, and
the tracker corrects any error by up to three bits per draw.
#How anyone recomputes a threshold
Everything below uses only public data. No wallet, no signature, no permission.
For draw p, saver address u, tier t with count[t] prizes and odds
oddsNum[t] / oddsDen[t]:
prn = keccak256(abi.encode(R, p, u, t))
r = prn mod M // 0 <= r < M
threshold_k = floor((r + k * M) * oddsDen[t] / (oddsNum[t] * count[t]))for each k from 0 to count[t] - 1. That saver won prize k if and only if their
time-weighted weight for period p was strictly greater than threshold_k.
You do not have to reimplement it. The vault exposes
thresholdOf(drawId, saver, tier, k) as a pure view over the same arithmetic evaluation
uses, so the app's verify panel, the test suite and anyone with a block explorer all read
the same implementation. Reimplementing it off chain is four lines of big-integer
arithmetic if you would rather check the contract against your own code.
A worked example with small numbers is in
winner selection. A filled example from a real Sepolia
draw is here, taken from the usdc pool, whose prize pool is
0xA0785AacF30B6FE46EDc53CD8A9db1d94FeF5Df2. Every pool publishes the same fields for its
own draws:
| Field | Value |
|---|---|
| Draw | 2, the period from 23:00 to 00:00 UTC on 2 September 2026 |
Seed R |
5625525180683981523 |
Bracket M |
2^43, which is 8,796,093,022,208 balance-seconds |
| Harvest | 19.531380 USDC |
| Tier prize sizes | 3.559644 / 1.779822 / 0.889911 USDC, grand / mid / frequent |
| Prizes paid per tier | 0 / 0 / 5, against a funded capacity of 2 / 2 / 8 |
Read from the chain: the seed and the bracket come from the pool's DrawAwarded event,
the prize sizes and the offered liquidity from drawParams(2), and the prizes paid from the
three TierReconciled events for that draw, since what a tier offered and did not pay is
exactly the carry it published. The grand and mid tiers paid nothing in this draw and handed
their whole offer back, which is what a 1 in 24 and a 1 in 6 tier do most of the time.
The app's verify panel does this arithmetic in the browser for any address you type in, at
/verify?pool=<slug> for the pool you want. It has no privileged access; it is the same
public inputs and the same formula.
#Why the remainder is unbiased
Reducing a big random number into a range with a plain remainder is usually biased. If
2^256 is not an exact multiple of the range, the low residues occur slightly more often,
and that bias lands unevenly on savers. PoolTogether V5 solves it with rejection sampling,
and so did an earlier draft of Hearth.
Hearth no longer needs to. M is a power of two by construction, and 2^256 is an exact
multiple of every power of two up to 2^256. So prn mod M is simply the low m bits of
a 256-bit hash, and every value from 0 to M - 1 comes from exactly the same number of
inputs. The bias is zero, not small, with no loop, no rejection and nothing for a
verifier to reproduce carefully.
That is a side benefit of publishing the bracket rather than the exact total, and it is worth stating because it removes a piece of code that anybody checking the draw would otherwise have to match exactly.
#Address grinding does not work
Once R is public, someone could generate addresses until they find one with a low
threshold. It would be useless. Thresholds are compared against a weight for period p,
and a brand new address has no observations at or before period p, so its weight is
zero. Zero beats no threshold. To have weight in period p you had to hold a balance
during period p, which was over before R existed.
Grinding for a future draw fails for the other reason: that draw's seed has not been generated yet, and it is unpredictable.
#What verification proves, and what it does not
Being precise about this is the point of the page.
It proves:
- The seed was generated on chain, inside a transaction, under the network key, and published exactly once.
- The prize sizes and offered liquidity were fixed before that seed existed.
- The rule applied to every saver is public, uniform and recomputable by anyone.
- The prize sizes follow from the tier liquidity and the tier parameters by public arithmetic.
- The number of prizes each tier paid matches what the tier offered minus what came back in its carry.
- The pool funded every credit the vault wrote, since the unfunded counter is published and is zero.
It does not prove:
- That the coprocessor's generator is uniform. That is Zama's engine, and it is trusted, not verified here.
- That the key management service signed the true plaintext of the seed handle. The contract checks the signature, not the semantics. A dishonest quorum could sign a value of its choosing. Every application on this protocol shares that assumption; it is attacker 8 in the threat model.
- That the published bracket really is the bracket of the sum of every saver's weight. An outsider cannot add up encrypted weights, and now cannot see the sum either. What they have instead is that the same public, immutable code computed the comparisons and each saver's weight from the same observations, and that the conservation invariants hold: paid equals credited, and nobody withdraws more than principal plus winnings.
- Anything about who won. That is the whole point, and it is why publishing more would make verification stronger and the product worse. Publishing the exact total is the concrete example: it made the pool's size checkable, and it also made a lone mover's deposit recoverable to the base unit.
#What a saver can check that nobody else can
A saver can go one step further than an outsider, because they can decrypt their own weight and their own credit for a draw.
- Reveal your weight for draw
p. - Recompute your own thresholds from the public
RandM, or read them fromthresholdOf. - Count how many you beat, multiply by the tier's prize size.
- Reveal your credit for draw
pand check it matches.
If it does not match, either a tier ran out before the walk reached you, which is the documented clamp, or something is wrong and you have the numbers to prove it. The app does all four steps for you and shows the arithmetic.