Skip to main content
Version: 0.5.0

Faucet

The faucet is a testnet-only static Cloudflare Pages app that hands out 100 each of zkUSD, zkEUR, zkGBP, and zkPLN to a user-supplied recipient. It distributes from a pre-minted supply held by a faucet account — it does not mint. A single deployment serves every chain listed in CHAIN_IDS; the client picks the target chain per request. It exists for onboarding and demos, not for the main deployment.

The recipient can be either:

  • a public 0x… EVM address — receives the tokens in its public ERC-20 balance, or
  • a zk1… address — receives the tokens directly into its encrypted balance.

User flow

The app serves a single static page:

  1. The user picks a chain (the selector is populated from GET /api/chains, which returns { chains: [{ chainId, name }] }) and enters a 0x… or zk1… address (a 0x… address can be filled from an injected wallet such as MetaMask).
  2. The page submits { address, chainId } to POST /api/mint (public) or POST /api/mint-encrypted (zk1), chosen by the address format.
  3. The API returns { success: true, userOpHash } after the bundler accepts the UserOperation.

There is no receipt polling, CAPTCHA, password, rate limit, queue, or user wallet signature. The connected wallet is only an address source.

Distribution mechanism

The faucet account (FAUCET_PRIVATE_KEY) holds the pre-minted supply and signs an EIP-712 authorization for each token. The keyless SharedAccount forwards those signed calls as a single sponsored ERC-4337 UserOperation, and the paymaster pays the gas. The faucet account therefore never sends a transaction itself and needs no ETH — its only role is signing.

Each route builds one UserOperation batching the four tokens:

RouteRecipientPer-token callSelector
POST /api/mint0x… public addresstransferWithAuthorization(from,to,value,validAfter,validBefore,nonce,signature)0xcf092995
POST /api/mint-encryptedzk1… addresspublicToEncryptedTransferWithAuth(owner,recipient,amount,auth)0x26f4704d

Common fields:

FieldValue
Chainchosen per request (chainId), validated against the CHAIN_IDS allowlist
SenderSharedAccount (per-chain, from that chain's manifest)
Targetthe four ZKEMT token contracts
Amount100_000000 base units per token (100, 6 decimals)
Signerfaucet account (FAUCET_PRIVATE_KEY) — the authorization from/owner
Paymaster flowzk_requestGasAndPaymasterData through createZkBundlerClient

Both authorizations bind the recipient into the signed digest, so the SharedAccount can forward the call but cannot redirect funds.

The route must not fall back to a direct EOA transaction. The faucet relies on the stage Paymaster Backend running in open-sponsorship mode and sends an empty partner context; if the stage paymaster is reconfigured with OPEN_SPONSORSHIP=false, the faucet must be reissued partner credentials and the partner-context signing path restored.

Encrypted route requires a registered recipient

publicToEncryptedTransferWithAuth deposits into the recipient EPK's encrypted balance, which requires the EPK to be registered (have a controller) on the Hub. Registration is Hub-level, so one registration covers all four tokens. The route checks registration first and returns 400 with a clear message if the zk1… address has not been registered yet.

Deployment configuration

Required Cloudflare Pages environment variables. Chain-independent creds are shared across every chain; only the read RPC is per-chain:

VariablePurpose
CHAIN_IDSAllowlist of chain ids this deployment serves (comma-separated, e.g. 84532,8453). Each must be a chain the SDK knows; drives /api/chains and the per-request chainId check.
FAUCET_PRIVATE_KEYThe faucet account holding the pre-minted supply and signing distribution authorizations. 0x-prefixed 32-byte key. Shared across chains — the same key is the same address on every chain; pre-mint to it on each.
RPC_URL_<chainId>Per-chain read RPC for the SDK's eth_call/nonce/fee lookups (e.g. RPC_URL_8453). Optional per chain; defaults to that chain's public RPC. Required for Base mainnet (RPC_URL_8453) — the public endpoint rate-limits and requests fail with "over rate limit".
PAYMASTER_URL_<chainId>Optional per-chain override for the Paymaster Backend URL (e.g. PAYMASTER_URL_31337). Defaults to the chain's deployment manifest. Used to point a local stack at container-network URLs.
BUNDLER_URL_<chainId>Optional per-chain override for the bundler RPC URL. Defaults to the chain's deployment manifest. Same use as PAYMASTER_URL_<chainId>.
PAYMASTER_PARTNER_IDOptional. Partner id registered in the Paymaster Backend. Shared across chains. Set together with the key to sign the partner context.
PAYMASTER_PARTNER_PRIVATE_KEYOptional. Partner signing key for the paymaster context. Shared across chains. Omit both to rely on open sponsorship.
ENTRYPOINT_ADDRESSOptional override; defaults come from the SDK path.
GITHUB_TOKENBuild-time only: read:packages token. The install step writes an .npmrc and fetches the published @cardinal-cryptography/core from GitHub Packages.

Bundler URL, Paymaster URL, SharedAccount address, and zk token addresses are resolved per request from the requested chain's deployment manifest in @cardinal-cryptography/core. The bundler and paymaster URLs can be overridden per chain with BUNDLER_URL_<chainId> / PAYMASTER_URL_<chainId> (e.g. to run against a local stack).

On-chain setup — repeat for each chain in CHAIN_IDS:

  1. Deploy the ZKHub and the test ZKEMT token contracts (Hub is shared across all tokens), and ensure the SharedAccount is deployed.
  2. Pre-mint the faucet supply to the faucet account's address (the address of FAUCET_PRIVATE_KEY) for each of the four tokens. Top it up as it drains.
  3. Configure that chain's Paymaster Backend to allow the four faucet token addresses and the selectors 0xcf092995 (transferWithAuthorization) and 0x26f4704d (publicToEncryptedTransferWithAuth).
  4. Fund and stake the paymaster for EntryPoint.

The faucet no longer needs any minting right: do not grant the SharedAccount (or the faucet account) minting rights. If a previous deployment enrolled the SharedAccount as a minter for the old faucet, revoke it with Hub.setMinter(SHARED_ACCOUNT_ADDRESS, false).