- Go 70.6%
- TypeScript 22.5%
- Solidity 3.3%
- CSS 2.2%
- Python 0.6%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| contracts | ||
| docs | ||
| frontend | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| docker-compose.local.example.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| foundry.toml | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
ICU PLS Faucet
A configurable, self-funding faucet for test networks. It dispenses native and bridged assets through direct claims and browser-based proof-of-work, while automated funding replenishes payout balances. The app ships as one Go binary with an embedded Preact dashboard and SQLite-backed settings, so deployment needs one application container and persistent storage.
Quick start
Every command below runs in a container. There is no local Go, Node, bun or Foundry requirement — only Docker with Compose.
cp docker-compose.local.example.yml docker-compose.local.yml
cp .env.example .env
docker-compose.yml itself needs no copy — it is the tracked deployment
template. docker-compose.local.yml is a complete, standalone local-testing
file, not an overlay on that template. It and .env are gitignored so local
values cannot be committed by accident.
make caches
Run this first, once. Every Go and frontend target runs as your uid inside a
container and writes to named cache volumes. Docker creates a fresh named volume
root-owned, so without this the first make build or make keygen dies on a
permission error inside /gomod that reads like a broken toolchain.
make keygen NAME=faucet
The wallet the faucet pays out of, unrelated to admin sign-in. It writes
keystores/faucet/ (0700, owned by you) and — since there is no
automatic directory lookup, only env vars — prints a ready-to-paste
FAUCET_KEYSTORE=<minified JSON> line (that's what -print is for; make keygen passes it by default). Copy that line into .env as-is.
Then open keystores/faucet/secret.env in an editor (never cat it —
that puts the passphrase in your terminal's scrollback) and add its value to
.env as FAUCET_SECRET=<the value>. Both halves now live in .env as
plain values — the container never needs to read ./keystores at all for
this wallet, so there is no permission dance to do first. (_KEYSTORE/
_SECRET also each accept a path instead, if you'd rather point at the files
themselves — see "Deploying without local files" below, including the
container-uid caveat that only applies if you choose that route.)
echo "ADMIN_ADDRESS=0xYourWalletAddress" >> .env
The only wallet allowed to sign in — an env var, not something the running
app (or any subcommand run against it) can ever change; see
docker-compose.yml's own comment for why. There is no password: the panel is
opened by proving control of this address with a wallet signature, so what is
configured is a public address and nothing else.
make up
make logs
Then open http://127.0.0.1:8080/admin/settings and sign in with your wallet.
make upand friends drive the LOCAL stack only — compose projecticu-pls-faucet-local, with its ownfaucet-datavolume and its own database, separate from theicu-pls-faucetproject a plaindocker compose -f docker-compose.yml up -dcreates. That separation is deliberate (a local run must not adopt and recreate a deployed stack's container), but it means the two are independent deployments: ADMIN_ADDRESS in.envhere has no bearing on a deployed stack, which reads its own.env(or the platform's env var UI) instead.
Out of the box only chain 943 is servable — it is native, so the bot pays directly from its own wallet. Sepolia ships enabled but refuses to serve until you set a faucet contract address, because without one the service would send the chain's native coin and hand out the scarce hand-collected ETH that funds the whole pipeline. The startup log gives the reason per chain.
Full walkthrough, including how to exercise sign-in and session expiry: docs/GETTING-STARTED.md.
Deploying without local files (any git-based platform)
A git-based platform deploy never gets a local
./keystores/ bind mount — ./keystores/ and every .env* file are
gitignored on purpose — so every secret in this app also has a plain,
paste-into-the-env-var-UI form. Every secret is a plain env var; none of them
require a mounted file, though a path is also accepted when one is more
convenient (see below).
Wallet keystores. Two env vars per wallet: <PREFIX>_KEYSTORE and
<PREFIX>_SECRET — PREFIX is the wallet's name, uppercased with
non-alphanumerics turned into _, e.g. faucet → FAUCET_KEYSTORE /
FAUCET_SECRET. There is no automatic directory lookup; each var holds
EITHER the value directly OR a path to load it from:
<PREFIX>_KEYSTORE— content-sniffed: a value starting with{is treated as the keystore JSON itself (minified to one line); anything else is a path to a file containing it.<PREFIX>_SECRET— a literal value is used as the passphrase directly; a value prefixedfile:(e.g.file:/keystores/faucet/secret.env) reads the passphrase from that path instead — the exactKEY=VALUEformatfaucet keygenalready writes, so pointing at a generatedsecret.envjust works.
After make keygen NAME=faucet (which prints a ready-to-paste
FAUCET_KEYSTORE=<minified JSON> line via -print), either paste that
line plus the passphrase (open keystores/faucet/secret.env in an editor
and copy the value out — never cat it, that puts it in your terminal's
scrollback) directly into your env vars, or, if the container has
./keystores mounted, point at the files instead:
FAUCET_KEYSTORE=/keystores/faucet/0x<the address>.json
FAUCET_SECRET=file:/keystores/faucet/secret.env
The path form only works if the container can actually read that path. The
container runs as uid 65532 (distroless nonroot); a 0700 keystore directory
owned by your own uid is unreadable to it, and the symptom is a startup
failure about a missing passphrase that looks like a configuration problem,
not a permissions one. Fix with either sudo chown -R 65532:65532 ./keystores, or RUN_AS=$(id -u):$(id -g) in .env (which then also needs
the /data volume chowned once, since it's created 65532-owned from the
image) — both spelled out in docker-compose.yml. Never make a keystore
world-readable to work around this. Pasting both halves inline (the Quick
start default) sidesteps this entirely, since the container never touches
./keystores.
GOTIFY_TOKEN / GOTIFY_URL, CAPTCHA_SECRET_TURNSTILE /
CAPTCHA_SECRET_HCAPTCHA,
BACKUP_S3_ENDPOINT / BUCKET / REGION / ACCESS_KEY / SECRET_KEY /
USE_TLS, BACKUP_ENCRYPTION_KEY. One var per value, never a JSON blob
pasted onto one line — see the comment block above these vars in
docker-compose.yml for what each one gates.
Build and test
make caches once, then:
| target | what it runs |
|---|---|
make build |
go build ./... |
make vet |
go vet ./... |
make fmt / make fmt-check |
rewrite with gofmt / fail if anything is unformatted |
make test |
go test ./... |
make test-race |
go test -race ./..., on a Debian image (the race detector needs cgo) |
make test-live |
opt-in: also runs the tests that dial real public RPC endpoints |
make check |
fmt-check build vet test-race — everything CI runs for Go |
make tidy / make vuln |
go mod tidy / govulncheck |
Frontend — install and build with bun, but run the tests with node:
| target | what it runs |
|---|---|
make fe-install |
install frozen dependencies and verify the tracked build-tooling patch |
make fe-build |
bun run build:preview (version check, typecheck, local-preview bundle) |
VITE_RELEASE_VERSION=v<package version> bun run build:release from frontend/ |
version check, typecheck, release bundle |
make fe-test |
npx vitest run under node, not bun |
make fe-audit |
verify frozen dependencies, patch integrity and advisories |
bun run bones / bun run bones:check from frontend/ |
capture or verify every loading layout through Browserless |
make check-all |
check plus frontend install, build and tests |
Frontend dependency patches are applied by Bun during installation and verified by the post-install check. make fe-audit verifies the installed patch before accepting its narrowly scoped advisory disposition; unexpected dependency versions or missing patch files fail verification.
Local previews show v<package version>-dev. Release builds require the exact
package version tag. Docker builds use VERSION=dev for previews and the exact
tag for releases (make image VERSION=v<package version>).
Contracts (Foundry in Docker): make contracts-build, contracts-test,
contracts-test-live, contracts-fmt, contracts-shanghai,
contracts-check.
Container and compose: make image, make image-size, make compose-check
(parse-checks the tracked deployment and standalone local file).
The tracked Compose build and make image refresh the kit tags on each build.
Version-tag pushes deploy the current main commit to Coolify after all CI checks
succeed. The deploy job uses web-deploy, updates the exact commit and build
version, and verifies deployment health. Superseded tags do not deploy. Configure
the COOLIFY_APP_UUID Actions variable and the COOLIFY_URL, COOLIFY_API_TOKEN,
CF_ACCESS_CLIENT_ID, and CF_ACCESS_CLIENT_SECRET Actions secrets.
make release-check is the complete pre-release gate: Go and frontend checks,
deterministic and live contract tests, live RPC tests, compose validation,
container build, and both dependency audits.
Architecture in five bullets
- One artifact. Vite builds the Preact dashboard,
//go:embedputs it in the Go binary, and the binary serves the API, the SSE stream and the dashboard from one process.CGO_ENABLED=0throughout, so the SQLite driver is pure Go (modernc.org/sqlite) and the runtime image is distroless static. - One settings document, one row in the database. Everything an operator tunes — drip amounts, cooldowns, RPC endpoints, branding, captcha keys — lives there; the environment carries deploy-time paths and secrets. CAPTCHA has separate drip and mining-start switches. Saves take effect without a restart; see docs/SETTINGS.md.
- Automated writes use a durable outbox (
internal/txq) with serial(chain, wallet)lanes. Attempts are persisted before broadcast and checked for canonical receipts. A recent-price guard may hold sends, fee bumps, or cancellations; signed gas-cost ceilings are 0.001/0.005 ETH for Sepolia payouts/maintenance and 50/250 PLS on PulseChain Testnet. - Funding is a periodic sweep. Each collector mints tDAI on Sepolia and gates its own bridge crossing independently. Sepolia-to-943 delivery is validator-driven; 943-to-Sepolia requires a signature claim.
- Admin sign-in is a wallet signature (EIP-4361), not a password. The server composes the message and verifies against its own stored copy, so there is no second template to keep byte-identical. Nonces are single-use, challenges expire, and sessions live in memory — a restart signs everyone out everywhere. Nothing stored anywhere is a credential.
Subcommands
Run faucet help (or docker compose ... run --rm faucet help) for the
current, authoritative list, grouped by SERVICE / KEYS / CONTRACTS / ASSET
REGISTRY / MOVING VALUE / BRIDGE. It is not reproduced here as a second table:
TestUsageListsEverySubcommand (cmd/server/usage_test.go) parses the
CLI's own dispatch table and fails the build if the help text and the real
subcommand set ever disagree — a hand-maintained copy in this README has no
such guarantee and had already drifted once before that test existed.
Every command that signs defaults to -dry-run=true: it simulates and prints
what would change, and requires an explicit -dry-run=false to broadcast.
run is not in that group — it is the service itself, paying out and sweeping
continuously, bounded by the operator.paused/operator.sweepEnabled
settings (admin panel, no restart needed to change either). Run any
subcommand with -h for its flags.
set-balance-gate is the owner command for ordinary-claim eligibility. It
accepts -token native or any ERC20 address and an at-or-above threshold;
-threshold 0 removes that token's gate. As with every signing command, run the
default simulation first and add -dry-run=false only after reviewing it.
Documentation
- docs/GETTING-STARTED.md — first run, admin sign-in, and how to exercise both in a test environment.
- docs/SETTINGS.md — the settings document, what's in the environment instead and why, how a save behaves, recovery.
The package doc comments are the reference for anything below that level:
internal/txq for the outbox, internal/funding for the sweep,
internal/bridge for the asymmetric AMB directions, internal/settings for the
settings document, internal/config for the chain registry.
License
MIT — see LICENSE.