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.
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/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.
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
});// 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 });// 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
});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.
// 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:
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
}
}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.
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>
);
}