This Repo contains the code for the celo testnets faucet. This is contained in 2 apps.
-
The firebase app contains functions which do the actual fauceting.
-
The web app contains a UI for making requests.
The web app deploys automatically to vercel.
The deploy-chains gh actions deploys functions to staging and production environments from staging and master branches respectively
Note other branches are not deployed automatically but can be by manually triggering the flow
To set up the web app to run locally:
-
navigate to the
apps/webfolder$ cd apps/web -
link your local repository to the
faucetproject on Vercel$ yarn dlx vercel@latest link
You'll be asked to authenticate with your Vercel account. Once you've done that, you'll be guided through a series of prompts to link your local project to the
faucetVercel project.? Set up “~/Documents/celo-org/faucet/apps/web”? [Y/n] y ? Which scope should contain your project? Celo Ecosystem Project Hosting ? Link to existing project? [y/N] y ? What’s the name of your existing project? faucet ✅ Linked to c-labs/faucet (created .vercel) -
fetch environment variables from Vercel
$ yarn dlx vercel@latest env pull
If you get an error like
Error! No project found, you may need to runvercel linkagain. If everything worked, you should see a message like this:> Downloading `development` Environment Variables for Project faucet ✅ Created .env.local file [249ms]
-
run the app locally
$ yarn dev
You should see a message like this:
ready - started server on 0.0.0.0:3000, url: http://localhost:3000 info - Loaded env from /Users/arthur/Documents/celo-org/faucet/apps/web/.env.local
You can now view the app in your browser at http://localhost:3000.
To set up the firebase app to run locally:
- navigate to the
apps/firebasefolder$ cd apps/firebase - login to firebase
You'll be asked to authenticate with your Firebase account.
$ yarn dlx firebase-tools@latest login
- build the firebase app
$ yarn run preserve
- ensure that you are on required node version specified in
engines.nodeinfirebase/package.json. Currently this is Node 24 at the time of writing.$ nvm use <the-required-node-version>
- run the firebase app locally
$ yarn run serve
The browser flow is gated on reCAPTCHA v3, which scores headless callers badly and cannot be solved without driving a real browser. Scripts and AI agents use an API key instead.
This section used to be the only place the API was documented, which meant an agent had to fetch and parse a README on GitHub to learn how to behave. The same contract is now published on the faucet's own domain, in formats meant to be read by a machine:
| File | For |
|---|---|
/openapi.json |
The schema. Endpoints, request and response shapes, every error code. |
/llms-full.txt |
The full reference as prose, for assistants that read rather than call. |
/llms.txt |
The short index. |
Keep this section and those files in agreement. apps/web/tests/geo-assets.test.ts
asserts the published limits and payout amounts still match the constants the
code enforces, but the prose is on you.
Sign in with GitHub at /keys and create one.
The key is shown once and only a hash of it is stored, so it cannot be
recovered — create a new one if you lose it. You may hold up to 2 keys and they
expire after 90 days.
A key minted the way above is a human's credential handed to an agent. The faucet cannot tell one agent from another, or an agent from the person running it, because its only notion of identity was a GitHub account — and an agent cannot hold one.
An agent registered under ERC-8004 has an identity of its own, so it can mint its own key. Two calls, no browser, no OAuth, no human:
# 1. Ask for a challenge.
curl -X POST https://faucet.celo.org/api/keys/challenge \
-H 'Content-Type: application/json' \
-d '{"agentId":"2"}'
# → {"message":"…","nonce":"…","expiresAt":…,"agentWallet":"0x…"}
# 2. Sign that message with the agent wallet, then exchange it.
curl -X POST https://faucet.celo.org/api/keys/agent \
-H 'Content-Type: application/json' \
-d '{"agentId":"2","nonce":"…","signature":"0x…"}'
# → 201 {"key":"cfk_prd_…","record":{…}}The key is bound to the agent id rather than to a person, so it draws on the agent's own daily allowance. That allowance grows with the age of the registration: an agent registered less than 7 days ago gets 2 requests a day, and an established one gets 10, the same as a signed-in person. Registering an agent costs a fraction of a cent, so without this one wallet could mint twenty identities and fill the shared programmatic ceiling on its own. The tier is decided at request time from the registration timestamp recorded on the key, so an agent grows into the larger allowance without minting a new key.
To revoke, ask for a challenge with "action":"revoke" and send the proof to
DELETE /api/keys/agent with the keyId. Omit keyId to revoke every key the
agent holds, which is the recovery path after a signing-key compromise: the
attacker's keys were never shown to the rightful agent, so it cannot name them.
A challenge names its action in the signed text, so a mint signature cannot be
turned into a revoke or the other way round.
Design notes, since each of these is load-bearing:
- Mainnet registry only,
0x8004A169FB4a3325136EB29fA0ceB6D2e539a432on chain 42220. Registering on Celo Sepolia costs Sepolia gas, which is what this faucet hands out — a testnet registry would let an attacker faucet its way to as many identities as it wanted. - No fallback to
ownerOf. An agent whosegetAgentWalletis unset is refused withagent_wallet_unset.ownerOfis whoever holds the NFT, often a human or a multisig, so accepting it would authenticate the operator while reporting that an agent had signed. Agent 1 on mainnet is in exactly this state today, so this is the common case, not an edge one. - The challenge is spent before the signature is verified. Verifying first would leave a window in which two requests carrying the same signature both verify and both mint.
- The signed message is rebuilt server-side from the stored expiry, never taken from the request, so a caller cannot present a signature over text of its own choosing. It carries the domain, registry and chain so a signature gathered elsewhere cannot be replayed here.
- ERC-1271 is honoured, so agents using smart accounts work.
Sybil resistance is unchanged and deliberately modest: mainnet registration costs only gas. What bounds exposure is the programmatic ceiling that already existed — 20 requests per 10 minutes and 200 per day across every key. See discussion #286 for the x402 and reputation phases that turn "capped" into "costly".
Verify the whole path against a deployment with no cookie jar at all. You need
the signing key of a registered agent's wallet. If you do not hold one, register
a throwaway agent first: every register() overload in the registry sets the
agent wallet to the calling address, so one transaction from any wallet you
control (a fraction of a cent of mainnet CELO) gives you an agent id whose key
you already have.
export AGENT_SIGNING_KEY=0x... # any wallet with a little mainnet CELO
yarn --cwd apps/web register:smoke-agent # prints the new agent id
yarn --cwd apps/web smoke:agent-key \
--url https://faucet.celo.org --agent-id <id> --beneficiary 0xYourAddressNeither script prints the key, and neither falls back to any other wallet
variable: to sign with a key held under a different name, set
AGENT_SIGNING_KEY_ENV to that name. The smoke run revokes the key it minted at
the end, so it leaves the agent's two slots as it found them and exercises the
revoke path live.
Send the key as a bearer token and omit captchaToken:
curl -X POST https://faucet.celo.org/api/faucet \
-H "Authorization: Bearer cfk_prd_…" \
-H 'Content-Type: application/json' \
-d '{"beneficiary":"0xYourAddress","network":"celo-sepolia"}'A successful call returns HTTP 200 with {"status":"Pending","key":"…"}. The
queueing is carried by the status field, not by the status code: the payout is
accepted but not yet settled. Poll for the outcome with that key:
curl "https://faucet.celo.org/api/status?key=<key>&network=celo-sepolia"
# {"status":"Done","beneficiary":"0x…","txHash":"0x…"}Other responses carry a machine-readable error code so you can branch without
parsing prose:
| Status | error |
Meaning |
|---|---|---|
400 |
— | Invalid network or beneficiary address |
401 |
invalid_api_key |
Missing, malformed, unknown or expired key |
401 |
api_key_disabled |
Programmatic access is switched off |
429 |
faucet_limit_exceeded |
Rate limited; honour Retry-After |
503 |
faucet_unavailable |
A dependency is down; honour Retry-After |
405 |
— | Wrong HTTP method |
The browser flow keeps its existing 403 for rate limits; 429 is used only on
the key path, matching the Coinbase CDP faucet.
- Keyed requests count against your GitHub account's existing daily allowance (10 per 24h), the same bucket the signed-in browser flow uses. A key changes how you prove who you are, not how much you can request.
- Holding two keys does not double your allowance — the limit is bound to the account, not the key, as it is on the Coinbase CDP faucet.
- The per-address limit is shared with the browser path.
- Two ceilings apply across all API keys: a daily one that bounds total spend, and a 10-minute burst window that bounds arrival rate. The burst window is what stops programmatic traffic locking the payout pool and starving the browser flow.
- Testnet only. There is no mainnet exposure.
Never commit a key. Keys found in public repositories are revoked without notice. If you are integrating a tool that others will install, read the key from the user's environment — do not ship a default.
Environment variables on the web app:
| Variable | Required | Purpose |
|---|---|---|
FAUCET_API_KEY_PEPPER |
yes | Secret mixed into the HMAC of every stored key. Without it, key minting and verification both fail closed. Rotating it invalidates every existing key. |
FAUCET_API_KEY_NETWORKS |
no | Comma-separated networks that accept key auth. Defaults to celo-sepolia. |
PROGRAMMATIC_GLOBAL_DAILY_LIMIT |
no | Daily ceiling across all keys. Defaults to 200. |
PROGRAMMATIC_BURST_LIMIT |
no | Keyed requests allowed per 10 minutes across all keys. Bounds arrival rate so the payout pool cannot be locked. Defaults to 20. |
To stop all programmatic traffic immediately without a deploy, set the
api-keys:disabled key in Upstash to any truthy value. The browser flow is
unaffected. Delete it to re-enable.
apps/web/scripts/smoke-api-key.mjs exercises the whole key path over HTTP —
rejection of unissued keys, address and method validation, a real payout, the
status poll, and the returned tx hash. It needs no repo credentials:
export FAUCET_API_KEY=cfk_... # minted at <deployment>/keys
yarn --cwd apps/web smoke:api-key \
--url https://<deployment> --beneficiary 0xYourAddressIt exits non-zero if any check fails and never prints the key.
Every other test mocks the sender, so nothing otherwise proves the hash written
to goldTxHash is a real transaction. apps/firebase/src/celo-adapter.onchain.test.ts
sends a real transfer on Celo Sepolia. It is skipped unless explicitly opted in,
because it spends gas:
RUN_ONCHAIN_TESTS=1 PRIVATE_KEY=<funded key> \
yarn --cwd apps/firebase test:ci onchainThe transfer is a self-send, so only the gas fee (~0.0011 CELO at the 50 gwei floor) is consumed, and the test asserts the balance falls by exactly that fee.
Keys are self-serve on purpose. Allowlisted keys need a human to triage each request and hand the secret over out of band, which does not scale and tends to leave secrets in chat logs. Tying keys to a GitHub account instead reuses an identity the faucet already understands, which is also what the Coinbase CDP, Circle, and Chainstack faucets do.
If you are testing contracts in CI you probably do not want the faucet at all.
anvil --fork-url https://forno.celo-sepolia.celo-testnet.org gives you
pre-funded accounts against forked state, with no rate limits and no network
flakiness. Reach for a key only when you need funds on the real testnet.
-
Add the chain config and token info to
config/chains.ts. -
Add chain name to the networks array, and
ChainIdandFaucetAddressto enums intypes/index.ts.
Dispatch the deploy-chains workflow. ensure chain name is kebab case and matches a network in config/chains.ts.