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:
- The user picks a chain (the selector is populated from
GET /api/chains, which returns{ chains: [{ chainId, name }] }) and enters a0x…orzk1…address (a0x…address can be filled from an injected wallet such as MetaMask). - The page submits
{ address, chainId }toPOST /api/mint(public) orPOST /api/mint-encrypted(zk1), chosen by the address format. - 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:
| Route | Recipient | Per-token call | Selector |
|---|---|---|---|
POST /api/mint | 0x… public address | transferWithAuthorization(from,to,value,validAfter,validBefore,nonce,signature) | 0xcf092995 |
POST /api/mint-encrypted | zk1… address | publicToEncryptedTransferWithAuth(owner,recipient,amount,auth) | 0x26f4704d |
Common fields:
| Field | Value |
|---|---|
| Chain | chosen per request (chainId), validated against the CHAIN_IDS allowlist |
| Sender | SharedAccount (per-chain, from that chain's manifest) |
| Target | the four ZKEMT token contracts |
| Amount | 100_000000 base units per token (100, 6 decimals) |
| Signer | faucet account (FAUCET_PRIVATE_KEY) — the authorization from/owner |
| Paymaster flow | zk_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:
| Variable | Purpose |
|---|---|
CHAIN_IDS | Allowlist 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_KEY | The 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_ID | Optional. Partner id registered in the Paymaster Backend. Shared across chains. Set together with the key to sign the partner context. |
PAYMASTER_PARTNER_PRIVATE_KEY | Optional. Partner signing key for the paymaster context. Shared across chains. Omit both to rely on open sponsorship. |
ENTRYPOINT_ADDRESS | Optional override; defaults come from the SDK path. |
GITHUB_TOKEN | Build-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:
- Deploy the
ZKHuband the testZKEMTtoken contracts (Hub is shared across all tokens), and ensure the SharedAccount is deployed. - 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. - Configure that chain's Paymaster Backend to allow the four faucet token
addresses and the selectors
0xcf092995(transferWithAuthorization) and0x26f4704d(publicToEncryptedTransferWithAuth). - 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).