Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,10 @@ CAP_SECRET=
CAP_HARD_SITE_KEY=
CAP_HARD_SECRET=

# Trusted server-to-server API (JSON map of key IDs to SHA-256 key hashes)
# Example: {"platform-team":"0123456789abcdef..."}
FAUCET_API_KEY_HASHES=
FAUCET_API_KEY_DAILY_LIMIT=100

# Promo codes as JSON (optional, merged with promo_codes.json; env wins on conflict)
# PROMO_CODES={"TESTCODE": {"amount": 5.0}}
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Environment variables:
| `CORE_FAUCET_AMOUNT` | DASH amount for core faucet | `1.0` |
| `CAP_SITE_KEY` | CAP captcha site key | - |
| `CAP_SECRET` | CAP captcha secret | - |
| `FAUCET_API_KEY_HASHES` | JSON map of trusted API key IDs to SHA-256 hashes | - |
| `FAUCET_API_KEY_DAILY_LIMIT` | Successful payouts allowed per trusted key per rolling 24 hours | `100` |

## Architecture

Expand All @@ -53,6 +55,23 @@ Environment variables:
- `GET /api/status` - Faucet status, balance, and deposit address
- `POST /api/identity-package` - Get an identity package with asset lock proof
- `POST /api/core-faucet` - Request testnet DASH
- `POST /api/v1/core-faucet` - Trusted bearer-key tDASH payout without browser CAPTCHA or public IP limits

### Trusted API

The trusted endpoint requires `Authorization: Bearer <key>` and accepts only an
`address` field. API keys are configured as SHA-256 hashes so plaintext keys do
not need to be stored by the faucet.

```bash
curl -X POST https://faucet.testnet.networks.dash.org/api/v1/core-faucet \
-H "Authorization: Bearer $FAUCET_API_KEY" \
-H "Content-Type: application/json" \
--data '{"address":"YOUR_TESTNET_DASH_ADDRESS"}'
```

This endpoint bypasses the browser CAPTCHA and public IP rate limit, but retains
a per-key rolling daily limit. Promo codes are not accepted by the trusted API.

## Development

Expand Down
5 changes: 5 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ class Settings(BaseSettings):
cap_hard_site_key: str = ""
cap_hard_secret: str = ""

# Trusted server-to-server faucet API
# JSON mapping of key IDs to SHA-256 key hashes.
faucet_api_key_hashes: str = ""
faucet_api_key_daily_limit: int = 100

# Promo codes config file path
promo_codes_file: str = "promo_codes.json"

Expand Down
123 changes: 94 additions & 29 deletions app/routers/faucet.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,27 @@

