Skip to content

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, or deny

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.

DecisionHTTP statusWhat happens
allow200Transaction is broadcast
challenge202Client must solve an additional challenge and retry
review202Held for manual admin review
deny403Rejected with reason

Execution order

Layers run in this fixed order:

  1. Blocklist (weight 5) — always on
  2. Rate Limiting (weight 3) — always on
  3. Cloudflare Turnstile (weight 2) — if FAUCET_TURNSTILE_SITE_KEY set
  4. hCaptcha (weight 2) — if FAUCET_HCAPTCHA_SITE_KEY set
  5. Hashcash (weight 1) — if FAUCET_HASHCASH_SECRET set
  6. GeoIP / ASN (weight 1) — if FAUCET_GEOIP_BACKEND is not none
  7. Device Fingerprint (weight 1) — if FAUCET_FINGERPRINT_ENABLED
  8. On-Chain Heuristics (weight 1) — if FAUCET_ONCHAIN_ENABLED
  9. 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

#LayerWhat it catchesCostEnabled by
1BlocklistKnown bad actorsFreeAlways on
2Rate LimitingVolume abuseFreeAlways on
3Cloudflare TurnstileBotsFree tier availableFAUCET_TURNSTILE_SITE_KEY
4hCaptchaBotsFree tier availableFAUCET_HCAPTCHA_SITE_KEY
5HashcashScripted floodingFree (self-hosted)FAUCET_HASHCASH_SECRET
6GeoIP / ASNGeo-evasion, VPN, datacenterFree (DB-IP) or paidFAUCET_GEOIP_BACKEND
7Device FingerprintMulti-account farmingFree (client-side)FAUCET_FINGERPRINT_ENABLED
8On-Chain HeuristicsSweeper wallets, faucet clustersFree (RPC queries)FAUCET_ONCHAIN_ENABLED
9AI Anomaly ScoringNovel attack patternsFree (local CPU)FAUCET_AI_ENABLED

At minimum, enable:

  1. Rate limiting (always on) — caps claims per IP per day
  2. Turnstile or hCaptcha — blocks automated scripts
  3. Hashcash — adds CPU cost to every claim
  4. 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

bash
mkdir packages/abuse-mycheck
cd packages/abuse-mycheck
pnpm init

Step 2: Implement the AbuseCheck interface

typescript
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:

typescript
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:

typescript
// 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.

Built with ❤️ by Richy.