Documentation
Technical reference
The design decisions behind Sable, including the ones where the obvious approach turned out to be wrong.
Architecture
SableAccessControl ─┐
├─ SableCore ─ SableVault ─ SablePrizeEngine ─ Sable
ZamaEthereumConfig ─┘
SableReserveYieldAdapter Reserve-backed yield (external asset)
SableTestnetYieldAdapter Mint-backed yield (self-issued asset)SableCore holds storage, configuration and the confidential primitives: checkpointing, yield accrual and the ACL helper every ciphertext write passes through. SableVault adds deposits, withdrawals, the confidential mode and reward claiming. SablePrizeEngine adds the round lifecycle, ticket allocation, the encrypted draw and settlement.
The FHE data model
Everything monetary is euint64, matching ERC-7984 so no cast is ever needed at the token boundary. The mode is an ebool.
struct Position {
euint64 balance; // principal + compounded Steady yield
euint64 reward; // prize winnings, not yet claimed
ebool isLucky; // the confidential mode bit
}
roundId => account => euint64 roundWeight // mode-gated eligibility
roundId => account => euint64 ticketStart // half-open range [start, end)
roundId => account => euint64 ticketEndRound-scoped state lives in separate mappings rather than inside the position, so a new round starts clean without an O(participants) reset, and a completed round’s allocation stays independently auditable.
Why yield uses a public index
FHEVM has no encrypted-by-encrypted division. That single constraint shaped the entire yield model: Sable cannot compute one saver’s share of a pooled return homomorphically, because the divisor would itself be a ciphertext.
So the yield rate is public and each encrypted balance is multiplied by a public index delta. That is exact, costs one scalar multiply, and leaks a protocol parameter rather than a position.
yield = balance × (index − userIndex) / INDEX_SCALE
steady = select(isLucky, 0, yield) // compounds into balance
lucky = select(isLucky, yield, 0) // funds the prize poolThis is also what makes principal safety structural. The prize pool can only ever be fed from the lucky branch, whose value is bounded by that interval’s interest. No expression anywhere moves principal into a reward.
The draw algorithm
FHE.randEuint64(bound) requires bound to be a power of two. That is a property of the library, and it propagates directly into the product design.
tickets = min(weight / weightPerTicket, 2^k / maxParticipants)
[start, end) = [cumulative, cumulative + tickets)
isWinner = (point >= start) AND (point < end)
reward += select(isWinner, tierPrize, 0)Every participant is evaluated against every draw point and every participant receives a result — losers an encrypted zero rather than being skipped. That symmetry is the privacy property: there is no winners list, no event naming an address, and no branch whose cost differs between winning and losing.
Whatever part of the ticket space is unallocated stays dark, and a jackpot point landing there matches nobody. The rollover is not an error path bolted on afterwards — it is the unavoidable consequence of a fixed random domain, surfaced as a product feature.
Both decryption paths
Sable uses the two Zama decryption flows for two genuinely different jobs:
- Public decryption. The contract marks a round’s aggregate prize figures publicly decryptable at finalization. Any client resolves them through the relayer with no wallet. This is what lets the draw ledger show real numbers to an anonymous visitor.
- User decryption. Balances, modes, weights and rewards are readable only by the owning account, via an EIP-712 authorisation and a session keypair.
Individual positions never take the public path. The aggregate is an aggregate over all Lucky savers, so publishing it exposes no individual — while keeping it secret would force the ledger to show redacted boxes where a real prize belongs.
Measured performance
Batch sizes are derived from measurement, not assumption. These figures come from the repository’s HCU benchmark against the protocol’s 20,000,000 unit per-transaction ceiling:
| Operation | HCU |
|---|---|
| Set mode | 96 |
| Deposit | 1,129,096 |
| Withdraw | 1,129,032 |
| Eligibility, per account | 2,055,596 |
| Ticket assignment, per account | 1,027,008 |
| Settlement, per account | 7,560,448 |
| Finalize round | 5,385,000 |
| Complete round | 388,096 |
Settlement dominates, which is why it is limited to 2 accounts per transaction. A fifty-participant round is entirely feasible — it simply takes about twenty-five settlement transactions rather than one. That is an operational cost, not a wall, which is precisely why every phase is resumable.
Protocol limits
- Maximum balance per account
- 1,000,000 cUSDC
- Weight granularity
- 60 seconds
- Maximum round duration
- 30 days
- Yield index scale
- 1,000,000
These are not arbitrary. Each one bounds a product that would otherwise risk silently wrapping an euint64 — see the security page on why encrypted arithmetic needs this care.
Repository documentation
Deeper material lives alongside the code, including the verified Zama API notes recording which version choices were checked against installed packages rather than assumed:
docs/ZAMA_IMPLEMENTATION_NOTES.md verified version matrix and API surface
docs/ARCHITECTURE.md contract design and data flow
docs/FHE_SECURITY.md ciphertext permissions, leakage audit
docs/PRIZE_ENGINE.md weighting, tickets, draw, rollover
docs/PRIVACY_MODEL.md what is protected, what is not
docs/TESTING.md test architecture and coverage
docs/DEPLOYMENT.md Sepolia deployment and operations