Provides the main endpoint for creating identity packages.
"""
import logging
from typing import Annotated

import httpx
from fastapi import APIRouter, Request, Response, HTTPException
from fastapi import APIRouter, Header, Request, Response, HTTPException
from pydantic import BaseModel

from app.config import settings
from app.middleware.rate_limit import RateLimiter, rate_limiter
from app.models.schemas import FaucetResponse, ErrorResponse, RateLimitResponse, PublicKeyInfo
from app.services.api_keys import authenticate_api_key
from app.services.asset_lock import (
create_asset_lock_transaction,
get_suitable_utxo,
COIN,
)
from app.services.core_client import dash_client
from app.services.instant_lock import wait_for_instant_lock, InstantLockTimeout
from app.services.keys import generate_key_pair, create_identity_public_key
from app.services.proof_builder import build_instant_asset_lock_proof
from app.services.promo import promo_service


class FaucetRequest(BaseModel):
Expand Down Expand Up @@ -37,6 +53,11 @@ class CoreFaucetRequest(BaseModel):
promoCode: str | None = None


class ApiCoreFaucetRequest(BaseModel):
"""Request body for trusted server-to-server tDASH payouts."""
address: str


class CoreFaucetResponse(BaseModel):
"""Response for core faucet endpoint."""
txid: str
Expand Down Expand Up @@ -77,21 +98,14 @@ async def verify_cap_token(token: str, hard: bool = False) -> bool:
return result.get("success", False)
except Exception:
return False
from app.middleware.rate_limit import rate_limiter
from app.models.schemas import FaucetResponse, ErrorResponse, RateLimitResponse, PublicKeyInfo
from app.services.core_client import dash_client
from app.services.keys import generate_key_pair, create_identity_public_key
from app.services.asset_lock import (
create_asset_lock_transaction,
get_suitable_utxo,
COIN
)
from app.services.instant_lock import wait_for_instant_lock, InstantLockTimeout
from app.services.proof_builder import build_instant_asset_lock_proof
from app.services.promo import promo_service


router = APIRouter(prefix="/api", tags=["faucet"])
logger = logging.getLogger(__name__)
api_key_rate_limiter = RateLimiter(
max_requests=settings.faucet_api_key_daily_limit,
window_seconds=24 * 60 * 60,
)


def get_client_ip(request: Request) -> str:
Expand Down Expand Up @@ -584,14 +598,6 @@ async def core_faucet(request: Request, body: CoreFaucetRequest) -> CoreFaucetRe
headers={"Retry-After": str(retry_after)}
)

# Validate address format (basic check for testnet address)
address = body.address.strip()
if not address or len(address) < 26:
raise HTTPException(
status_code=400,
detail={"error": "Invalid address format"}
)

# Determine send amount (promo code may override)
send_amount = settings.core_faucet_amount
promo_code_used = None
Expand All @@ -606,17 +612,29 @@ async def core_faucet(request: Request, body: CoreFaucetRequest) -> CoreFaucetRe
send_amount = promo_amount
promo_code_used = body.promoCode

try:
# Send DASH to the address
txid = dash_client.send_to_address(address, send_amount)
result = dispense_core_dash(body.address, send_amount)

# Record successful request for rate limiting
rate_limiter.record_request(client_ip)
# Record successful request for rate limiting
rate_limiter.record_request(client_ip)

# Record promo code usage
if promo_code_used:
promo_service.record_usage(promo_code_used, client_ip)

# Record promo code usage
if promo_code_used:
promo_service.record_usage(promo_code_used, client_ip)
return result


def dispense_core_dash(address_value: str, send_amount: float) -> CoreFaucetResponse:
"""Validate an address and send a fixed amount of testnet DASH."""
address = address_value.strip()
if not address or len(address) < 26:
raise HTTPException(
status_code=400,
detail={"error": "Invalid address format"}
)

try:
txid = dash_client.send_to_address(address, send_amount)
return CoreFaucetResponse(
txid=txid,
amount=send_amount,
Expand Down Expand Up @@ -645,3 +663,50 @@ async def core_faucet(request: Request, body: CoreFaucetRequest) -> CoreFaucetRe
"detail": error_msg
}
)


@router.post(
"/v1/core-faucet",
response_model=CoreFaucetResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request"},
401: {"description": "Missing or invalid API key"},
429: {"model": RateLimitResponse, "description": "API key daily limit exceeded"},
500: {"model": ErrorResponse, "description": "Server error"},
503: {"model": ErrorResponse, "description": "Service unavailable"},
},
)
async def api_core_faucet(
body: ApiCoreFaucetRequest,
authorization: Annotated[str | None, Header()] = None,
) -> CoreFaucetResponse:
"""Send tDASH for a trusted API client without browser CAPTCHA or IP limits."""
key_id = authenticate_api_key(authorization)
if key_id is None:
raise HTTPException(
status_code=401,
detail={"error": "Missing or invalid API key"},
headers={"WWW-Authenticate": "Bearer"},
)

allowed, retry_after = api_key_rate_limiter.is_allowed(key_id)
if not allowed:
raise HTTPException(
status_code=429,
detail={
"error": "API key daily limit exceeded",
"retryAfter": retry_after,
},
headers={"Retry-After": str(retry_after)},
)

result = dispense_core_dash(body.address, settings.core_faucet_amount)
api_key_rate_limiter.record_request(key_id)
Comment on lines +692 to +704

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Daily-limit check-then-act race across the payout RPC call.

api_key_rate_limiter.is_allowed(key_id) and record_request(key_id) are separate locked operations with dispense_core_dash's network call to Dash Core RPC executed in between. Concurrent requests using the same API key can all pass the check before any of them records, letting a key exceed the intended daily safety cap.

🔒 Suggested direction: make check+reserve atomic
-    allowed, retry_after = api_key_rate_limiter.is_allowed(key_id)
-    if not allowed:
-        raise HTTPException(
-            status_code=429,
-            detail={
-                "error": "API key daily limit exceeded",
-                "retryAfter": retry_after,
-            },
-            headers={"Retry-After": str(retry_after)},
-        )
-
-    result = dispense_core_dash(body.address, settings.core_faucet_amount)
-    api_key_rate_limiter.record_request(key_id)
+    allowed, retry_after = api_key_rate_limiter.try_reserve(key_id)
+    if not allowed:
+        raise HTTPException(
+            status_code=429,
+            detail={
+                "error": "API key daily limit exceeded",
+                "retryAfter": retry_after,
+            },
+            headers={"Retry-After": str(retry_after)},
+        )
+
+    try:
+        result = dispense_core_dash(body.address, settings.core_faucet_amount)
+    except Exception:
+        api_key_rate_limiter.release(key_id)
+        raise

(try_reserve/release would need to be added to RateLimiter, performing the check-and-append under one lock acquisition.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/routers/faucet.py` around lines 692 - 704, Replace the separate
api_key_rate_limiter.is_allowed and record_request calls around
dispense_core_dash with an atomic try_reserve operation that checks and records
the key within one lock acquisition, rejecting with the existing 429 response
when reservation fails. If the payout RPC fails or does not complete
successfully, release the reservation through the corresponding RateLimiter
release operation so failed requests do not consume the daily allowance.

logger.info(
"Trusted faucet API payout key_id=%s address=%s amount=%s txid=%s",
key_id,
result.address,
result.amount,
result.txid,
)
return result
57 changes: 57 additions & 0 deletions app/services/api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Authentication helpers for trusted faucet API clients."""

