Public-flip audit: drop audit-ticket prefixes + topology refs + AI scaffolding

cardano-api: strip 'Fix #N:' audit-ticket prefixes from inline comments (was
50+ in main.py), drop hardening-pass changelog blocks from module docstring,
rewrite README to drop deploy paths + marketing sections, keep tier/auth/TTL
+ policy IDs.

cardano-checkout-py: drop TradeCraft lineage refs, swap chromaticcraft/tradecraft
test fixtures for acme/globex, repository URL → git.sulkta.com.
This commit is contained in:
Cobb Hayes 2026-05-27 11:15:02 -07:00
parent 11b551b0fe
commit aa8879bc69
2 changed files with 230 additions and 284 deletions

224
README.md
View file

@ -1,142 +1,148 @@
# Cardano Chain Data API
# cardano-api
REST API for querying Cardano blockchain data via db-sync PostgreSQL database.
REST API over cardano-db-sync + cardano-node. FastAPI + asyncpg + Redis.
Read paths hit the db-sync Postgres. UTxO queries, protocol params, and tx submit shell out to `cardano-cli` against a local node socket. Auth is TRP token-gated via CIP-8 wallet signatures.
## Stack
- **FastAPI** — async Python API framework
- **asyncpg** — async PostgreSQL driver
- **Redis** — rate limiting + response caching
- FastAPI / uvicorn
- asyncpg → cardano-db-sync Postgres (`cexplorer`)
- redis.asyncio → rate limiting + response cache + API-key storage
- cardano-cli (baked into the image) → node socket queries + tx submit
- pycardano + PyNaCl + cbor2 → CIP-8 verification
## Deployment
## Run
```bash
cd /opt/cardano/dbsync
sudo docker compose up -d --build
```
docker compose up -d --build
```
API runs on `127.0.0.1:8765` (localhost only, VPN access via future rebind to `192.168.254.105:8765`).
Listens on `:8765` inside the container. Wire it to whatever proxy / port-forward the deploy wants.
## Authentication
## Tiers + rate limits
### Rate Limits
- Anonymous (no key): 20 req/min per IP
- Standard API key: 100 req/min
- Elevated API key: 1000 req/min
- Master key: unlimited
| Tier | TRP needed | Rate (req/min) | tx submit (per min) | Node read | Node submit |
|---|---|---|---|---|---|
| anonymous | 0 | 20 | 0 | no | no |
| standard | ≥50 | 100 | 2 | yes | no |
| elevated | ≥500 | 1000 | 10 | yes | yes |
| master | n/a | unlimited | unlimited | yes | yes |
### Using API Keys
```bash
# Header (preferred)
curl -H "X-API-Key: capi_xxx" http://127.0.0.1:8765/v1/block/latest
Anonymous is rate-limited per source IP. Authed tiers are rate-limited per key.
TRP-gated keys expire 48h after issue and must be re-auth'd. A background task re-checks balances every 10 minutes and re-tiers in place.
## Auth flow (TRP-gated)
# Query param
curl http://127.0.0.1:8765/v1/block/latest?api_key=capi_xxx
```
POST /v1/auth/challenge { "address": "addr1..." }
→ { "nonce": "...", "expires_at": "..." }
# sign nonce with the wallet via CIP-8
POST /v1/auth/verify { "address", "nonce", "signature", "key" }
→ { "api_key": "capi_...", "tier", "trp_balance" }
POST /v1/auth/refresh (X-API-Key header — self-service only)
→ { "tier", "trp_balance", "expires_at", ... }
```
Master key is issued out-of-band via `API_MASTER_KEY` env. Master-key-created keys (`/admin/keys`) don't expire.
Header is preferred (`X-API-Key: capi_...`); `?api_key=` works too.
## Endpoints
### Sync Status
```
GET /health
GET /v1/sync/status
```
### Blocks
```
GET /v1/block/latest
GET /v1/block/{block_no}
```
### Addresses
```
GET /v1/address/{address}/balance
GET /v1/address/{address}/tokens
GET /v1/address/{address}/transactions?page=1&limit=20&order=desc
```
GET /v1/address/{address}/tokens?page=&limit=
GET /v1/address/{address}/transactions?page=&limit=&order=
GET /v1/address/{address}/utxos (standard+)
### Transactions
```
GET /v1/tx/{tx_hash}
```
POST /v1/tx/submit (elevated+)
### Assets
```
GET /v1/asset/{policy_id}/info
GET /v1/asset/{policy_id}/{asset_name}/holders?limit=20
```
GET /v1/asset/{policy_id}/info?page=&limit=
GET /v1/asset/{policy_id}/{asset_name}/holders?limit=
### Pools
```
GET /v1/pool/{pool_id}/info
GET /v1/protocol-params (standard+)
POST /v1/auth/challenge
POST /v1/auth/verify
POST /v1/auth/refresh
POST /admin/keys (master)
DELETE /admin/keys/{key} (master)
GET /admin/keys (master)
POST /admin/refresh-tiers (master)
GET /admin/stats (master)
```
### Admin (master key required)
```
POST /admin/keys — create API key
DELETE /admin/keys/{key} — revoke key
GET /admin/keys — list all keys
GET /admin/stats — usage stats
```
## Known Policy IDs
- **TRP**: `9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05`
- **MAP**: `24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c`
## Future: TRP Token Gating
Design for decentralized, permissionless API access based on TRP token holdings:
### Tier Mapping
- 0 TRP → anonymous rate limits (20 req/min)
- 50+ TRP → standard tier (100 req/min)
- 500+ TRP → elevated tier (1000 req/min)
### Implementation Plan
1. `POST /admin/keys/verify-trp` endpoint
2. Takes Cardano address, queries `/v1/address/{addr}/tokens`
3. Checks TRP policy balance
4. Auto-creates or upgrades API key based on holdings
5. Stores `owner` (address) and `trp_balance` in key hash
### Data Model (already in place)
API keys stored in Redis as:
```
apikey:<key> → {
tier: "standard"|"elevated",
label: "...",
owner: "addr1...", # Cardano address
trp_balance: 500, # Last verified TRP balance
created_at: "..."
}
```
### Benefits
- **Permissionless**: Anyone can verify holdings and get access
- **Decentralized**: No manual approval needed
- **Incentivized**: Holding TRP = better API access
- **Revocable**: Re-verify periodically to maintain tier
## Caching
Response TTLs:
- Balance/tokens: 60s
- Transactions: 30s
- Latest block: 10s
- TX details: 300s (immutable)
- Asset info: 120s
- Pool info: 120s
- Sync status: 5s
## Environment Variables
## Cache TTLs (Redis)
```
DB_HOST=postgres-dbsync
DB_PORT=5432
DB_NAME=cexplorer
DB_USER=dbsync
DB_PASS=...
REDIS_HOST=redis-api
REDIS_PORT=6379
API_MASTER_KEY=capi_...
balance 60s
tokens 60s
transactions 30s
block_latest 10s
tx_details 300s (immutable)
asset_info 120s
pool_info 120s
sync_status 5s
protocol_params 300s
utxos 10s
```
## Validation
Inputs hit regex gates before any DB query:
- bech32 mainnet/testnet addresses (`addr1...` / `addr_test1...`)
- 64-hex tx hashes
- 56-hex policy IDs
Tx submit body is capped at 64 KB (middleware reads the actual stream; chunked transfer can't bypass).
CIP-8 verification rejects non-EdDSA (`alg ≠ -8`), wrong key length, empty payload, payload-not-nonce, and bad key→address hash binding.
## Key storage
Keys are stored as `sha256(key)`. The raw key is returned exactly once at issue. Lookups, admin listing, and revoke all operate on the hash.
TRP-gated keys are tracked in a `trp_gated_keys` Redis set so the refresh task can batch a single Postgres query for all owner addresses instead of N+1.
## Known policy IDs
```
TRP 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
MAP 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c
```
## Environment
```
DB_HOST (default: postgres-dbsync — compose service name)
DB_PORT 5432
DB_NAME cexplorer
DB_USER dbsync
DB_PASS
REDIS_HOST (default: redis-api — compose service name)
REDIS_PORT 6379
API_MASTER_KEY unrestricted-tier key
CARDANO_NODE_SOCKET_PATH /node-ipc/node.socket
CARDANO_NETWORK mainnet
```
`TRUSTED_PROXIES` (in `main.py`) is the set of IPs whose `X-Forwarded-For` header is honoured. Defaults to loopback + the docker default bridges. If the deployment fronts the API with a different proxy, override the set.

210
main.py
View file

@ -1,54 +1,21 @@
"""
Cardano Chain Data REST API
Sits in front of cardano-db-sync PostgreSQL database.
Includes node integration for UTxO queries, tx submission, and protocol params.
TRP-gated permissionless API keys with CIP-8 wallet signature verification.
cardano-api REST API over cardano-db-sync + cardano-node.
Reads hit the db-sync Postgres directly. UTxO queries, protocol params, and
tx submit shell out to cardano-cli against a local node socket. Auth is
TRP-token-gated via CIP-8 wallet signatures; keys are stored as sha256(key).
Tiers:
anonymous 0 TRP 20 req/min db-sync only, no node access
standard >=50 TRP 100 req/min + node reads (utxos, protocol-params)
elevated >=500 TRP 1000 req/min + POST /v1/tx/submit
master env key unlimited everything
Node endpoints return 403 for insufficient tier.
Known policy IDs:
- TRP: 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
- MAP: 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c
Access Tiers (strictly enforced):
- Anonymous (0 TRP, 20 req/min):
db-sync read-only ONLY. Balance, transactions, tokens, blocks, assets, pools, sync status.
NO node access whatsoever.
- Standard (50 TRP, 100 req/min):
db-sync read + node read-only. UTxO queries, protocol params.
NO transaction submission.
- Elevated (500 TRP, 1000 req/min):
Everything above + POST /v1/tx/submit (transaction broadcasting).
- Master key: Unrestricted access.
Node endpoints (/v1/address/{addr}/utxos, /v1/protocol-params, /v1/tx/submit)
return HTTP 403 for insufficient tier.
Security hardening applied 2026-03-21:
- Fix #1: Atomic nonce GETDEL to prevent race conditions
- Fix #2: X-Forwarded-For only trusted from known proxies
- Fix #3: TRP refresh every 10 min + 48h key expiry for TRP-gated keys
- Fix #4: SHA-256 hashed key storage in Redis
- Fix #5: Generic error messages (no internal detail leakage)
- Fix #6: Auth refresh is self-service only (key refreshes itself)
- Fix #7: CBOR validation before tx submit
- Fix #8: Input validation regex for addresses, tx hashes, policy IDs
- Fix #9: Correct tx hash calculation (blake2b of tx body, not full tx)
- Fix #10: Enforce key expiry globally in get_api_key_info
Security hardening pass 2 (2026-03-21):
- Fix #11: Request body size limit (64KB) on /v1/tx/submit
- Fix #12: CIP-8 empty payload bypass fixed
- Fix #13: Pagination on /v1/address/{addr}/tokens and /v1/asset/{policy_id}/info
- Fix #14: cbor2 bumped to >=5.6.5 (CVE-2024-26134)
- Fix #15: Fixed holder count query (was using GROUP BY + COUNT DISTINCT incorrectly)
- Fix #16: Async lock for protocol params cache to prevent stampede
Security hardening pass 3 (2026-03-21):
- Fix #17: Body size middleware now reads actual body stream (catches chunked/missing Content-Length)
- Fix #17: Challenge flood prevention - per-address rate limit (5/min) + outstanding limit (10 max)
- Fix #18: COSE algorithm validation (must be EdDSA/-8)
- Fix #18: COSE element type validation (protected, sig must be bytes; key must be 32 bytes)
- Fix #19: TRP refresh uses Redis Set + batched Postgres query (no more N+1)
- Fix #20: Pagination page parameter capped at 10000 across all endpoints
TRP 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
MAP 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c
"""
import os
@ -135,10 +102,11 @@ CACHE_TTLS = {
# TRP-gated key expiry (48 hours)
TRP_KEY_EXPIRY_HOURS = 48
# Fix #2: Trusted proxies for X-Forwarded-For
# Proxies whose X-Forwarded-For we trust. Loopback + docker default bridges
# cover the standard compose deploy; override for any other front-end.
TRUSTED_PROXIES = {"127.0.0.1", "::1", "172.22.0.1", "172.17.0.1"}
# Fix #8: Input validation regexes
# Input validation regexes — gate all path params before any DB query.
ADDR_RE = re.compile(r'^addr1[a-z0-9]{50,120}$')
ADDR_TEST_RE = re.compile(r'^addr_test1[a-z0-9]{50,120}$')
HEX64_RE = re.compile(r'^[a-fA-F0-9]{64}$')
@ -149,11 +117,11 @@ db_pool: Optional[asyncpg.Pool] = None
redis_client: Optional[redis.Redis] = None
protocol_params_cache: dict = {"data": None, "expires": 0}
# Fix #16: Async lock for protocol params cache to prevent stampede
# Async lock for protocol params cache to prevent stampede.
_params_lock = asyncio.Lock()
# ============ Input Validation (Fix #8) ============
# ============ Input Validation ============
def validate_address(address: str) -> bool:
"""Validate Cardano address format."""
@ -175,7 +143,7 @@ def validate_policy_id(policy_id: str) -> bool:
# ============ Helper Functions ============
def hash_api_key(key: str) -> str:
"""Hash an API key for storage/lookup. Fix #4."""
"""Hash an API key for storage/lookup. Raw keys are never persisted."""
return hashlib.sha256(key.encode()).hexdigest()
@ -249,7 +217,6 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
# Decode the key (should be a 32-byte Ed25519 public key)
key_bytes = bytes.fromhex(key_hex)
# Fix #18: Validate key_bytes type and length
if not isinstance(key_bytes, bytes) or len(key_bytes) != 32:
logger.warning(f"CIP-8 verification failed: invalid key length {len(key_bytes) if isinstance(key_bytes, bytes) else 'non-bytes'}")
return False
@ -290,12 +257,11 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
protected, unprotected, payload, sig = cose_sign1
# Fix #18: Validate COSE element types
if not isinstance(protected, bytes) or not isinstance(sig, bytes):
logger.warning("CIP-8 verification failed: invalid COSE_Sign1 element types")
return False
# Fix #18: Decode and validate protected header - must be EdDSA (-8)
# Protected header must declare EdDSA (alg = -8).
try:
protected_decoded = cbor2.loads(protected)
except Exception:
@ -311,7 +277,7 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
logger.warning(f"CIP-8 verification failed: invalid algorithm {alg}, expected -8 (EdDSA)")
return False
# Fix #12: Reject empty payloads - nonce verification must happen
# Empty payloads bypass the nonce check; reject.
if not payload:
logger.warning("CIP-8 verification rejected: empty payload")
return False
@ -356,7 +322,7 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
# ============ Lifespan ============
async def refresh_trp_tiers_task():
"""Background task to refresh TRP tiers for all gated keys every 10 minutes. Fix #3."""
"""Background task: refresh TRP tiers for all gated keys every 10 minutes."""
while True:
try:
await asyncio.sleep(600) # Run every 10 minutes (was 3600)
@ -370,12 +336,11 @@ async def refresh_trp_tiers_task():
async def refresh_all_trp_tiers():
"""
Check all TRP-gated keys and update tiers if balance changed.
Fix #19: Uses Redis Set + batched Postgres query to avoid N+1.
Uses a dedicated Redis set + a single batched Postgres query to avoid N+1.
"""
if not redis_client or not db_pool:
return
# Fix #19: Get TRP-gated keys from dedicated set instead of SCAN
key_hashes = await redis_client.smembers("trp_gated_keys")
if not key_hashes:
return
@ -398,7 +363,7 @@ async def refresh_all_trp_tiers():
if not addresses:
return
# Fix #19: Batch query all TRP balances in one Postgres call
# Batch all TRP balances in one Postgres call.
try:
async with db_pool.acquire() as conn:
results = await conn.fetch("""
@ -508,19 +473,20 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Cardano Chain Data API",
description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
version="2.3.0", # Bumped for security hardening pass 3
version="2.3.0",
lifespan=lifespan
)
# ============ Fix #11: Request Body Size Limit Middleware ============
# ============ Request Body Size Limit ============
class LimitBodySizeMiddleware(BaseHTTPMiddleware):
"""
Limit request body size on tx submit to prevent DoS. Cardano max tx is ~16KB.
Fix #17: Actually reads body stream to catch chunked encoding / missing Content-Length.
Cap /v1/tx/submit body size. Cardano max tx is ~16 KB; cap at 64 KB.
Reads the actual body stream so chunked transfer / missing Content-Length
can't bypass the limit.
"""
MAX_TX_SIZE = 65536 # 64KB - generous limit
MAX_TX_SIZE = 65536
async def dispatch(self, request: Request, call_next):
if request.url.path == "/v1/tx/submit":
@ -565,7 +531,7 @@ async def handle_undefined_table(request: Request, exc: UndefinedTableError):
@app.exception_handler(PostgresError)
async def handle_postgres_error(request: Request, exc: PostgresError):
"""Handle general postgres errors. Fix #5: Don't leak internal details."""
"""Handle general postgres errors. Don't leak internal detail to the caller."""
logger.error(f"Database error: {exc}")
return JSONResponse(
status_code=503,
@ -625,29 +591,25 @@ class TxSubmitResponse(BaseModel):
async def get_api_key_info(key: str) -> Optional[dict]:
"""
Get API key info from Redis using hashed key lookup. Fix #4 and Fix #10.
Also enforces key expiry for TRP-gated keys.
Look up an API key by sha256 hash. Enforces TRP-gated key expiry
expired keys are deleted on read and treated as missing.
"""
if not redis_client:
return None
# Hash the key for lookup (Fix #4)
key_hash = hash_api_key(key)
data = await redis_client.hgetall(f"apikey:{key_hash}")
if not data:
return None
# Fix #10: Check expiry for TRP-gated keys
expires_at = data.get("expires_at")
if expires_at:
try:
expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
if datetime.now(timezone.utc) > expiry_time:
# Key expired, delete it and return None
logger.info(f"API key expired for owner {data.get('owner', 'unknown')}")
await redis_client.delete(f"apikey:{key_hash}")
# Fix #19: Also remove from TRP-gated keys set
await redis_client.srem("trp_gated_keys", key_hash)
return None
except Exception as e:
@ -692,9 +654,7 @@ async def set_cached(cache_key: str, data: dict, ttl: int):
def get_client_ip(request: Request) -> str:
"""
Extract client IP from request. Fix #2: Only trust X-Forwarded-For from known proxies.
"""
"""Extract client IP. X-Forwarded-For is only honoured from TRUSTED_PROXIES."""
client_host = request.client.host if request.client else "unknown"
# Only trust X-Forwarded-For if connecting IP is a trusted proxy
@ -725,10 +685,10 @@ async def get_auth_context(
if key_info:
return {
"tier": key_info.get("tier", "standard"),
"identifier": hash_api_key(key), # Use hash as identifier
"identifier": hash_api_key(key),
"label": key_info.get("label", "unknown"),
"owner": key_info.get("owner"),
"raw_key": key # Keep raw key for refresh endpoint
"raw_key": key # needed by the refresh endpoint
}
return {"tier": "anonymous", "identifier": client_ip, "label": None}
@ -742,10 +702,7 @@ async def require_master_key(auth: dict = Depends(get_auth_context)):
async def require_standard_tier(auth: dict = Depends(get_auth_context)):
"""
Require standard tier or higher for node read endpoints.
Anonymous users get db-sync only - no direct node access.
"""
"""Standard tier or higher — node read endpoints. Anonymous = db-sync only."""
if auth["tier"] == "anonymous":
raise HTTPException(
status_code=403,
@ -760,10 +717,7 @@ async def require_standard_tier(auth: dict = Depends(get_auth_context)):
async def require_elevated_tier(auth: dict = Depends(get_auth_context)):
"""
Require elevated tier for transaction submission.
Standard tier gets read-only node access, elevated gets write.
"""
"""Elevated tier — tx submission. Standard tier is read-only on the node."""
if auth["tier"] not in ("elevated", "master"):
raise HTTPException(
status_code=403,
@ -807,7 +761,7 @@ async def rate_limit_middleware(request: Request, call_next):
key_info = await get_api_key_info(key)
if key_info:
tier = key_info.get("tier", "standard")
identifier = hash_api_key(key) # Use hash as identifier
identifier = hash_api_key(key)
label = key_info.get("label", "unknown")
# Check rate limit (general)
@ -898,10 +852,9 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
Query UTxOs for an address directly from the node.
Faster than db-sync for current unspent outputs.
Requires: standard tier (50+ TRP) or higher.
Anonymous users should use /v1/address/{address}/balance (db-sync) instead.
Standard tier (50+ TRP) or higher. Anonymous tier should use
/v1/address/{address}/balance (db-sync) instead.
"""
# Fix #8: Validate address format
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -927,7 +880,6 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
status_code=503,
detail={"error": "node_unavailable", "message": "Cardano node not available"}
)
# Fix #5: Don't leak stderr details
logger.error(f"cardano-cli utxo query failed: {stderr}")
raise HTTPException(
status_code=500,
@ -991,8 +943,7 @@ async def submit_transaction(
Submit a signed transaction to the network.
Accepts hex or base64 encoded CBOR transaction.
Requires: elevated tier (500+ TRP) or master key.
Standard tier gets read-only node access.
Elevated tier (500+ TRP) or master key. Standard tier is node-read-only.
"""
# Check tx submission rate limit (elevated tier still has limits)
allowed, retry_after = await check_rate_limit(auth["identifier"], auth["tier"], "tx_submit")
@ -1011,7 +962,7 @@ async def submit_transaction(
detail={"error": "invalid_encoding", "message": str(e)}
)
# Fix #7: Validate CBOR before proceeding
# Validate CBOR before shelling out.
try:
tx_cbor = cbor2.loads(tx_bytes)
except Exception:
@ -1020,7 +971,7 @@ async def submit_transaction(
detail={"error": "invalid_tx", "message": "Transaction is not valid CBOR"}
)
# Fix #9: Calculate correct tx hash from tx body (index 0), not full tx
# Cardano tx hash is blake2b of the tx body (index 0), not the full tx.
try:
if isinstance(tx_cbor, (list, tuple)) and len(tx_cbor) > 0:
tx_body_cbor = cbor2.dumps(tx_cbor[0])
@ -1067,7 +1018,6 @@ async def submit_transaction(
status_code=400,
detail={"error": "value_not_conserved", "message": "Input/output value mismatch"}
)
# Fix #5: Don't leak full stderr for other errors
logger.error(f"tx submit failed: {stderr}")
raise HTTPException(
status_code=400,
@ -1092,14 +1042,12 @@ async def submit_transaction(
@app.get("/v1/protocol-params")
async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
"""
Get current epoch protocol parameters from the node.
Cached for 5 minutes.
Requires: standard tier (50+ TRP) or higher.
Get current epoch protocol parameters from the node. Cached 5 min.
Standard tier (50+ TRP) or higher.
"""
global protocol_params_cache
# Fix #16: Use async lock to prevent cache stampede
# Lock to prevent cache stampede on first-load.
async with _params_lock:
# Check cache inside the lock
if protocol_params_cache["data"] and protocol_params_cache["expires"] > time.time():
@ -1117,7 +1065,6 @@ async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
status_code=503,
detail={"error": "node_unavailable", "message": "Cardano node not available"}
)
# Fix #5: Don't leak stderr
logger.error(f"protocol-params query failed: {stderr}")
raise HTTPException(
status_code=500,
@ -1149,18 +1096,17 @@ async def create_auth_challenge(request: AuthChallengeRequest, req: Request):
Create a challenge nonce for wallet signature verification.
The nonce must be signed with CIP-8 and submitted to /v1/auth/verify.
Fix #17: Per-address rate limit to prevent challenge flood / Redis memory exhaustion.
Per-address rate-limited to prevent challenge flood / Redis memory blow-up.
"""
address = request.address.strip()
# Fix #8: Validate address format
if not validate_address(address):
raise HTTPException(
status_code=400,
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
)
# Fix #17: Per-address challenge rate limit: 5 per minute
# 5 challenges/min per address.
challenge_rate_key = f"challenge_rate:{address}:{int(time.time() // 60)}"
current_count = await redis_client.incr(challenge_rate_key)
if current_count == 1:
@ -1171,7 +1117,7 @@ async def create_auth_challenge(request: AuthChallengeRequest, req: Request):
detail={"error": "too_many_challenges", "message": "Too many challenge requests. Try again in a minute."}
)
# Fix #17: Enforce max outstanding challenges per address (prevent accumulation across minutes)
# And cap outstanding (unverified) challenges per address — prevents accumulation across minutes.
outstanding_key = f"challenge_count:{address}"
outstanding = await redis_client.incr(outstanding_key)
if outstanding == 1:
@ -1216,14 +1162,13 @@ async def verify_auth(request: AuthVerifyRequest):
address = request.address
nonce = request.nonce
# Fix #8: Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
)
# Fix #1: Atomic nonce check-and-delete using pipeline
# Atomic GET+DEL the nonce — closes the verify-twice race window.
challenge_key = f"auth_challenge:{address}:{nonce}"
async with redis_client.pipeline(transaction=True) as pipe:
@ -1237,7 +1182,7 @@ async def verify_auth(request: AuthVerifyRequest):
detail={"error": "invalid_nonce", "message": "Nonce expired or already used"}
)
# Fix #17: Decrement outstanding challenge counter since nonce is now consumed
# Nonce consumed — drop one from the outstanding counter.
outstanding_key = f"challenge_count:{address}"
await redis_client.decr(outstanding_key)
@ -1267,10 +1212,9 @@ async def verify_auth(request: AuthVerifyRequest):
# Generate API key
new_key = f"capi_{secrets.token_hex(24)}"
# Fix #3: Set expiry 48 hours from now for TRP-gated keys
# TRP-gated keys expire 48h after issue — re-auth via /v1/auth/refresh.
expires_at = datetime.now(timezone.utc) + timedelta(hours=TRP_KEY_EXPIRY_HOURS)
# Fix #4: Store using hashed key, not raw key
key_hash = hash_api_key(new_key)
await redis_client.hset(f"apikey:{key_hash}", mapping={
@ -1279,10 +1223,11 @@ async def verify_auth(request: AuthVerifyRequest):
"owner": address,
"trp_balance": str(trp_balance),
"created_at": datetime.now(timezone.utc).isoformat(),
"expires_at": expires_at.isoformat() # Fix #3: Add expiry
"expires_at": expires_at.isoformat()
})
# Fix #19: Track TRP-gated keys in a set for efficient refresh
# Track TRP-gated keys in a set — the refresh task batches a single
# Postgres query over the owner addresses instead of N+1.
await redis_client.sadd("trp_gated_keys", key_hash)
logger.info(f"Issued {tier} API key for {address[:20]}... (TRP: {trp_balance}, expires: {expires_at.isoformat()})")
@ -1303,9 +1248,8 @@ async def refresh_auth(
"""
Re-check TRP balance and upgrade/downgrade tier for an existing key.
Fix #6: This endpoint is self-service only. The API key in the header
is both the authentication AND the key being refreshed. You cannot
use key A to refresh key B.
Self-service only. The API key in the header is both the authentication
AND the key being refreshed you cannot use key A to refresh key B.
"""
if auth["tier"] == "anonymous":
raise HTTPException(
@ -1331,17 +1275,15 @@ async def refresh_auth(
new_tier = get_tier_from_trp_balance(trp_balance)
old_tier = auth["tier"]
# Fix #3: Reset expiry on refresh
# Refresh resets the 48h expiry too.
expires_at = datetime.now(timezone.utc) + timedelta(hours=TRP_KEY_EXPIRY_HOURS)
# Use hashed key for storage (Fix #4)
key_hash = hash_api_key(x_api_key)
# Update key info
await redis_client.hset(f"apikey:{key_hash}", mapping={
"tier": new_tier,
"trp_balance": str(trp_balance),
"expires_at": expires_at.isoformat() # Reset expiry
"expires_at": expires_at.isoformat()
})
tier_changed = new_tier != old_tier
@ -1431,7 +1373,7 @@ async def get_block(block_no: int, auth: dict = Depends(get_auth_context)):
@app.get("/v1/address/{address}/balance")
async def get_address_balance(address: str, auth: dict = Depends(get_auth_context)):
"""Get address balance including native tokens."""
# Fix #8: Validate address
# Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -1499,8 +1441,8 @@ async def get_address_tokens(
limit: int = Query(100, ge=1, le=1000, description="Results per page (max 1000)"),
auth: dict = Depends(get_auth_context)
):
"""Get native tokens held by an address. Fix #13: Now paginated."""
# Fix #8: Validate address
"""Get native tokens held by an address. Paginated."""
# Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -1526,7 +1468,7 @@ async def get_address_tokens(
total_count = count_result["total"] if count_result else 0
# Fix #13: Add LIMIT and OFFSET for pagination
# LIMIT/OFFSET for pagination
tokens = await conn.fetch("""
SELECT
encode(ma.policy, 'hex') as policy_id,
@ -1576,7 +1518,7 @@ async def get_address_transactions(
auth: dict = Depends(get_auth_context)
):
"""Get transactions for an address."""
# Fix #8: Validate address
# Validate address
if not validate_address(address):
raise HTTPException(
status_code=400,
@ -1651,7 +1593,7 @@ async def get_address_transactions(
@app.get("/v1/tx/{tx_hash}")
async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
"""Get transaction details by hash."""
# Fix #8: Validate tx hash
# Validate tx hash
if not validate_tx_hash(tx_hash):
raise HTTPException(
status_code=400,
@ -1743,8 +1685,8 @@ async def get_asset_info(
limit: int = Query(100, ge=1, le=500, description="Results per page (max 500)"),
auth: dict = Depends(get_auth_context)
):
"""Get info about all assets under a policy ID. Fix #13: Now paginated."""
# Fix #8: Validate policy ID
"""Get info about all assets under a policy ID. Paginated."""
# Validate policy ID
if not validate_policy_id(policy_id):
raise HTTPException(
status_code=400,
@ -1769,7 +1711,7 @@ async def get_asset_info(
total_count = count_result["total"] if count_result else 0
# Fix #13: Add LIMIT and OFFSET for pagination
# LIMIT/OFFSET for pagination
assets = await conn.fetch("""
SELECT
encode(ma.name, 'hex') as asset_name_hex,
@ -1816,7 +1758,7 @@ async def get_asset_holders(
auth: dict = Depends(get_auth_context)
):
"""Get top holders of a specific asset."""
# Fix #8: Validate policy ID
# Validate policy ID
if not validate_policy_id(policy_id):
raise HTTPException(
status_code=400,
@ -1853,7 +1795,8 @@ async def get_asset_holders(
LIMIT $2
""", asset["id"], limit)
# Fix #15: Correct holder count query using subquery
# Holder count via subquery — naive COUNT DISTINCT + GROUP BY misses
# the HAVING SUM > 0 filter and over-counts addresses that net to zero.
holder_count = await conn.fetchrow("""
SELECT COUNT(*) as count FROM (
SELECT txo.address
@ -1937,10 +1880,9 @@ async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_ma
"""Create a new API key (admin-created keys don't expire unless explicitly set)."""
new_key = f"capi_{secrets.token_hex(24)}"
# Fix #4: Store using hashed key
key_hash = hash_api_key(new_key)
# Admin-created keys don't expire by default (Fix #10)
# Admin-created keys don't expire by default — only TRP-gated keys do.
await redis_client.hset(f"apikey:{key_hash}", mapping={
"label": key_data.label,
"tier": key_data.tier,
@ -1962,13 +1904,11 @@ async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_ma
@app.delete("/admin/keys/{key}")
async def revoke_api_key(key: str, auth: dict = Depends(require_master_key)):
"""Revoke an API key."""
# Fix #4: Hash the key for lookup
key_hash = hash_api_key(key)
deleted = await redis_client.delete(f"apikey:{key_hash}")
if not deleted:
raise HTTPException(status_code=404, detail={"error": "not_found", "message": "API key not found"})
# Fix #19: Remove from TRP-gated keys set
await redis_client.srem("trp_gated_keys", key_hash)
return {"status": "revoked", "key": key[:16] + "..."}