Confidential prize-linked savings. You commit, you cannot lose your principal, the pooled yield is drawn as a prize on a schedule, and nothing about your position is visible to anyone.
Built for the Zama Developer Program, Mainnet Season 4. See docs/BRIEF.md for the full design.
Live app: not yet published. See Deploying the frontend below, then put the URL here. Everything else works against the Sepolia deployment already listed under Sepolia, from any browser with a wallet.
Sepolia ETH is the only thing you need to bring. The pool's test token has an open faucet in the app.
- Open
/app/registerand connect a wallet on Sepolia. - Press Mint 5 cUSDT. This mints the pool's ERC-7984 test token to you and authorises the pool as an ERC-7984 operator in the same step, because a commit fails without that authorisation and a judge should never hit that for a reason the UI never mentioned.
- Enter an amount and press Commit. The amount is encrypted in your
browser by the relayer SDK, sent as a ciphertext handle with an input proof,
and pulled with
confidentialTransferFrom. Nothing readable leaves your machine. - Press decrypt next to STAKE. Your wallet signs an EIP-712 grant scoped to the pool and a one-day window, and the relayer returns the plaintext to this browser only. That is the user-decryption flow.
- Wait for the stake to cross an hour boundary, then open
/appto see the register, the current draw and the history. - Press Release to withdraw. Principal is withdrawable at any time. Try an amount larger than your stake: the transaction succeeds, moves nothing, and costs the same gas as one that does, because reverting on an encrypted comparison would leak the balance.
- Open
/app/verifyand verify a draw. This works with no wallet connected.
Draws are operator-triggered on Sepolia, and the flow is two transactions on
purpose. From a clone with PRIVATE_KEY set to the deployer:
npx hardhat run scripts/live.ts --network sepoliaThat runs a full commit, hold, release, over-release, openDraw, drawLot and
claimPrize against the live contracts and prints every gas figure. It takes
about ninety minutes end to end, most of it the hour the stake has to sit to
carry weight. LIVE_SKIP_COMMIT=1 LIVE_HOLD_SECONDS=0 resumes from the release
if a stake already exists.
MockYieldAdapter has an admin-callable accrue(uint64) that books a prize,
and harvest(address) mints that much cUSDT into the draw contract and resets.
That is the whole of it. Sepolia has no real yield and building a convincing
fake one would cost days and prove nothing about the part of this that is hard.
The mainnet path is the same ISortisYieldAdapter interface in front of an
ERC-4626 vault. SortisDraw only ever calls harvest(address) and only ever
treats the result as a public uint64, so swapping the adapter is a
constructor argument and no contract change.
The Next app lives in web/, and the repository root is the Hardhat project.
A Vercel build pointed at the root fails with No Next.js version detected,
because the root package.json has no next in it and should not.
vercel.json at the root fixes this by building from web/ explicitly. If you
would rather use the dashboard, set Root Directory to web in project
settings, which makes web/vercel.json the one that applies. Either works;
both are committed so it does not matter which you pick.
Set these on the Vercel project, for Production and Preview. Without them the footer reads "Not deployed", the stat strip has nothing to show, and Verify has no contract to read. None of them are secret.
NEXT_PUBLIC_POOL_ADDRESS 0xa57F6D5FC7780cbE5324EeC26d5a6BA88D22AeBa
NEXT_PUBLIC_DRAW_ADDRESS 0xBB39Fd2c061A138940dfC3aC182B5847d163EC57
NEXT_PUBLIC_CUSDT_ADDRESS 0x0ADfC89408f91aA3da2bac550Da87E1c6d08e989
NEXT_PUBLIC_YIELD_ADDRESS 0xBeb04ad88B411661D15742dbE1a659a6CEbB96Ae
NEXT_PUBLIC_DEPLOY_BLOCK 11578000
Two more are optional. NEXT_PUBLIC_SEPOLIA_RPC_URL replaces the public
fallback, which is rate limited and will be the first thing to break under a
judge's traffic. NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID adds WalletConnect to
the wallet list; without it the app offers injected wallets only, which is
deliberate rather than a gap. RainbowKit initialises WalletConnect on page load
whether or not anyone uses it, and with a placeholder id that means a 403 and a
400 in the console of every visitor.
NEXT_PUBLIC_DEPLOY_BLOCK is the earliest block worth scanning for this
deployment's logs. Public RPCs reject an unbounded fromBlock: 0 range, which
is what made the draw history render empty while every direct read on the same
page succeeded. Update it if you redeploy the contracts.
Every other entry will encrypt balances and scan them, which reverts at 30 depositors because of the FHEVM sequential depth budget. Sortis descends an encrypted tree instead. That draws from 32 stakes in a single transaction, and scales by adding shards rather than by growing the tree. The 32 is not a target, it is measured: hiding the winner forces the search to touch every leaf it could have picked, so the cost is linear in stakes however the tree is arranged, and a draw runs out of depth at 64.
FHEVM enforces two limits per transaction. Exceeding either reverts.
| budget | limit | what it measures |
|---|---|---|
| global complexity | 20,000,000 HCU | work that can run in parallel |
| sequential depth | 5,000,000 HCU | the longest dependent chain |
test/HCU.t.ts measures everything below and is a submission asset, not
internal hygiene. It does not assume where the ceiling is, it sweeps until the
transaction reverts.
npm testThe obvious way to maintain a segment tree recomputes each parent from its
children, so every parent depends on the child written a step earlier: 17 adds
in a chain. _update folds the sign into the delta once and adds the same two
ciphertexts to every node on the path, so the writes are independent of each
other and bill against the global budget instead.
| register | seq depth | of budget | global HCU | of budget |
|---|---|---|---|---|
| 2^4 | 713,000 | 14.26% | 2,226,000 | 11.13% |
| 2^8 | 713,000 | 14.26% | 3,522,000 | 17.61% |
| 2^12 | 713,000 | 14.26% | 4,818,000 | 24.09% |
| 2^16 | 713,000 | 14.26% | 6,114,000 | 30.57% |
A draw is more than a walk. drawLot reduces the lot modulo the published
total before it descends, and FHE.rem is a 1,153,000 chain that the whole
walk then hangs off. Measuring _walk alone says 64 stakes fit. Measuring the
transaction that actually has to land says otherwise.
| stakes | drawLot depth | of budget | result |
|---|---|---|---|
| 4 | 2,199,000 | 43.98% | fits |
| 8 | 3,020,000 | 60.40% | fits |
| 16 | 3,748,000 | 74.96% | fits |
| 32 | 4,476,000 | 89.52% | fits, and this is the shard |
| 64 | reverts | depth budget |
A shard was briefly deployed at 64 on the strength of the walk figure and could not have settled its own draw. The number that sets capacity has to be the cost of the whole transaction.
Verified against the chain, not just the mock. A real draw on Sepolia at
active height 2 reported 2,199,000 HCU of depth. The mock reports 2,199,000 for
the same call. They agree exactly, which is what makes the table above
trustworthy. test/Calibration.t.ts pins that comparison so it cannot drift.
Depth binds, not global work. That distinction decides what can be done about it: too much work splits across checkpointed transactions, a chain that is too long does not. So this is a hard ceiling, and the protocol shards rather than pretending otherwise.
The cost is about 728,000 HCU per level of the descent, and most of it is turning a node's intercept and slope into a weight on the critical path. That is the price of a weight line that never goes stale.
The named path to raising it, if there is ever slack: materialise the
weights once at openDraw instead of evaluating intercept and slope at every
level. Each node's evaluation is independent, so a snapshot pass bills against
the 20,000,000 global budget rather than the 5,000,000 depth one. That does not
make a shard much larger, but it converts a hard ceiling into a soft one,
because global work is checkpointable and depth is not.
A stake's weight is money multiplied by the hours it sat there. Accruing
balance * elapsed into an accumulator on every balance change looks right and
is wrong: weight then only moves when the stake is touched, so a depositor who
commits once and leaves the position alone carries the weight they had at their
last change. For a stake that never changed, that is zero. Safe against
sniping, and useless to an honest saver.
Weight over time is piecewise linear, so store the line:
weight(T) = intercept + slope * T
A balance change of delta at hour t moves it by slope += delta and
intercept -= delta * t. Both terms are additive over a subtree, so the exact
time-weighted total of any subtree at any T is one scalar multiply and one add.
No accrual pass, no keeper, nothing stale.
Time is whole hours since deployment, not unix seconds: slope * T has to fit
in a euint64 and a ten million dollar pool against a unix timestamp is 1.8e22
against a ceiling of 1.8e19. Hourly granularity is also the anti-snipe
property, and for a better reason than before. A stake committed minutes before
a draw is worth zero because it has genuinely been in the pool for no time, not
because an accrual pass missed it.
| file | what it does |
|---|---|
SortisRegister.sol |
Two encrypted segment trees, intercept and slope. _update and the oblivious _walk. |
SortisTwab.sol |
A stake's own copy of its weight line. Scalar FHE.mul for the time term. |
SortisPool.sol |
commit and release. Over-withdrawal is an encrypted no-op, never a revert. |
SortisDraw.sol |
openDraw then drawLot, two transactions. Native randomness, winner never revealed. |
SortisWrapQueue.sol |
Epoch-batched wrapping of public USDT into confidential stakes. |
Every function states its worst-case HCU depth in a comment above it.
Reverting on an encrypted comparison publishes the comparison. A release(X)
that reverts proves the caller's balance is below X and one that succeeds
proves it is at or above X, so an attacker binary-searches any balance in about
64 transactions. SortisPool.release uses FHESafeMath.tryDecrease, which
returns an encrypted success flag and leaves the balance untouched on failure.
A refused release transfers zero, moves the weight line by zero, emits the same
event, and matches an honoured one on gas, HCU depth and global HCU. All of it
is asserted in test/Pool.t.ts.
tryDecrease and not trySub: both return a flag, but trySub returns zero
on failure, which would wipe a stake the first time someone fat-fingered a
release.
openDraw captures the reference hour, publishes the register's total weight
at it, and records both root handles and the block. No randomness exists yet.
drawLot produces the lot in a later block with FHE.randEuint64, refuses to
run in the opening block, and refuses if either root handle moved in between.
Handles are content-derived, so that check is cryptographic rather than a
promise.
The lot must land uniformly in [0, total), and every reduction FHEVM offers
takes a plaintext bound: there is no ciphertext-ciphertext remainder, and
FHE.randEuint64(bound) reverts with NotPowerOfTwo unless the bound is a
power of two. So the total is published and verified with FHE.checkSignatures
against the KMS, and decoded from the bytes that were verified.
FHE.allow takes a plaintext address and the walk resolves to an encrypted
index, so granting from drawLot would mean decrypting the winner.
claimPrize compares the caller's own leaf against the encrypted result under
encryption and transfers the prize or an encrypted zero. Every claimant runs
identical code and moves an identically shaped ciphertext.
One shard, deployed at register height 5 so capacity enforces the measured ceiling: the 33rd depositor is rejected rather than silently pushing the draw past what it can settle.
| contract | address |
|---|---|
| SortisPool | 0xa57F6D5FC7780cbE5324EeC26d5a6BA88D22AeBa |
| SortisDraw | 0xBB39Fd2c061A138940dfC3aC182B5847d163EC57 |
| SortisWrapQueue | 0xF492f9b8e9dC86F6d6CDad46BaF66A332029c3Cc |
| cUSDT (mock) | 0x0ADfC89408f91aA3da2bac550Da87E1c6d08e989 |
| USDT (mock) | 0x6fa6daC32f9065Ab1caE413ae9726fD55E0F420A |
| Yield adapter (mock) | 0xBeb04ad88B411661D15742dbE1a659a6CEbB96Ae |
Copy .env.example to .env and set PRIVATE_KEY to a funded account.
npm run deploy:sepoliaOne live commit, hold, release and draw in a single process:
npm run live:sepoliaBudget about ninety minutes. initializeCLIApi downloads the 4.6MB PKE CRS
from S3 in eu-west-1 and does not cache it between processes, which takes about
twenty minutes on a slow link. Each encrypted input costs roughly forty
seconds. The hold has to cross an hour boundary for the stake to carry weight.
That is why the cycle and the draw run in one script rather than two.
One Next.js app in web/ serving all three surfaces by Host header. See
web/README.md.
Built: all five contracts, the HCU suite, one Sepolia shard, and the landing page with the draw column.
Not built: the Verify screen, the Register screen, the six docs pages, and wiring the draw column to live Sepolia instead of its local simulation.