import hashlib
import hmac
import json

from app.config import settings


def parse_api_key_hashes(value: str) -> dict[str, str]:
"""Parse a JSON mapping of API key IDs to SHA-256 hashes."""
if not value.strip():
return {}

parsed = json.loads(value)
if not isinstance(parsed, dict):
raise ValueError("FAUCET_API_KEY_HASHES must be a JSON object")

result: dict[str, str] = {}
for key_id, key_hash in parsed.items():
if not isinstance(key_id, str) or not key_id.strip():
raise ValueError("Faucet API key IDs must be non-empty strings")
if (
not isinstance(key_hash, str)
or len(key_hash) != 64
or any(char not in "0123456789abcdefABCDEF" for char in key_hash)
):
raise ValueError(
f"Faucet API key hash for {key_id!r} must be a SHA-256 hex digest"
)
result[key_id] = key_hash.lower()

return result


API_KEY_HASHES = parse_api_key_hashes(settings.faucet_api_key_hashes)


def authenticate_api_key(authorization: str | None) -> str | None:
"""Return the matching key ID for a valid Bearer token."""
if not authorization:
return None

scheme, separator, token = authorization.partition(" ")
token = token.strip()
if not separator or scheme.casefold() != "bearer" or not token:
return None

presented_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
matched_key_id = None

# Compare against every configured hash to avoid revealing which key IDs exist.
for key_id, expected_hash in API_KEY_HASHES.items():
if hmac.compare_digest(presented_hash, expected_hash):
matched_key_id = key_id

