Documentation

Sortis

Confidential prize-linked savings on the Zama Protocol. Four pages: what it is, how the register works, what stays private, and what it does not do.

Overview

Sortis is confidential prize-linked savings. You commit funds, you cannot lose your principal, the pooled yield is awarded as a prize on a schedule, and the size of your position is not visible to anyone but you.

The draw stays publicly verifiable. Anyone can check that the lot was drawn against the register root committed one block earlier, that the denominator was signed by the KMS, and that the walk ran. What they cannot see is who it landed on.

The cycle

  • Commit. Your amount is encrypted in your browser, sent as a ciphertext handle with an input proof, and pulled by the pool with confidentialTransferFrom.
  • Wait. Weight accrues from how much sat in the pool and for how long, so a deposit made moments before a draw carries nothing.
  • Draw. openDraw commits the root and the block. In a later block drawLot produces the lot with FHE.randEuint64 and descends the register.
  • Claim. Each depositor claims the same way; an encrypted comparison pays the drawn leaf and pays everyone else zero.
  • Release. Principal is withdrawable at any time. An over-withdrawal is an encrypted no-op, never a revert.

The yield source

On Sepolia the prize comes from a mock adapter with an admin-callable accrue, which mints cUSDT into the draw contract. It is a mock and the README says so. On mainnet the same ISortisYieldAdapter interface sits in front of an ERC-4626 vault; harvest(to) is the only method the draw depends on. Chasing a live yield source on a testnet would cost days and prove nothing about the part of this that is hard.

Architecture

FHEVM caps the longest chain of dependent operations in one transaction at 5,000,000 HCU. Encrypting balances and scanning them puts every depositor in that one chain, which is why a linear draw stops working at 30 depositors. Sortis keeps time-weighted stake in a segment tree and descends it, so the chain grows with the height of the tree rather than with the number of people in it.

The update path is parallel, not deep

A commit folds the direction into the delta once and then adds that single ciphertext to every node on the path. Each node depends only on its own previous value, so the writes are independent and bill against the global budget rather than the depth budget. Sequential depth is 920,000 HCU and is flat in shard size.

The walk keeps the branch encrypted

At each level the walk compares the remaining lot against the left subtree's weight, selects whether to subtract, and folds the branch bit into an encrypted index. Solidity never branches on a ciphertext. Because the index stays encrypted, the contract cannot address the next node in plaintext either, so each level resolves its child obliviously over the candidates at that level.

A shard holds 32, and the number is measured

   4 stakes   2,199,000 HCU   43.98%   fits
   8 stakes   3,020,000 HCU   60.40%   fits
  16 stakes   3,748,000 HCU   74.96%   fits
  32 stakes   4,476,000 HCU   89.52%   fits
  64 stakes     reverts         depth budget

The ceiling is set by what a draw costs, not by what a walk costs. drawLot reduces the lot modulo the published total before descending, and FHE.rem is a 1,153,000 chain the whole walk then hangs off. A shard was briefly deployed at height 6 on the strength of the walk figure alone and could not have settled its own draw. It deploys at height 5: 32 stakes at 89.52% of budget, and 64 reverts.

Scale is more shards, not a bigger tree. Depth is the binding budget and it cannot be checkpointed across transactions the way global work can. Every figure above is measured by test/HCU.t.ts and test/Calibration.t.ts at commit c234b6a, and a real draw on Sepolia reported the same depth the mock did, to the unit.

Privacy model

Encrypted

  • Your deposit amount. Encrypted in your browser before it is sent.
  • Your balance and your weight. euint64 handles, decryptable by you alone through EIP-712 user decryption.
  • Whether you won. The resolved leaf is an encrypted euint16 and no ACL grant is ever issued for it.
  • What you were paid. A losing claim transfers an encrypted zero, and costs the same gas and the same HCU as a winning one.

Public, and deliberately so

  • The pot. The prize size is not a secret, and encrypting it would remove the public verifiability the design exists to provide.
  • The register root, the block it was committed at, and the total weight the lot was reduced against.
  • That an address interacted with the pool, when, and in which direction. commit pulls and release pushes, and those are different external calls.
  • Which leaf index moved. _update writes a visible path of storage slots, so leaf ownership is on chain whatever the frontend does.

The wrap leak

Money arrives as public USDT. Wrapping it in the same transaction as the deposit would make the amount readable one call before it became private, so deposits queue and settle in epoch batches instead. Batching raises the cost of linkage; it does not eliminate it. A depositor alone in an epoch has no anonymity set, amounts are not mixed so sizes can be matched back, and each settlement emits an event naming the stake owner.

What verification cannot do

The descent cannot be replayed client-side. Every node in the register is a ciphertext nobody holds a grant on, and publishing per-node decryptions so a verifier could re-run the comparisons would hand everyone the register. The Verify screen checks the public chain of facts and names this limit rather than papering over it.

Limitations

Sortis has not been audited. It is deployed on Sepolia only, against a mock yield source, and nothing here should be treated as production infrastructure.

Known limits

  • A shard holds 32 stakes. The 33rd depositor is rejected by RegisterFull rather than silently pushing the draw past what it can settle.
  • Multi-shard routing is not built. The contracts support many shards; the deployment scripts and the frontend address one.
  • Weight is evaluated at whole hours. A stake held for less than an hour carries no weight, which is what prevents a late deposit from sniping a draw and also means very short holds earn nothing.
  • The epoch wrap queue is deployed but the frontend commits directly, which is the more legible path for a judge and the less private one. The queue is the private path and the docs say which is which.
  • The prize is awarded to one leaf per draw. Multiple winners per draw would need either several walks or a different selection rule.

The change that would raise the ceiling

Materialising the weights once at openDraw, instead of evaluating intercept and slope at every level of the walk, moves that cost off the critical path. Each node's evaluation is independent of every other node's, so a snapshot pass bills against the 20,000,000 global budget rather than the 5,000,000 depth budget.

It would not make a shard much larger, because global cost roughly doubles per level and takes over around the same place. What it would do is turn a hard ceiling into a soft one, because global work is checkpointable across transactions and depth is not. That is the named path forward, and it is not in this build.