2.0 RC docsView 1.x docs
Testnet Faucet 2.0 · Quickstart

Mint TTT and CTTT on Sepolia in one client call.

Install the 2.0 SDK, create a faucet client, mint both test tokens, read both balances.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.
Sepolia only
The faucet has no mainnet deployment by design, and the client throws UnsupportedChainError on any chain outside TESTNET_FAUCET_SUPPORTED_CHAIN_IDS. Both tokens mint openly to anyone and carry no value.

1. Connect a wallet

The SDK only consumes a publicClient plus an optional walletClient. Reads work with the public client alone; the two mints need a wallet on Sepolia to sign. A wallet on a different chain from the public client is refused with WalletChainMismatchError before anything is sent.

2. Get Sepolia ETH

The mints are real transactions and need gas. This faucet dispenses TTT and CTTT, not ETH. Google Cloud's Web3 faucet (cloud.google.com/…/sepolia) and Alchemy's (alchemy.com/faucets/ethereum-sepolia) both drip enough for a few mints.

3. Install

The 2.0 line is published on the next dist-tag; latest still resolves 1.x. viem is a peer. Add wagmi and @tanstack/react-query only for the hooks under @tokenops/sdk/testnet-faucet/react.

pnpm add @tokenops/sdk@next viem
No Zama peer needed
Unlike /fhe-vesting, /fhe-airdrop and /fhe-disperse, the faucet takes no encryptor and imports nothing from @zama-fhe/sdk. You need the Zama peer only to decrypt a CTTT balance.

4. Create the client and mint

One constructor, two open mints. Each mint estimates its gas and sends it with 25% headroom; a per-call gas replaces the estimate. Mints always wait for their receipt.

lib/faucet.ts
ts
import { createPublicClient, createWalletClient, custom, http } from "viem";
import { sepolia } from "viem/chains";
import { TestnetFaucetClient } from "@tokenops/sdk/testnet-faucet";

const publicClient = createPublicClient({ chain: sepolia, transport: http() });
const [account] = (await window.ethereum.request({
  method: "eth_requestAccounts",
})) as [`0x${string}`];
const walletClient = createWalletClient({
  account,
  chain: sepolia,
  transport: custom(window.ethereum),
});

// No encryptor and no @zama-fhe dependency: faucet mints take plaintext amounts.
// The CTTT address resolves from DEPLOYED_ADDRESSES on Sepolia; TTT is read
// from the CTTT wrapper's own underlying() getter.
const faucet = new TestnetFaucetClient({
  publicClient,
  walletClient,
  // gasHeadroomPercent: 25, // the default; 0 sends the bare estimate
});
lib/mint-ttt.ts
ts
// TTT: plain 18-decimal ERC-20, open mint(to, amount).
const ttt = await faucet.mintUnderlying({
  amount: 100n * 10n ** 18n, // 100 TTT
});

console.log({ hash: ttt.hash, to: ttt.to, amount: ttt.amount });
lib/mint-cttt.ts
ts
// CTTT: 6-decimal ERC-7984 wrapper. The open mint is backed: it mints the
// TTT behind it first, then credits the confidential balance.
const cttt = await faucet.mintConfidential({
  amount: 50n * 10n ** 6n, // 50 CTTT (6 decimals, not 18)
  // gas: 400_000n, // optional per-call limit, sent as is (skips the estimate)
});

console.log({
  hash: cttt.hash,
  to: cttt.to,
  amount: cttt.amount,
  underlyingMinted: cttt.underlyingMinted, // amount * rate, in TTT base units
  handle: cttt.handle, // the minted euint64 handle, informational
});
TTT is 18 decimals, CTTT is 6
underlyingDecimals() returns 18 and decimals() returns 6. Scale each amount for its token or it is off by 1012.

5. Read the balances

The TTT balance is a bigint. The CTTT balance is a euint64 handle; a never-credited account reads the zero handle.

lib/balances.ts
ts
// Plaintext TTT balance: a bigint in 18-decimal base units.
const tttBalance = await faucet.underlyingBalanceOf();

// Confidential CTTT balance: an encrypted euint64 handle (Hex), not a number.
// The account holds persistent ACL on it, so user-decrypt it with your own
// Zama SDK instance and the CTTT address (faucet.address).
const ctttHandle = await faucet.confidentialBalanceOf();

Decryption is covered in Decryption. On the wrong chain, the constructor tells you what to fix:

lib/wrong-chain.ts
ts
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
import {
  TestnetFaucetClient,
  UnsupportedChainError,
} from "@tokenops/sdk/testnet-faucet";

try {
  new TestnetFaucetClient({
    publicClient: createPublicClient({ chain: mainnet, transport: http() }),
  });
} catch (err) {
  if (err instanceof UnsupportedChainError) {
    err.context.chainId; // 1
    err.context.method; // "TestnetFaucetClient"
    err.context.hint; // says the faucet runs on Sepolia only, and how to point the clients there
  }
}
Supply can run out
The CTTT mint reverts once the wrapper's backing reaches maxTotalSupply(); the SDK maps the ERC7984TotalSupplyOverflow revert to FaucetSupplyExhaustedError. It is a shared ceiling on a public faucet, not a per-caller limit.

6. The same thing in React

Inside a standard WagmiProvider and QueryClientProvider. Read hooks take query for TanStack options. On an unsupported chain the reads stay disabled and the mint rejects with the typed error; nothing throws during render.

components/FaucetPanel.tsx
tsx
import { useQueryClient } from "@tanstack/react-query";
import {
  TESTNET_FAUCET_KEY,
  TESTNET_FAUCET_NAMESPACE,
  useConfidentialBalance,
  useMintConfidential,
  useUnderlyingBalance,
} from "@tokenops/sdk/testnet-faucet/react";

export function FaucetPanel() {
  const queryClient = useQueryClient();
  const mint = useMintConfidential();
  const { data: ttt } = useUnderlyingBalance({
    query: { refetchInterval: 15_000 },
  });
  const { data: handle } = useConfidentialBalance();

  return (
    <div>
      <p>TTT: {ttt?.toString() ?? "..."}</p>
      <p>CTTT handle: {handle ?? "..."}</p>
      <button
        disabled={mint.isPending}
        onClick={() =>
          mint.mutate(
            { amount: 50n * 10n ** 6n },
            {
              // Mutations do not invalidate reads; refresh every faucet query.
              onSuccess: () =>
                queryClient.invalidateQueries({
                  queryKey: [TESTNET_FAUCET_KEY, TESTNET_FAUCET_NAMESPACE],
                }),
            },
          )
        }
      >
        Mint 50 CTTT
      </button>
      {mint.error ? <p>{mint.error.message}</p> : null}
    </div>
  );
}

Where to go next