return matched_key_id
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ services:
- CAP_SECRET=${CAP_SECRET:-}
- CAP_HARD_SITE_KEY=${CAP_HARD_SITE_KEY:-}
- CAP_HARD_SECRET=${CAP_HARD_SECRET:-}
- FAUCET_API_KEY_HASHES=${FAUCET_API_KEY_HASHES:-}
- FAUCET_API_KEY_DAILY_LIMIT=${FAUCET_API_KEY_DAILY_LIMIT:-100}
depends_on:
dashcore:
condition: service_healthy
Expand Down
40 changes: 40 additions & 0 deletions tests/test_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Tests for trusted faucet API key authentication."""

import hashlib
import unittest
from unittest.mock import patch

from app.services import api_keys


class ApiKeyTests(unittest.TestCase):
def test_parse_api_key_hashes(self):
digest = hashlib.sha256(b"secret").hexdigest()
self.assertEqual(
api_keys.parse_api_key_hashes(f'{{"platform-team":"{digest}"}}'),
{"platform-team": digest},
)

def test_parse_rejects_non_sha256_hash(self):
with self.assertRaises(ValueError):
api_keys.parse_api_key_hashes('{"platform-team":"short"}')

def test_authenticate_valid_bearer_key(self):
token = "test-api-key"
digest = hashlib.sha256(token.encode()).hexdigest()
with patch.object(api_keys, "API_KEY_HASHES", {"platform-team": digest}):
self.assertEqual(
api_keys.authenticate_api_key(f"Bearer {token}"),
"platform-team",
)

def test_authenticate_rejects_invalid_or_missing_key(self):
digest = hashlib.sha256(b"valid-key").hexdigest()
with patch.object(api_keys, "API_KEY_HASHES", {"platform-team": digest}):
self.assertIsNone(api_keys.authenticate_api_key(None))
self.assertIsNone(api_keys.authenticate_api_key("Basic abc"))
self.assertIsNone(api_keys.authenticate_api_key("Bearer wrong-key"))


if __name__ == "__main__":
unittest.main()
63 changes: 63 additions & 0 deletions tests/test_trusted_faucet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Tests for the trusted server-to-server faucet endpoint."""

import unittest
from unittest.mock import Mock, patch

from fastapi import HTTPException

from app.routers import faucet


class TrustedFaucetTests(unittest.IsolatedAsyncioTestCase):
async def test_rejects_invalid_key_before_payout(self):
body = faucet.ApiCoreFaucetRequest(address="y" * 34)
with patch.object(faucet, "authenticate_api_key", return_value=None), patch.object(
faucet, "dispense_core_dash"
) as dispense:
with self.assertRaises(HTTPException) as raised:
await faucet.api_core_faucet(body, "Bearer invalid")

self.assertEqual(raised.exception.status_code, 401)
dispense.assert_not_called()

async def test_valid_key_bypasses_public_protection_and_records_usage(self):
body = faucet.ApiCoreFaucetRequest(address="y" * 34)
expected = faucet.CoreFaucetResponse(
txid="ab" * 32,
amount=1.0,
address=body.address,
)
limiter = Mock()
limiter.is_allowed.return_value = (True, 0)

with patch.object(
faucet, "authenticate_api_key", return_value="platform-team"
), patch.object(faucet, "api_key_rate_limiter", limiter), patch.object(
faucet, "dispense_core_dash", return_value=expected
) as dispense:
result = await faucet.api_core_faucet(body, "Bearer valid")

self.assertEqual(result, expected)
dispense.assert_called_once_with(body.address, faucet.settings.core_faucet_amount)
limiter.record_request.assert_called_once_with("platform-team")

async def test_daily_limit_blocks_before_payout(self):
body = faucet.ApiCoreFaucetRequest(address="y" * 34)
limiter = Mock()
limiter.is_allowed.return_value = (False, 123)

with patch.object(
faucet, "authenticate_api_key", return_value="platform-team"
), patch.object(faucet, "api_key_rate_limiter", limiter), patch.object(
faucet, "dispense_core_dash"
) as dispense:
with self.assertRaises(HTTPException) as raised:
await faucet.api_core_faucet(body, "Bearer valid")

self.assertEqual(raised.exception.status_code, 429)
self.assertEqual(raised.exception.headers["Retry-After"], "123")
dispense.assert_not_called()


if __name__ == "__main__":
unittest.main()