Abuse Prevention Layers
The Nimiq Simple Faucet uses 9 pluggable abuse-prevention layers arranged in a defense-in-depth pipeline. Rate limiting is on by default; all other layers are opt-in via environment variables.
How the pipeline works
Every claim request passes through all enabled layers sequentially. Each layer returns:
- A score between 0 (clean) and 1 (certain abuse)
- Signals — structured evidence (country, ASN, fingerprint match, etc.)
- An optional hard decision:
allow,challenge,review, ordeny
A deny decision short-circuits the pipeline — remaining layers don't run. A challenge or review decision is recorded but does NOT short-circuit, so later layers can still escalate to deny. After all layers run, the hardest decision wins. If no hard decision, the aggregate weighted score is compared against configurable thresholds.
| Decision | HTTP status | What happens |
|---|---|---|
allow | 200 | Transaction is broadcast |
challenge | 202 | Client must solve an additional challenge and retry |
review | 202 | Held for manual admin review |
deny | 403 | Rejected with reason |
Execution order
Layers run in this fixed order:
- Blocklist (weight 5) — always on
- Rate Limiting (weight 3) — always on
- Cloudflare Turnstile (weight 2) — if
FAUCET_TURNSTILE_SITE_KEYset - hCaptcha (weight 2) — if
FAUCET_HCAPTCHA_SITE_KEYset - Hashcash (weight 1) — if
FAUCET_HASHCASH_SECRETset - GeoIP / ASN (weight 1) — if
FAUCET_GEOIP_BACKENDis notnone - Device Fingerprint (weight 1) — if
FAUCET_FINGERPRINT_ENABLED - On-Chain Heuristics (weight 1) — if
FAUCET_ONCHAIN_ENABLED - AI Anomaly Scoring (weight 1) — if
FAUCET_AI_ENABLED
The order is not currently configurable. Each layer's weight affects its contribution to the aggregate score (higher weight = more influence).
Important: captcha vs hashcash
The ClaimUI only shows one challenge widget at a time (priority: Turnstile > hCaptcha > Hashcash). If both a captcha provider (Turnstile/hCaptcha) and hashcash are enabled server-side, the ClaimUI will only show the captcha widget and the server will reject claims missing the hashcash solution.
Recommendation: enable either a captcha provider (Turnstile OR hCaptcha) or hashcash, not both simultaneously. If you need both proof-of-work and captcha protection, use a captcha provider — they already include bot detection that achieves similar goals to hashcash.
Layer summary
| # | Layer | What it catches | Cost | Enabled by |
|---|---|---|---|---|
| 1 | Blocklist | Known bad actors | Free | Always on |
| 2 | Rate Limiting | Volume abuse | Free | Always on |
| 3 | Cloudflare Turnstile | Bots | Free tier available | FAUCET_TURNSTILE_SITE_KEY |
| 4 | hCaptcha | Bots | Free tier available | FAUCET_HCAPTCHA_SITE_KEY |
| 5 | Hashcash | Scripted flooding | Free (self-hosted) | FAUCET_HASHCASH_SECRET |
| 6 | GeoIP / ASN | Geo-evasion, VPN, datacenter | Free (DB-IP) or paid | FAUCET_GEOIP_BACKEND |
| 7 | Device Fingerprint | Multi-account farming | Free (client-side) | FAUCET_FINGERPRINT_ENABLED |
| 8 | On-Chain Heuristics | Sweeper wallets, faucet clusters | Free (RPC queries) | FAUCET_ONCHAIN_ENABLED |
| 9 | AI Anomaly Scoring | Novel attack patterns | Free (local CPU) | FAUCET_AI_ENABLED |
Recommended production setup
At minimum, enable:
- Rate limiting (always on) — caps claims per IP per day
- Turnstile or hCaptcha — blocks automated scripts
- Hashcash — adds CPU cost to every claim
- GeoIP with country allowlist — restricts to your target regions
For higher-value faucets, add fingerprint correlation, on-chain heuristics, and AI scoring.
Adding your own abuse layer
The abuse pipeline is designed for extensibility. Any module that implements the AbuseCheck interface can be plugged in.
Step 1: Create the package
mkdir packages/abuse-mycheck
cd packages/abuse-mycheck
pnpm initStep 2: Implement the AbuseCheck interface
import type { AbuseCheck, CheckResult, ClaimRequest } from '@faucet/core';
export interface MyCheckConfig {
threshold: number;
}
export function myCheck(config: MyCheckConfig): AbuseCheck {
return {
id: 'my-check',
description: 'My custom abuse check',
weight: 1,
async check(req: ClaimRequest): Promise<CheckResult> {
// Your logic here — inspect req.ip, req.address,
// req.hostContext, req.fingerprint, etc.
const suspicious = /* ... */ false;
return {
score: suspicious ? 0.8 : 0,
signals: { reason: 'example' },
decision: suspicious ? 'deny' : undefined,
reason: suspicious ? 'failed my check' : undefined,
};
},
};
}Step 3: Register in the pipeline
In apps/server/src/abuse/pipeline.ts, import your check and add it conditionally:
import { myCheck } from '@faucet/abuse-mycheck';
// Inside buildPipeline():
if (config.myCheckEnabled) {
checks.push(myCheck({ threshold: config.myCheckThreshold }));
}Step 4: Add config + env vars
In apps/server/src/config.ts, add your config fields and env var mappings:
// In ServerConfigSchema:
myCheckEnabled: z.coerce.boolean().default(false),
myCheckThreshold: z.coerce.number().default(0.5),
// In ENV_KEYS:
myCheckEnabled: 'FAUCET_MY_CHECK_ENABLED',
myCheckThreshold: 'FAUCET_MY_CHECK_THRESHOLD',Step 5: Test
Add tests in your package and in apps/server/test/ following the pattern of existing abuse layer tests (e.g., geoip.e2e.test.ts, hashcash.e2e.test.ts).
See CONTRIBUTING.md for the full development workflow.