— Integration docs · for engineers

Plug Zerith into your stack.

Everything an engineer needs to evaluate, integrate, or audit Zerith in production: the TypeScript SDK, the end-to-end sealed-auction lifecycle, what we protect (and what we don't), and a step-by-step recipe to verify any Zerith auction settled correctly using only an Etherscan link.

— Contents
  1. 01SDK reference
  2. 02Integration guide
  3. 03Threat model
  4. 04Verify a settlement
Section 01

SDK reference

Typed TypeScript client for posting, bidding, and settling encrypted auctions.

Zerith does not ship a bespoke client. The frontend integrates directly against two installed packages: @cofhe/sdk 0.8 (Fhenix CoFHE 1.0 — encryption, decryption, access permissions) and ethers v6 (the on-chain calls, against the published contract ABIs). Every snippet below is exactly the pattern the app uses in frontend/src/providers/CofheProvider.tsx and the feature pages — copy-paste runnable against what's installed.

Install

bash
npm install @cofhe/sdk@0.8.0 ethers

Initialize the CoFHE client

ts
import { ethers } from "ethers";
// @cofhe/sdk ships WASM — import the web entrypoints client-side only.
import { createCofheClient, createCofheConfig } from "@cofhe/sdk/web";
import { Ethers6Adapter } from "@cofhe/sdk/adapters";
import { chains } from "@cofhe/sdk/chains";

const provider = new ethers.JsonRpcProvider(SEPOLIA_RPC_URL);
const signer = new ethers.Wallet(privateKey, provider);

const cofhe = createCofheClient(
  createCofheConfig({ supportedChains: [chains.sepolia] }),
);
const { publicClient, walletClient } = await Ethers6Adapter(provider, signer);
await cofhe.connect(publicClient, walletClient);

Most-used methods

cofhe.encryptInputs([Encryptable.uint64(v)]).setConsumingContract(addr).execute()
Encrypt client-side with a ZK proof. Inputs are bound to the contract that consumes them, and a batch shares one signature: the result is [...handles, signature], which maps straight onto (externalEuint64 handle, bytes proof) contract arguments.
cofhe.decryptForView(ctHash, FheTypes.Uint64).withACP().execute()
Decrypt a handle your address is allowed to read, using your self ACP (Access Control Permission). Other accounts are refused.
cofhe.decryptForTx(handle).withoutACP().execute()
Fetch a Threshold-Network-signed (value, signature) pair for a handle the contract made public, then submit it on-chain (e.g. confirmLot, reveal). Anyone can do this.
cofhe.acp.getOrCreateSelfACP()
Ensure you have an active self ACP. The first call asks your wallet to sign once.
new ethers.Contract(addr, abi, signer)
Plain ethers v6 contract instance for the on-chain calls. Addresses are in deployed-addresses.json.

End-to-end example

ts
import { Encryptable, FheTypes } from "@cofhe/sdk";

// cofhe + signer are set up as above. token / vault / sealedAuction are
// ethers.Contract instances built from deployed-addresses.json + the ABIs.

// 1. Fund your vault. Bids lock funds from this balance, so deposit first.
//    Let the vault operate your ERC-7984 tokens for a short window only — an
//    operator can move your whole balance (approve() reverts by design).
const until = Math.floor(Date.now() / 1000) + 600; // 10 minutes
await (await token.setOperator(vaultAddr, until)).wait();
const [depositHandle, depositProof] = await cofhe
  .encryptInputs([Encryptable.uint64(500_000000n)]) // 500 tokens, 6 decimals
  .setConsumingContract(vaultAddr)
  .execute();
await (await vault.deposit(tokenAddr, depositHandle, depositProof)).wait();

// 2. Bid on auction #0. The amount is locked from your vault balance;
//    if you can't cover it, nothing is locked and the bid can't win.
const [bidHandle, bidProof] = await cofhe
  .encryptInputs([Encryptable.uint64(120_000000n)])
  .setConsumingContract(auctionAddr)
  .execute();
await (await sealedAuction.bid(0, bidHandle, bidProof)).wait();

// 3. Unseal your own bid (the amount actually locked). Only you can.
await cofhe.acp.getOrCreateSelfACP();
const myBid = await cofhe
  .decryptForView(BigInt(await sealedAuction.getMyBid(0)), FheTypes.Uint64)
  .withACP()
  .execute();
Section 02

Integration guide

The end-to-end sealed-auction lifecycle, contract calls, and event hooks.

Every bid and the seller's lot are locked in the settlement vault, so a sale settles atomically from escrow: the winner always pays exactly the winning bid and always receives the lot. After creation, every step except cancelling can be triggered by anyone.

State machine

PENDING_LOT
Created; the lot was locked from the seller's vault. Waiting for the Threshold Network to confirm the full lot was there.
OPEN
Lot confirmed. Accepts encrypted bids until the deadline (late bids extend it).
CLOSED
Anyone closed it after the deadline. The outcome handles are now publicly decryptable.
REVEALED
Threshold-Network-signed winner and winning bid were verified on-chain.
SETTLED
Escrow paid the seller (minus the 2% fee) and sent the lot to the winner. Losers claim refunds.
CANCELLED
Unfunded lot, no bids by the deadline, or the seller cancelled before any bid. The lot goes back.
NO_SALE
Blind Floor only: the reserve wasn't met. Nothing about any bid is revealed; the lot and all bids are refunded.

Lifecycle, by contract call

ts
// ─── Seller ─── (deposit the lot into the vault first)
await sealedAuction.createAuction(lotToken, paymentToken, lotAmount, duration, snipeExtension);
// Anyone: publish the Threshold Network's check that the lot was fully locked
const lot = await cofhe.decryptForTx(await sealedAuction.getLotFundedHandle(id)).withoutACP().execute();
await sealedAuction.confirmLot(id, lot.decryptedValue === 1n, lot.signature);

// ─── Bidders ─── (each bid locks funds from the bidder's vault balance)
const [h, proof] = await cofhe.encryptInputs([Encryptable.uint64(amount)])
  .setConsumingContract(auctionAddr).execute();
await sealedAuction.bid(id, h, proof);

// ─── Anyone, after the deadline ───
await sealedAuction.closeAuction(id);
const [okH, bidH, winnerH] = await sealedAuction.getRevealHandles(id);
const ok = await cofhe.decryptForTx(okH).withoutACP().execute();
const bid = await cofhe.decryptForTx(bidH).withoutACP().execute();
const win = await cofhe.decryptForTx(winnerH).withoutACP().execute();
await sealedAuction.reveal(id, ok.decryptedValue === 1n, ok.signature,
  bid.decryptedValue, bid.signature, toAddress(win.decryptedValue), win.signature);
await sealedAuction.settleAuction(id);

// ─── Each losing bidder ─── (refund stays encrypted; it is never decrypted)
await sealedAuction.claimRefund(id);

Events to subscribe to

AuctionCreated
auctionId, seller, token, amount, duration
LotConfirmed
auctionId, funded, deadline
BidPlaced
auctionId, bidder, newDeadline (anti-snipe extension)
AuctionClosed
auctionId
WinnerRevealed
auctionId, winner, winningBid (plaintext)
NoSale
auctionId — Blind Floor reserve not met
AuctionSettled
auctionId — escrow paid out
RefundClaimed
auctionId, bidder

None of these events leak losing-bidder information. The chain stores the full set of encrypted bids forever, but they remain cryptographically inaccessible.

Section 03

Threat model

What Zerith protects against, what it does not.

What we protect

  • Losing bid amounts.

    Encrypted on-chain forever. No FHE.allowGlobal is ever called on losing handles. Even the protocol deployer cannot decrypt them.

  • Cross-account decryption.

    The threshold network refuses requests where the caller doesn't own the handle. Bidder #2 cannot unseal Bidder #1's bid.

  • Pre-trade leakage to MEV.

    Bid amounts are ciphertext at submission. Searchers see encrypted input handles, not numbers. There is nothing to sandwich.

  • Reserve price (Blind Floor mode).

    The encrypted reserve is never decrypted. The chain publishes only whether the sale went through, and if it didn't, neither the winner nor the winning bid is revealed.

What we do NOT protect (yet)

  • Bidder identity.

    Bidder addresses are public on-chain. To anonymize who bid, route through a fresh wallet (the burner flow) or a privacy mixer at the transaction level. We do not bundle that.

  • Auction existence + size.

    The fact that an auction was posted, by whom, for what token quantity, on what deadline — all public. We seal the prices, not the trade itself. This is intentional: foundations want their sale to be discoverable.

  • Threshold network availability.

    Lot confirmation and the reveal need the Threshold Network to co-sign. If it's unreachable, the sale is delayed, not lost: closing and revealing are open to anyone and can be retried the moment the network returns. While it's down, escrowed bids and the lot stay locked; there is no on-chain timeout that refunds them without a reveal.

  • On-chain censorship / mempool ordering.

    MEV searchers cannot front-run the bid (they can't read it), but they can reorder transactions inside a block. For sealed auctions this only affects bid arrival timing within anti-snipe windows.

Cryptographic assumptions

FHE security
Fhenix CoFHE uses TFHE-rs. Security reduces to standard learning-with-errors hardness assumptions. We rely on the upstream library; we do not roll our own crypto.
Threshold network
Decryptions are performed by Fhenix's Threshold Network. On-chain, contracts verify one ECDSA signature from it (FHE.publishDecryptResult). Any quorum among its operators happens off-chain, so this is a trust assumption on Fhenix's network, which can in principle decrypt any handle.
Access permissions (ACPs)
Each user signs a self ACP once. It gates decrypt-to-view of their own values (balance, bid). A leaked ACP lets someone read only what that user can read, and ACPs can be revoked on-chain.
Section 04

Verify a settlement

From an Etherscan link, prove a Zerith auction cleared correctly.

This is the load-bearing check. Anyone with an Etherscan link can verify a Zerith auction settled correctly without trusting us, without running a node, without an account.

The headline tx

The canonical example we point reviewers at:

— EtherscanEscrowed sealed sale · 3 bidders · winner revealed0x609f93426d4374df8f249f24c8e293c3f97bd62072719ddc4bc4576871bca8cb

Step-by-step

  1. Open the reveal tx on Etherscan. Confirm To: matches the SealedAuction address in our deployed-addresses.json.
  2. Decode the input data. Etherscan's "Decode Input Data" reveals the function call: reveal(auctionId, saleOk, sig, winningBid, sig, winner, sig). Note the plaintext winningBid and winner — these were just decrypted by the threshold network and verified on-chain.
  3. Read the SealedAuction source. It's verified on Etherscan, so you can check it matches this repository. Confirm that reveal() calls FHE.publishDecryptResult(handle, value, signature) — this is the precompile that verifies the threshold network signature against the ciphertext handle. If it returns false, the call reverts.
  4. Read the storage slot for losing bids. bids[auctionId][bidderAddr] stores the encrypted handle for every bidder. Anyone can read these slots; nobody can decrypt them. The losing bid handles for this auction sit in storage forever.
  5. Try to decrypt a losing bid yourself. Call cofhe.decryptForTx(handle).withoutACP().execute() from a wallet that didn't place that bid. The threshold network refuses — we never called FHE.allowGlobal on losing handles, so they remain owner-restricted.

Spot-check the math

For the headline tx specifically:

Bidders
300 and 450 CDEX (each funded), plus 5,000 from a wallet with an empty vault
Winner revealed
the 450 bidder ✓ — the unfunded 5,000 bid locked nothing and couldn't win
Losing bids
the 300 bid stays an encrypted handle forever; it was refunded as ciphertext
Verify yourself

— Need more depth

Source-level documentation lives in the repo. The full reviewer replay path, every verified Sepolia transaction, and the launch QA results are linked from the README.