HESTIAdocs

Smart contracts

HestiaPool, the history-keeping Merkle tree, the association registry, the generated verifiers, and on-chain Poseidon.


Settlement lives in a small set of Solidity contracts on Base. They verify proofs, keep the commitment tree and nullifier set, gate spends on approved association roots, and move the underlying ERC-20 / ETH. They never see a plaintext note.

The contracts

ContractRole
HestiaPoolThe pool. Holds funds; handles shield and the transact entrypoints.
MerkleTreeWithHistoryThe depth-32 commitment tree with a rolling window of the last 64 roots.
AssociationSetRegistryRecords ASP-published association roots and revocations.
TransactionVerifier1x2 / …2x2Generated Groth16 verifiers, one per arity.
Poseidon (T2/T3/T6)circomlib-generated hash bytecode, deployed via CREATE.

Live deployment — Base mainnet

The contracts are live on Base mainnet (chain 8453). The Labs Console and the SDK in this app are wired to these addresses.

The pool is non-custodial and immutable — its verifiers, Poseidon hashers, registry, and USDC address are constructor-set immutables, so the wiring above is fixed for the life of the contract. The Groth16 keys behind the on-chain verifiers come from a fresh setup made specifically for this deployment — see circuits. All four source contracts are verified on Basescan; Poseidon is CREATE-deployed bytecode (no source).

shield works as soon as the pool is live. Spends (send / unshield) additionally require the spend's association root to be approved in the registry — the deployer is authorized as the ASP and the operator publishes roots as the pool grows. See enabling spends.

HestiaPool

The pool exposes a deposit entrypoint and two spend entrypoints (the human-readable ABI used by the indexer and relayer):

solidity
function shield(
  address token, uint256 amount, uint256 ownerSK, uint256 randomness, bytes encryptedNote
) payable returns (uint256 leafIndex, uint256 commitment);

struct TransactData {
  uint256 root; uint256 associationRoot; uint256 withdrawAmount;
  address token; address recipient; uint256 feeAmount; address relayer;
}

function transact1x2(
  uint256[2] a, uint256[2][2] b, uint256[2] c,
  uint256[1] nullifiers, uint256[2] outCommitments,
  TransactData d, bytes[2] encryptedNotes
);

function transact2x2(/* … uint256[2] nullifiers … */);

function getLastRoot() view returns (uint256);
function nextLeafIndex() view returns (uint256);

shield pulls amount of token (or accepts ETH as msg.value when the token is the zero address), computes the commitment from ownerSK + randomness + the deposit's label, appends a leaf, stores the encrypted blob, and emits Shield.

transact1x2 / transact2x2 are the spend paths. Each:

  1. checks d.root is one of the last 64 known tree roots;
  2. checks d.associationRoot is approved by the registry;
  3. verifies the Groth16 proof (a, b, c) against the public signals via the generated verifier;
  4. ensures each nullifier is unspent, then records it;
  5. appends outCommitments as new leaves and stores encryptedNotes; and
  6. pays withdrawAmount to recipient and feeAmount to relayer (both zero for a pure private send).

Because recipient, withdrawAmount, feeAmount, and relayer are part of the verified public signals, no submitter can change them.

Events

The indexer reconstructs all off-chain state from these:

solidity
event Shield(uint256 indexed commitment, uint256 leafIndex, uint256 label, address token, uint256 amount, bytes encryptedNote);
event Commitment(uint256 indexed commitment, uint256 leafIndex, bytes encryptedNote);
event Nullified(uint256 indexed nullifier);
event Unshield(address indexed token, address indexed recipient, uint256 amount, address relayer, uint256 fee);

Shield and Commitment both carry (commitment, leafIndex, encryptedNote) — they are the two leaf sources (deposits and transaction outputs). Nullified marks a spent note. Unshield records a public withdrawal.

MerkleTreeWithHistory

The pool's tree keeps not just the current root but the last 64. A proof built against any of them is accepted, so a transaction has a window to be proven and mined even as other deposits advance the tree. See the commitment tree.

AssociationSetRegistry

ASPs publish and revoke association roots here; the pool consults it on every spend:

solidity
event RootPublished(address indexed asp, uint256 indexed root, string uri);
event RootRevoked(address indexed by, uint256 indexed root);
function isValidRoot(uint256 root) view returns (bool);

The uri lets an ASP point to the off-chain set behind a root (its membership and policy).

On-chain Poseidon

The pool hashes with the same Poseidon the circuits use. The permutations (T2, T3, T6) are circomlib-generated bytecode deployed via CREATE, so the contract's commitment and tree hashes match @hestia/common and the circuit exactly. Conformance tests assert this equality; it is what lets an SDK-generated proof verify on-chain.

Deployment notes

  • Deploy order and wiring are scripted (Foundry). The pool must be pointed at the deployed verifiers, the Poseidon contracts, and the registry.
  • Verify the USDC address for the target chain at deploy time — base and baseSepolia differ (see parameters). The live deployment uses Base-mainnet USDC, confirmed against the deployed pool's usdc() immutable.
  • The on-chain verifiers are generated from the ceremony zkey. The open-source repo ships a reproducible dev ceremony; the live deployment above was built from a separate fresh-entropy setup whose secret was discarded — see circuits.
  • The contracts have not had an external audit; treat large mainnet value accordingly.