faucet-server
v0.2.0
Published
A configurable EVM faucet server that dispenses testnet/devnet tokens with Prosopo captcha protection. Supports multiple chains and deploys to Cloudflare Workers or Node.js.
Downloads
585
Readme
Faucet Server
A configurable EVM faucet server that dispenses testnet/devnet tokens with Prosopo captcha protection. Supports multiple chains and deploys to Cloudflare Workers or Node.js.
Quick Start
Run a local faucet server against your local chain, without captcha:
npx faucet-server --port 3000 --no-captchaOr install globally:
npm install -g faucet-server
faucet --port 3000 --no-captchaThe published package ships a prebuilt frontend, and a Prosopo site key can only be inlined at build time, so the captcha protected mode needs your own build. See Deploy your own faucet.
Configure via environment variables:
# Required unless --no-captcha: Prosopo secret key (get from https://prosopo.io/)
export PROSOPO_SITE_PRIVATE_KEY=your_secret_key
# Required: Wallet with funds to dispense
export FAUCET_PRIVATE_KEY=0x...your_wallet_private_key
# Required: Configure chains (format: amount_in_wei:rpc_endpoint)
export CHAIN_31337=10000000000000000:http://localhost:8545
export CHAIN_11155111=10000000000000000:https://eth-sepolia.g.alchemy.com/v2/KEY
# Recommended: persist rate limits across restarts (default is :memory:)
export DB=./faucet.dbThen access the faucet at: http://localhost:3000?chainId=31337&address=0x...
Features
- 🔗 Multi-chain support: Configure any EVM chain via environment variables
- 🤖 Bot protection: Integrated Prosopo captcha verification, fail closed, single use tokens
- 🚰 Abuse control: Per address and per ip cooldowns, plus a recipient balance ceiling
- 🔒 Secret rpc: The rpc endpoint never leaves the server, clients follow their transaction through the api
- 🚀 Multiple deployment targets: Cloudflare Workers or Node.js
- ⚡ Modern stack: Svelte frontend, Hono API server, Viem for transactions
- 📦 Monorepo architecture: Clean separation of frontend, server, and platform concerns
- 🔧 Development mode: Disable captcha for localhost testing without internet
Development
Want to contribute or customize the faucet? Follow the development setup below.
Prerequisites
- Node.js >= 18
- pnpm >= 8
- A Prosopo account with site keys (sign up)
- An EVM wallet with funds for the faucet
Installation
# Clone the repository
git clone https://github.com/wighawag/faucet.git
cd faucet
# Install dependencies
pnpm installConfiguration
Environment Variables
The faucet requires configuration via environment variables. Copy the example files and fill in your values:
Frontend (packages/frontend/.env):
VITE_PROSOPO_SITE_KEY=your_prosopo_site_keyThis one is inlined into the bundle at build time, not read at runtime. pnpm build refuses to produce a bundle without it (build with ALLOW_NO_CAPTCHA=true if the server runs with DISABLE_CAPTCHA=true). Remember to allowlist the domain you deploy to in the Prosopo portal for that site key.
Server (choose your platform):
For Node.js (platforms/nodejs/.env):
PROSOPO_SITE_PRIVATE_KEY=your_prosopo_secret_key
FAUCET_PRIVATE_KEY=0x...your_wallet_private_key
CHAIN_31337=10000000000000000:http://localhost:8545
DB=./faucet.dbFor localhost development, pass --no-captcha on the command line (pnpm nodejs:dev already does) rather than putting DISABLE_CAPTCHA=true in a committed .env, so it can never follow you into a deployment.
For Cloudflare Workers (platforms/cf-worker/.dev.vars):
PROSOPO_SITE_PRIVATE_KEY=your_prosopo_secret_key
FAUCET_PRIVATE_KEY=0x...your_wallet_private_key
CHAIN_31337=10000000000000000:http://localhost:8545
# Optional: Disable captcha for local development
DISABLE_CAPTCHA=trueAll server variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| FAUCET_PRIVATE_KEY | yes | | Wallet dispensing the funds |
| PROSOPO_SITE_PRIVATE_KEY | yes (unless captcha disabled) | | Prosopo secret key |
| CHAIN_<CHAIN_ID> | yes, at least one | | <amount_in_wei>:<rpc_endpoint> |
| DB (node only) | no | :memory: | libsql url or file path. In memory means rate limits reset on restart |
| DISABLE_CAPTCHA | no | false | true skips captcha entirely. Meant for localhost, but nothing enforces that: the server warns and serves anyway |
| CAPTCHA_FAIL_OPEN | no | false | true keeps dispensing when Prosopo is unreachable |
| CORS_ORIGINS | no | https://jolly-roger.eth.limo | Comma separated origins allowed to call the api cross-origin, or * |
| CLAIM_COOLDOWN_SECONDS | no | 86400 | Cooldown between two claims for the same address on the same chain |
| IP_COOLDOWN_SECONDS | no | same as above | Cooldown for the same ip |
| MAX_RECIPIENT_BALANCE | no | 10x the chain amount | Refuse addresses already holding this much (wei). 0 disables |
| ENABLE_ARBITRARY_CALLS | no | off | off, allowlist or unrestricted. See Arbitrary calls |
| CALL_ALLOWLIST_<CHAIN_ID> | in allowlist mode | | <contract>:<selector>,<selector>;<contract>:... |
| MAX_CALL_TOTAL_SPEND_<CHAIN_ID> | no | amount + 21000 * gas price | Ceiling in wei on value + gas for one call |
| MAX_CALL_VALUE_<CHAIN_ID> | no | the chain amount | Ceiling in wei on the value attached to a call |
| MAX_CALL_GAS | no | 2000000 | Absolute gas limit for one call |
Chain Configuration
Chains are configured using the CHAIN_<CHAIN_ID> environment variable format:
CHAIN_<CHAIN_ID>=<amount_in_wei>:<rpc_endpoint>Examples:
# Local development (anvil/hardhat)
CHAIN_31337=10000000000000000:http://localhost:8545
# Sepolia testnet (0.01 ETH)
CHAIN_11155111=10000000000000000:https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY
# Multiple chains
CHAIN_421614=10000000000000000:https://sepolia-rollup.arbitrum.io/rpcThe rpc endpoint is treated as a secret: it is never returned to clients, so it is safe to embed a provider api key in it. Set it with pnpm exec wrangler secret put rather than as a plain var.
Arbitrary calls
Off by default. When enabled, the faucet can execute a call you choose and pay for it, instead of only sending funds. The use case is emulating a credit card purchase provider, which executes a call on the buyer's behalf against an allow-list.
# a flag rather than an env var, so it cannot follow you into a deployment
npx faucet-server --port 3000 --no-captcha --arbitrary-calls unrestrictedThat combination binds 127.0.0.1 instead of every interface, because an unrestricted executor with no captcha has nothing in front of it and must not be reachable from the network. Pass --host 0.0.0.0 if you really mean to expose it.
# on Cloudflare Workers, which has no command line
ENABLE_ARBITRARY_CALLS=allowlist
CALL_ALLOWLIST_11155111=0xYourContract:0x1249c58bRead this before enabling it. Every call executes with msg.sender equal to the faucet address, so whoever passes the captcha borrows that address's authority. Not just its ERC20 balance: also every approval it granted, every role it holds, every contract it owns, and anything it is granted in the future. Use a key that does nothing else, ever.
| Mode | Meaning |
|------|---------|
| off | Default. /api/execute answers 404 and nothing else changes |
| allowlist | Only (contract, selector) pairs from CALL_ALLOWLIST_<CHAIN_ID> are executed |
| unrestricted | Any contract, any calldata. The faucet key's whole authority is on offer |
The allow-list is (contract, selector) pairs on purpose. Allow-listing a whole contract would make transfer reachable on a token, so a bare contract with no selector is refused at startup. For the same reason the faucet address should hold no ERC20 in either mode: dispense tokens by allow-listing a mint selector on a contract you control, not by letting the faucet hold a balance.
Spending is bounded per call by MAX_CALL_TOTAL_SPEND_<CHAIN_ID>, which defaults to what the equivalent claim would have cost, amount + 21000 * gas price. So turning this on does not change the faucet's worst case spend per request. Since value and gas share that budget, the value a call can carry is amount - (gas beyond 21000) * gas price: a plain transfer is unaffected by congestion, while a 200k gas call gets less room as gas gets expensive, and is refused outright when it no longer fits. Raise MAX_CALL_TOTAL_SPEND_<CHAIN_ID> if your calls need both value and a lot of gas.
Rate limiting is weaker here than on /api/claim. The to of a call is a shared contract, so it cannot be a cooldown key, which leaves only the ip (a self-declared beneficiary was considered and rejected: nothing binds it to the call, so it enforces nothing). A request whose client ip cannot be determined is refused with 503, so this feature needs a proxy that forwards the ip. With --no-captcha the ip cooldown is skipped entirely, since every local account shares one ip.
The full design, including the threat model and what was rejected, is in docs/specs/arbitrary-calls.md.
Abuse protection
Every claim goes through, in order:
- Captcha token replay check: each Procaptcha token can only be spent once.
- Captcha verification: Prosopo answers
verified: true, score: 0when their provider times out, which is a fail open. The server treats that as "unavailable" and refuses to dispense unlessCAPTCHA_FAIL_OPEN=true. Prosopo Premium customers also get a realscoreof 0 for genuine humans, so those accounts should setCAPTCHA_FAIL_OPEN=trueand lean on the rate limiter. - Recipient balance ceiling: addresses already holding
MAX_RECIPIENT_BALANCEare turned away. - Rate limit: one claim per address and per ip per
CLAIM_COOLDOWN_SECONDS, reserved atomically before the transaction is sent and released if it fails. - Faucet balance preflight: an empty faucet answers
503with a clear message instead of a raw rpc error. - Per chain send lock: concurrent claims cannot pick the same nonce. The lock is time boxed (30s) so a crashed request cannot wedge the faucet.
Steps 1, 4 and 6 need the database, so a faucet without a real DB (node) or D1 binding (workers) will not start dispensing.
Running Locally
Start the full development environment using Zellij:
pnpm startOr run individual services:
# Frontend only
pnpm frontend:dev
# Server only (Node.js)
pnpm nodejs:dev
# Server only (Cloudflare Workers)
pnpm cf-worker:devBuilding
# Build everything
pnpm build
# Build frontend only
pnpm build:frontend
# Build server only
pnpm build:serverDeployment
Deploy your own faucet
This walks through a public deployment from nothing, on Cloudflare Workers or on any Node.js host. Budget about 20 minutes.
What you need first
| Thing | Why | Where |
|-------|-----|-------|
| A dedicated wallet | It dispenses the funds, and its private key lives in your deployment. Never reuse a wallet that holds anything you care about | any wallet, or cast wallet new |
| Testnet funds | The faucet can only give away what it holds | a public faucet for the chain, or your own funds |
| A Prosopo account | Bot protection. You need both the site key (public, goes in the frontend) and the secret key (server side) | portal.prosopo.io |
| An rpc endpoint per chain | To send transactions and read balances | any provider, or a public endpoint |
| A Cloudflare account | Only for the Workers path | dash.cloudflare.com |
1. Get the code
git clone https://github.com/wighawag/faucet.git
cd faucet
pnpm install2. Prepare the wallet
cast wallet new # or any tool you trustKeep the private key for step 5 and send the address the funds you want to give away. The faucet stops dispensing (and answers 503) as soon as its balance can no longer cover one claim plus gas, so top it up before it runs dry.
3. Prepare the captcha keys
In the Prosopo portal, create a site and allowlist the domain you will deploy to. A site key that does not list your domain renders a widget that never returns a token, and nobody can claim. Note both keys down.
4. Decide what each chain gives away
One variable per chain, CHAIN_<CHAIN_ID>=<amount_in_wei>:<rpc_endpoint>. For example 0.01 ETH on Sepolia:
CHAIN_11155111=10000000000000000:https://eth-sepolia.example.com/v2/YOUR_KEYThe rpc endpoint never leaves the server, so an api key in that url is safe. Treat the whole variable as a secret anyway.
5a. Deploy to Cloudflare Workers
Log in to Cloudflare first, everything below talks to your account:
pnpm --filter faucet-server-cf-worker exec wrangler loginThe faucet needs a D1 database for its rate limits. Create it once:
pnpm cf-worker:create-dbPaste the id it prints into wrangler.toml, under env.production.d1_databases[0].database_id, then create the tables in it:
pnpm cf-worker:init-dbSet the secrets. They are environment scoped, and every script here deploys with -e production, so the --env production flag is not optional. Wrangler is a devDependency, so use pnpm exec from inside the cf-worker package to run it:
cd platforms/cf-worker
pnpm exec wrangler secret put PROSOPO_SITE_PRIVATE_KEY --env production
pnpm exec wrangler secret put FAUCET_PRIVATE_KEY --env production
pnpm exec wrangler secret put CHAIN_11155111 --env production # 10000000000000000:https://...
cd ../..Point CORS_ORIGINS at the site that will open the faucet popup, in [env.production.vars] of wrangler.toml. Leave it out entirely if only the faucet page itself calls the api.
Build and deploy. The captcha site key is inlined into the bundle at build time, so it has to be present in either packages/frontend/.env.local or as VITE_PROSOPO_SITE_KEY in the environment. The deploy:cf script runs the full build first, so there is no way to deploy without the site key being set:
pnpm deploy:cf5b. Deploy to Node.js
VITE_PROSOPO_SITE_KEY=your_site_key pnpm build
cd platforms/nodejs
DB=./faucet.db \
PROSOPO_SITE_PRIVATE_KEY=your_secret_key \
FAUCET_PRIVATE_KEY=0x... \
CHAIN_11155111=10000000000000000:https://... \
CORS_ORIGINS=https://your-dapp.example.com \
node dist/cli.js --port 3000Two things matter in production here. Set DB to a real path or libsql url: the default :memory: forgets every rate limit when the process restarts. And put the server behind a proxy that sets X-Forwarded-For (or CF-Connecting-IP), otherwise the per ip limit is skipped and only the per address one applies.
The published package takes the same variables without cloning anything, though you then get the frontend that was built at publish time, with the site key of whoever published it. That path only makes sense with --no-captcha on localhost:
npx faucet-server --port 3000 --no-captcha6. Check it actually works
Replace FAUCET with your deployed url:
# captcha must be ON in production
curl -s $FAUCET/api/config # {"captchaDisabled":false}
# the chain must be configured, and the rpc must NOT be in the answer
curl -s $FAUCET/api/chain/11155111 # {"chainId":11155111,"amount":"10000000000000000"}
# a claim without a real captcha token must be refused
curl -s -X POST $FAUCET/api/claim -H 'Content-Type: application/json' \
-d '{"token":"nope","chainId":"11155111","address":"0x0000000000000000000000000000000000000001"}'Then open $FAUCET/?chainId=11155111&address=0xYourAddress in a browser, solve the captcha, and claim. If the button stays greyed out, the captcha widget never returned a token: check the site key and its domain allowlist.
7. Running it
- Funding. Watch the faucet balance. When it can no longer cover a claim plus gas, users get a clear
503and the server logsfaucet 0x... is out of funds on chain .... - Tuning the limits.
CLAIM_COOLDOWN_SECONDS,IP_COOLDOWN_SECONDSandMAX_RECIPIENT_BALANCEdecide how fast the faucet can be drained. The defaults give one claim per address and per ip per day, and refuse addresses already holding ten times the dispensed amount. - Rotating the wallet. Set the new
FAUCET_PRIVATE_KEYsecret and redeploy. Nothing else stores the address. - Adding a chain. Add another
CHAIN_<id>secret and redeploy. No code change, no database change.
If something is wrong
| Symptom | Cause |
|---------|-------|
| Build fails with VITE_PROSOPO_SITE_KEY is not set | Working as intended: set the site key in packages/frontend/.env.local or pass VITE_PROSOPO_SITE_KEY=... to pnpm build. The deploy:cf script runs the build first, so there is no way to deploy without it. Use ALLOW_NO_CAPTCHA=true only if the server runs with DISABLE_CAPTCHA=true |
| Claim button never enables | The captcha widget got no token: wrong site key, or your domain is not allowlisted in the Prosopo portal |
| the D1 binding "DB" is missing | database_id was never filled in, or the binding is not under [env.production] |
| no such table: Claims | pnpm cf-worker:init-db was not run against the remote database |
| Every claim answers 503 about the captcha provider | Prosopo is unreachable and the faucet fails closed by design. Set CAPTCHA_FAIL_OPEN=true only if you accept that bots get through during outages |
| Rate limits reset all the time (node) | DB is still :memory: |
| Everyone shares one ip bucket, or nobody is ip limited | The proxy in front does not forward the client ip |
Usage
Users access the faucet via URL with query parameters:
https://your-faucet.example.com?chainId=11155111&address=0x...Query Parameters:
| Parameter | Description | Example |
|-----------|-------------|---------|
| chainId | Target chain ID | 11155111 (Sepolia) |
| address | Recipient wallet address | 0x742d35Cc6634C0532925a3b844Bc9e7595f... |
API Reference
GET /api/config
{"captchaDisabled": false}arbitraryCalls and faucetAddress are added only when arbitrary calls are enabled, so a faucet that leaves the feature off answers exactly what it always did. faucetAddress is the address a call is signed by, which the popup shows: it is public, being the sender of every dispensed transaction.
{"captchaDisabled": false, "arbitraryCalls": "allowlist", "faucetAddress": "0x..."}GET /api/chain/:chainId
Amount dispensed on that chain, in wei. 404 when the chain is not configured. The rpc endpoint is deliberately not exposed.
{"chainId": 11155111, "amount": "10000000000000000"}GET /api/tx/:chainId/:txHash
Follows a dispensed transaction server side.
{"status": "pending"}
{"status": "success", "blockNumber": "5123456"}
{"status": "failed", "blockNumber": "5123456"}POST /api/execute
Only when arbitrary calls are enabled, otherwise 404. Executes a call and pays for it.
Request Body:
{
"token": "prosopo_captcha_token",
"chainId": "11155111",
"to": "0x...",
"data": "0x1249c58b",
"value": "0"
}data defaults to 0x and value is a decimal wei string defaulting to 0. Success and errors have the same shape as /api/claim, so the transaction is followed through /api/tx/:chainId/:txHash in the same way.
| Status | Meaning |
|--------|---------|
| 404 | Arbitrary calls are not enabled on this faucet |
| 400 | Bad input, unconfigured chain, contract or selector not allow-listed, value over the ceiling, call reverts, or the call does not fit the budget |
| 401 | Captcha failed or token already used |
| 429 | ip cooldown still running |
| 503 | Captcha provider unreachable, client ip unknown, rpc unreachable, faucet empty, or faucet busy |
POST /api/claim
Request funds from the faucet.
Request Body:
{
"token": "prosopo_captcha_token",
"chainId": "11155111",
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f..."
}Success Response:
{
"success": true,
"txHash": "0x..."
}Error Response:
{
"error": "Error message"
}| Status | Meaning |
|--------|---------|
| 400 | Bad input, unconfigured chain, or recipient already funded |
| 401 | Captcha failed or token already used |
| 429 | Cooldown still running, see the Retry-After header and retryAfter field |
| 503 | Captcha provider unreachable, rpc unreachable, faucet empty, or faucet busy |
Project Structure
faucet/
├── packages/
│ ├── client/ # Popup client library for dapps
│ ├── frontend/ # Svelte SPA with captcha integration
│ └── server/ # Hono API server (platform-agnostic)
├── platforms/
│ ├── cf-worker/ # Cloudflare Workers deployment
│ └── nodejs/ # Node.js deployment
└── package.json # Root workspace configTech Stack
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
