Operations
Deploying
Deploying one pool per token, constructor signatures and parameters, verification, and the two Sepolia parameter sets against a mainnet one.
One repeatable script, never manual clicking. This page is the order, the parameters and what each of them means, so that a reviewer can read the deployed constructor arguments and know they match.
Hearth deploys one pool per confidential token: a vault, a prize pool and a yield source
per token, sharing nothing with any other pool. One run opens one pool, because one
deployer nonce runs one deploy, and the token is chosen with HEARTH_TOKEN. Every task
afterwards takes --token:
cd packages/contracts
HEARTH_TOKEN=weth npx hardhat deploy --network sepolia
npx hardhat hearth:verify --network sepolia --token weth
npx hardhat hearth:seed --network sepolia --token weth
npx hardhat hearth:status --network sepolia --token wethLeave both off and you get usdc, the network's default token. An unknown slug fails with
the list of pools that network does have. Every pool's parameters live in one file,
packages/contracts/hearth.config.ts: the asset pair, the period, the tier set, the
initial bracket, the drip rate, the sponsorship, the five demo stakes and the account index
its keeper signs from. Read that file next to the tables below; it is the same numbers.
The deploy reuses any contract that already has a saved deployment rather than replacing
it, so a second run is a no-op. A live pool holding savers' money and days of draw history
can never be moved to a fresh address by rerunning the script. To replace one deliberately,
delete its file under deployments/<network>/ first.
It writes deployments/sepolia/hearth.<slug>.json, which is what a keeper is pointed at
with HEARTH_ADDRESSES_FILE and what the app's pool list is generated from.
#What depends on what
flowchart TD
Vault["HearthVault"] --> IERC7984["IERC7984 (Zama cUSDC)"]
Vault --> FHE["@fhevm/solidity FHE"]
Vault --> Pool["HearthPrizePool"]
Pool --> IERC7984
Pool --> FHE
Pool --> IYield["IYieldSource"]
IYield --> Sponsored["SponsoredYieldSource"]
IYield -.-> CV["ConfidentialVaultYieldSource (mainnet design, not built)"]
Pool --> Auto["IAutomationCompatible"]
Vault --> OZ["OpenZeppelin Ownable2Step, Pausable, ReentrancyGuard"]
Pool --> OZSolid edges are contracts in this repository. The dotted node is the mainnet yield path:
the adapter is specified against Zama's published batcher interface and no adapter contract
is written here, so only SponsoredYieldSource is deployed below.
The vault and the pool each need the other, so one of the two links is made after deployment rather than in a constructor. That is why there are five steps below and not three.
#The order
| Step | Action | Why here |
|---|---|---|
| 1 | Deploy HearthVault |
It holds savers' money and needs nothing but the token to exist. |
| 2 | Deploy HearthPrizePool, pointing at the vault |
The pool reads the vault's clock and its scale count, and pays the vault. |
| 3 | Wire: vault.setPrizePool(pool) |
Emits PrizePoolSet. The vault will only accept funding from this address. |
| 4 | Deploy the yield source, pointing at the pool as recipient | It has to know where to send harvests. |
| 5 | Wire: pool.setYieldSource(source) |
Emits YieldSourceSet. Until this lands, a close harvests nothing and emits HarvestFailed. |
After step 5, seed the pool: hearth:seed --token <slug> sponsors the yield source so
prizes exist and puts five demo savers of different sizes in from accounts 2 to 6, so a
first visitor lands on a populated pool rather than an empty one. Every step of it checks
the chain for what is already done, so a seed interrupted by a relayer hiccup is safe to
run again.
The keeper for that pool also needs its own Sepolia ETH, and so do the five demo savers:
npx hardhat hearth:spread-gas --network sepolia --token weth
npx hardhat hearth:spread-gas --network sepolia --keepers 10,11,12,13,14,15 --savers falseThe first funds one pool's keeper and the savers; the second funds several keeper accounts in one pass, which is what opening six pools at once needs.
Then point the app at what was deployed:
cd ../web
node scripts/sync-pools.mjs#The parameters
HearthVault(IERC7984 asset, uint256 periodLength, uint256 firstPeriodAt, address owner)
HearthPrizePool(IHearthVault vault, IERC7984 asset, Tier[3] tiers, uint8 initialScaleBits, address owner)
Tier = { uint32 prizeCount; uint64 oddsNumerator; uint64 oddsDenominator; uint16 shares; uint16 reconcileEvery }
SponsoredYieldSource(IERC7984ERC20Wrapper asset, address recipient, uint64 ratePerSecond, address owner)#HearthVault
| Parameter | Meaning | Getting it wrong |
|---|---|---|
asset |
The ERC-7984 confidential token savers deposit, one of Zama's seven. | Every wrapper reads six decimals, and the deploy refuses to continue if the chain disagrees with the config. The rate to the public token underneath is not 1 on every pool: on the 18-decimal WETH mock it is a million million, so anything reading the public token must apply it. |
periodLength (L) |
Seconds in a period. Immutable. | Also sets the per-saver cap, (2^64 - 1) / L. Too small an L and the cap is huge but draws are noisy; too large and the cap tightens. |
firstPeriodAt |
Timestamp when period 1 starts. Immutable, and must be at or before deployment. | A future value makes period(now) undefined until it passes. |
owner |
Two-step owner. Renouncing is disabled. | The powers are listed in the threat model. |
maxPrincipal is derived from periodLength, not set. At one hour it is about 5 billion
tokens, at six hours about 854 million, and at a day about 213 million.
The vault owns the clock. The pool takes the vault address and reads periods from it, so there is no way for the two contracts to disagree about what period it is.
#HearthPrizePool
| Parameter | Meaning |
|---|---|
vault |
The vault this pool serves, and the clock it reads. |
asset |
The same confidential token the vault uses. They must match. |
prizeCount[t] |
Prizes per draw in tier t. |
oddsNumerator[t], oddsDenominator[t] |
The tier's odds as a fraction, one draw in oddsDenominator / oddsNumerator. |
shares[t] |
The tier's slice of every harvest. Shares are relative, so 40/20/40 and 2/1/2 mean the same thing. |
reconcileEvery[t] |
How many draws pass between publications of that tier's carry. |
initialScaleBits |
The expected bit length of the first period's total weight, the starting guess for the bracket tracker. |
owner |
As above. |
UTILISATION is a constant rather than an argument: 50 percent, following PoolTogether V5.
It is the fraction of a tier's plaintext liquidity used to size each prize.
Two of those deserve a word.
reconcileEvery is a privacy setting, not a gas setting, and it trades against how the
prize pot looks. Publishing a tier's carry makes that tier's prize count public, and a
count over one draw points at the small set of savers eligible in that draw. Setting it
higher spreads the count over a span in which nearly everybody was eligible at some point.
What that costs is the visible jackpot: a close moves all of a tier's public liquidity
into the draw and it comes back only at a reconcile, so a tier at a cadence of 24
publishes a prize sized off one draw's harvest share on 23 draws out of 24, with the
accumulated pot showing in the open only on the reconcile draw. The money is offered and
winnable the whole time inside the encrypted carry; it is just invisible. Sepolia runs all
three tiers at 1 for that reason and states the per-draw count as a residual. See
limitation 14.
initialScaleBits only has to be close. The tracker compares the real total against five
powers of two around the current guess at every close and corrects itself by up to three
bits per draw, so a guess that is a few bits off costs a draw or two of slightly mis-scaled
odds and then settles.
#SponsoredYieldSource
| Parameter | Meaning |
|---|---|
asset |
The ERC-7984 wrapper it holds and sends. The public token sponsors pay in is the wrapper's own underlying, so it is not a separate argument. |
recipient |
The prize pool that receives harvests. |
ratePerSecond |
How fast the sponsored balance drips out as yield. |
owner |
Sets the rate, emitting RateChanged. |
Sponsoring is a separate call after deployment, not a constructor argument. It books exactly what the wrapper minted rather than what the sponsor asked for, and it cannot be undone.
#Three parameter sets
Sepolia runs two of them, because the pools run on two clocks.
| Setting | Sepolia usdc |
Sepolia, the other six | Mainnet, candidate |
|---|---|---|---|
| Period length | 1 hour | 6 hours | 1 day |
| Window | 2 hours (two periods) | 12 hours | 2 days |
| Close deadline | 1 hour 30 minutes after the period ends | 9 hours after | 1 day 12 hours after |
| Per-saver cap | About 5 billion tokens | About 854 million | About 213 million |
| Grand tier | count 1, odds 1/24, shares 40, reconcile every draw | count 1, odds 1/4, shares 40, reconcile every draw | count 1, odds 1/30, shares 50, reconcile every draw |
| Mid tier | count 1, odds 1/6, shares 20, reconcile every draw | count 1, odds 1/2, shares 20, reconcile every draw | count 1, odds 1/7, shares 25, reconcile every draw |
| Frequent tier | count 4, odds 1, shares 40, reconcile every draw | count 4, odds 1, shares 40, reconcile every draw | count 4, odds 1, shares 25, reconcile every draw |
| Utilisation | 50 percent | 50 percent | 50 percent |
| Yield source | SponsoredYieldSource |
SponsoredYieldSource |
ConfidentialVaultYieldSource over Zama's batcher |
| Grand prize fires | About once a day | About once a day | Set by the odds chosen |
The Sepolia numbers exist so a visitor sees a full cycle in one sitting: four small prizes every draw and a grand prize about daily on either clock. They are not what a real deployment would use.
Why two clocks. A draw at five savers costs 8,456,388 gas, so seven pools drawing hourly
would spend about 1.43 ETH a day on Sepolia, which public faucets cannot keep up with. Six
hours cuts that to four draws a day per pool, about 0.41 ETH a day for all seven. The
odds are set against each pool's own period rather than carried over, which is why the
middle column reads 1/4 and 1/2 where the first reads 1/24 and 1/6, and why the grand prize
still lands about once a day in both. The USDC pool kept its hourly clock because it was
deployed first and its draw history is filed under it.
The mainnet column is a candidate, not a deployment. The rule for filling it is the same one that produced the Sepolia column: pick how many draws you want between grand prizes and set the grand tier's odds to one over that number, then set shares so the resulting prize sizes read sensibly against the yield the source actually earns, then decide each tier's reconcile cadence by weighing a prize count that names nobody against a pot savers can watch accumulate. Sepolia took the second; a mainnet deployment may take the first, and the paragraph above says what each side costs. A daily period with grand odds of 1 in 365 gives an annual grand prize, which is the shape V5 uses.
#Deployed addresses
Seven pools on Sepolia, every contract verified on Etherscan. The token pair each one holds is Zama's and is listed in pools and tokens, together with the seeded stakes and drip rate per pool.
| Pool | HearthVault | HearthPrizePool | SponsoredYieldSource | Deployed in block |
|---|---|---|---|---|
usdc |
0x0F93e5db6027b4FB1C76566d24aA2D2E417fAF52 |
0xA0785AacF30B6FE46EDc53CD8A9db1d94FeF5Df2 |
0xCC49DF69eAB6884fD8DD9260902B8A0Abc9D6b91 |
11622398 |
usdt |
0xe54F44dE64F8A7abc0647eaae547dD59ce0EFfac |
0x6a83Beb2Dc3f258107Cad5e17BC57657fAd4fbd1 |
0x5bb1Cd5380Cb9f2B15569030fF0dB7a445cF54cA |
11641314 |
weth |
0x3D1A182782B68fE270A66294C9adaC7F005c4f14 |
0x1a11e7C689F244fA8Dd5f4abA8F2F3131090cc1C |
0x40DF298f15c6136294eC651aD7b0c1C6F221DE8F |
11641366 |
bron |
0x18086DC8271f8A73c5Ea985fd519527Dbb991279 |
0x2Ed982979CD184494B947a1E38E597494a38ACe4 |
0x0cD1155D752bD81b3a437a6f0B3965CAA2A1C8e9 |
11641408 |
zama |
0xEEC26386F273c6678cA538AcA18e1d9384eA9F09 |
0x873B285404199D46325a294Aa0EC7a79C30A7fF7 |
0xdD352D70311E834ab75307f53d5C276060081d23 |
11641447 |
tgbp |
0xCe95dAa01f5354aA8887A5952E403D26d452c323 |
0xC531D54ee2c695e0eBfe8b8258e9Fd80fd507095 |
0xDEa2BD6351072F735B6ea83c357bF157d83c01af |
11641484 |
xaut |
0x77f701101d66FbD522A3bFdC2c00DB09a4F57daE |
0x9a2888aca42c707A3BC0D561FdF6ff8Abfda5201 |
0x03fDdAA7C4323C53CE511CC49D4c33B26B492af7 |
11641523 |
First period start: 1788386400 (2 September 2026, 22:00:00 UTC) for usdc,
1788620400 (5 September 2026, 15:00:00 UTC) for usdt, and
1788624000 (5 September 2026, 16:00:00 UTC) for the remaining five. firstPeriodAt is
immutable and must be at or before the deployment block, so the deploy reads the chain's
own clock and rounds down to the top of the hour, never the machine's clock.
#Verification
Verification is part of the deploy, not an afterthought. A reviewer who cannot read the deployed source has to take our word for the whole of this documentation.
- Verify all three of that pool's contracts on Etherscan with the constructor arguments
recorded by the deploy script:
hearth:verify --token <slug>does it, contract by contract, and says which were already verified. - Check that the verified constructor arguments match the parameter tables above. In
particular that the pool was given its own vault and the same
asset, and that the tier set matches the column for that pool's clock. - Check that
vault.prizePool()is that pool's prize pool andpool.yieldSource()is that pool's source, and that neither points at another pool's contracts. - Check the token:
assetshould be the confidential wrapper for that pool from Zama's published Sepolia list, andunderlying()should be the public mock beneath it. The wrapper'srate()is 1 only where the public token also reads six decimals; on the WETH pool it is a million million, and a rate other than 1 changes what a base unit means for anything touching the public token. - Read
pool.scaleBits()after a few draws and check it has settled near the bit length the pool's real size implies. A tracker stuck far from that would mean the initial guess was wildly off and the correction has not caught up.
#Secrets
Nothing sensitive is ever hardcoded. The deploy reads from a .env file, and
.env.example lists every key with a comment on where its value comes from. The
deployer's key and the keeper's key are separate accounts, so the keeper's hot key has no
owner powers.
#Hosting the app
The app is a Next.js workspace package, not the repository root, which is the only setting most hosts get wrong.
| Setting | Value | Why |
|---|---|---|
| Framework preset | Next.js | Detected from packages/web/package.json |
| Root directory | packages/web |
The app lives in an npm workspace |
| Include source files outside the root directory | On | Dependencies are hoisted to the repository root, and the build needs the root package.json and lockfile |
| Install command | the default, npm install |
Runs at the repository root and installs the whole workspace |
| Build command | the default, next build |
With the root directory set, it runs inside packages/web |
| Output directory | the default, .next |
See the warning below |
| Node version | 20 or newer | The root package.json sets engines.node |
Do not set NEXT_DIST_DIR in a hosted environment. packages/web/next.config.ts reads it
and moves the build output when it is present. It exists so a local verification build does
not fight a running dev server over the same .next directory. In a hosted build it would
move the output away from where the host looks for it, and the deploy would fail with
nothing obvious to point at.
#Environment variables
| Variable | Public in the browser | Where its value comes from |
|---|---|---|
SEPOLIA_RPC_URL |
No | Your own Sepolia endpoint. The landing page and the /api/activity route read the chain on the server, so this one never reaches a browser. Log queries need it, because the free public node caps eth_getLogs ranges far below a day of blocks |
NEXT_PUBLIC_SEPOLIA_RPC_URL |
Yes | Optional. The wallet reads use it and fall back to https://ethereum-sepolia-rpc.publicnode.com when it is unset. Visible in the bundle, so it must be one you are happy to publish |
NEXT_PUBLIC_CHAIN_ID |
Yes | 11155111 for Ethereum Sepolia. The app defaults to it if unset |
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID |
Yes | Optional, and free from Reown's dashboard at https://dashboard.reown.com. Set it and every connect screen offers "Scan with a phone" beside the browser extension, which is how a phone wallet and a machine with no extension get in. Left blank the connector is not built at all, so nobody is offered a button that fails at the moment they scan |
No contract address is an environment variable any more. The app reads every pool from
packages/web/src/lib/chain/pools.json, which node scripts/sync-pools.mjs generates from
the address files the deploy script wrote, so an address the app shows can always be traced
to a deployment record rather than to something somebody typed. Run that script after every
deploy and commit the result. The three public variables that used to hold one pool's
vault, prize pool and yield source addresses are gone; delete them from any environment
that still sets them, because nothing reads them.
The confidential asset and its underlying ERC-20 are also read from the vault and the wrapper on chain, so the app cannot talk to a token the vault would refuse.
#After the first deploy
- Open the production URL on a phone. Every page has to work at 375 pixels wide.
- Connect a wallet on Sepolia and walk the two-minute path from the README against the deployed site rather than localhost.
- Open
/verify?pool=<slug>and paste a saver's address. The thresholds come from a contract call, so if they render, the deployed app is talking to that pool's deployed vault. - Open the pool picker and check every slug loads its own dashboard, and that the restricted token shows its refusal page rather than a broken screen.
#What this page does not cover
It does not cover running the pools after deployment, which is the keeper, and one keeper process per pool is part of that page. It does not cover mainnet operational readiness: the Confidential Vault adapter is specified against Zama's published batcher interface and is not implemented in this repository, and taking it live is described in yield source.