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 ## Stack
- **FastAPI** — async Python API framework - FastAPI / uvicorn
- **asyncpg** — async PostgreSQL driver - asyncpg → cardano-db-sync Postgres (`cexplorer`)
- **Redis** — rate limiting + response caching - 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 docker compose up -d --build
sudo 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 | Tier | TRP needed | Rate (req/min) | tx submit (per min) | Node read | Node submit |
- Anonymous (no key): 20 req/min per IP |---|---|---|---|---|---|
- Standard API key: 100 req/min | anonymous | 0 | 20 | 0 | no | no |
- Elevated API key: 1000 req/min | standard | ≥50 | 100 | 2 | yes | no |
- Master key: unlimited | elevated | ≥500 | 1000 | 10 | yes | yes |
| master | n/a | unlimited | unlimited | yes | yes |
### Using API Keys Anonymous is rate-limited per source IP. Authed tiers are rate-limited per key.
```bash
# Header (preferred) 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.
curl -H "X-API-Key: capi_xxx" http://127.0.0.1:8765/v1/block/latest
## 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 ## Endpoints
### Sync Status
``` ```
GET /health
GET /v1/sync/status GET /v1/sync/status
```
### Blocks
```
GET /v1/block/latest GET /v1/block/latest
GET /v1/block/{block_no} GET /v1/block/{block_no}
```
### Addresses
```
GET /v1/address/{address}/balance GET /v1/address/{address}/balance
GET /v1/address/{address}/tokens GET /v1/address/{address}/tokens?page=&limit=
GET /v1/address/{address}/transactions?page=1&limit=20&order=desc GET /v1/address/{address}/transactions?page=&limit=&order=
``` GET /v1/address/{address}/utxos (standard+)
### Transactions
```
GET /v1/tx/{tx_hash} GET /v1/tx/{tx_hash}
``` POST /v1/tx/submit (elevated+)
### Assets GET /v1/asset/{policy_id}/info?page=&limit=
``` GET /v1/asset/{policy_id}/{asset_name}/holders?limit=
GET /v1/asset/{policy_id}/info
GET /v1/asset/{policy_id}/{asset_name}/holders?limit=20
```
### Pools
```
GET /v1/pool/{pool_id}/info 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) ## Cache TTLs (Redis)
```
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
``` ```
DB_HOST=postgres-dbsync balance 60s
DB_PORT=5432 tokens 60s
DB_NAME=cexplorer transactions 30s
DB_USER=dbsync block_latest 10s
DB_PASS=... tx_details 300s (immutable)
REDIS_HOST=redis-api asset_info 120s
REDIS_PORT=6379 pool_info 120s
API_MASTER_KEY=capi_... 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 cardano-api REST API over cardano-db-sync + cardano-node.
Sits in front of cardano-db-sync PostgreSQL database.
Includes node integration for UTxO queries, tx submission, and protocol params. Reads hit the db-sync Postgres directly. UTxO queries, protocol params, and
TRP-gated permissionless API keys with CIP-8 wallet signature verification. 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: Known policy IDs:
- TRP: 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05 TRP 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
- MAP: 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c 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
""" """
import os import os
@ -135,10 +102,11 @@ CACHE_TTLS = {
# TRP-gated key expiry (48 hours) # TRP-gated key expiry (48 hours)
TRP_KEY_EXPIRY_HOURS = 48 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"} 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_RE = re.compile(r'^addr1[a-z0-9]{50,120}$')
ADDR_TEST_RE = re.compile(r'^addr_test1[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}$') 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 redis_client: Optional[redis.Redis] = None
protocol_params_cache: dict = {"data": None, "expires": 0} 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() _params_lock = asyncio.Lock()
# ============ Input Validation (Fix #8) ============ # ============ Input Validation ============
def validate_address(address: str) -> bool: def validate_address(address: str) -> bool:
"""Validate Cardano address format.""" """Validate Cardano address format."""
@ -175,7 +143,7 @@ def validate_policy_id(policy_id: str) -> bool:
# ============ Helper Functions ============ # ============ Helper Functions ============
def hash_api_key(key: str) -> str: 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() 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) # Decode the key (should be a 32-byte Ed25519 public key)
key_bytes = bytes.fromhex(key_hex) 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: 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'}") logger.warning(f"CIP-8 verification failed: invalid key length {len(key_bytes) if isinstance(key_bytes, bytes) else 'non-bytes'}")
return False 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 protected, unprotected, payload, sig = cose_sign1
# Fix #18: Validate COSE element types
if not isinstance(protected, bytes) or not isinstance(sig, bytes): if not isinstance(protected, bytes) or not isinstance(sig, bytes):
logger.warning("CIP-8 verification failed: invalid COSE_Sign1 element types") logger.warning("CIP-8 verification failed: invalid COSE_Sign1 element types")
return False return False
# Fix #18: Decode and validate protected header - must be EdDSA (-8) # Protected header must declare EdDSA (alg = -8).
try: try:
protected_decoded = cbor2.loads(protected) protected_decoded = cbor2.loads(protected)
except Exception: 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)") logger.warning(f"CIP-8 verification failed: invalid algorithm {alg}, expected -8 (EdDSA)")
return False return False
# Fix #12: Reject empty payloads - nonce verification must happen # Empty payloads bypass the nonce check; reject.
if not payload: if not payload:
logger.warning("CIP-8 verification rejected: empty payload") logger.warning("CIP-8 verification rejected: empty payload")
return False return False
@ -356,7 +322,7 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex:
# ============ Lifespan ============ # ============ Lifespan ============
async def refresh_trp_tiers_task(): 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: while True:
try: try:
await asyncio.sleep(600) # Run every 10 minutes (was 3600) 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(): async def refresh_all_trp_tiers():
""" """
Check all TRP-gated keys and update tiers if balance changed. 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: if not redis_client or not db_pool:
return return
# Fix #19: Get TRP-gated keys from dedicated set instead of SCAN
key_hashes = await redis_client.smembers("trp_gated_keys") key_hashes = await redis_client.smembers("trp_gated_keys")
if not key_hashes: if not key_hashes:
return return
@ -398,7 +363,7 @@ async def refresh_all_trp_tiers():
if not addresses: if not addresses:
return return
# Fix #19: Batch query all TRP balances in one Postgres call # Batch all TRP balances in one Postgres call.
try: try:
async with db_pool.acquire() as conn: async with db_pool.acquire() as conn:
results = await conn.fetch(""" results = await conn.fetch("""
@ -508,19 +473,20 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Cardano Chain Data API", title="Cardano Chain Data API",
description="REST API for querying Cardano blockchain data via db-sync and cardano-node", 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 lifespan=lifespan
) )
# ============ Fix #11: Request Body Size Limit Middleware ============ # ============ Request Body Size Limit ============
class LimitBodySizeMiddleware(BaseHTTPMiddleware): class LimitBodySizeMiddleware(BaseHTTPMiddleware):
""" """
Limit request body size on tx submit to prevent DoS. Cardano max tx is ~16KB. Cap /v1/tx/submit body size. Cardano max tx is ~16 KB; cap at 64 KB.
Fix #17: Actually reads body stream to catch chunked encoding / missing Content-Length. 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): async def dispatch(self, request: Request, call_next):
if request.url.path == "/v1/tx/submit": if request.url.path == "/v1/tx/submit":
@ -565,7 +531,7 @@ async def handle_undefined_table(request: Request, exc: UndefinedTableError):
@app.exception_handler(PostgresError) @app.exception_handler(PostgresError)
async def handle_postgres_error(request: Request, exc: 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}") logger.error(f"Database error: {exc}")
return JSONResponse( return JSONResponse(
status_code=503, status_code=503,
@ -625,29 +591,25 @@ class TxSubmitResponse(BaseModel):
async def get_api_key_info(key: str) -> Optional[dict]: 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. Look up an API key by sha256 hash. Enforces TRP-gated key expiry
Also enforces key expiry for TRP-gated keys. expired keys are deleted on read and treated as missing.
""" """
if not redis_client: if not redis_client:
return None return None
# Hash the key for lookup (Fix #4)
key_hash = hash_api_key(key) key_hash = hash_api_key(key)
data = await redis_client.hgetall(f"apikey:{key_hash}") data = await redis_client.hgetall(f"apikey:{key_hash}")
if not data: if not data:
return None return None
# Fix #10: Check expiry for TRP-gated keys
expires_at = data.get("expires_at") expires_at = data.get("expires_at")
if expires_at: if expires_at:
try: try:
expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
if datetime.now(timezone.utc) > expiry_time: 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')}") logger.info(f"API key expired for owner {data.get('owner', 'unknown')}")
await redis_client.delete(f"apikey:{key_hash}") 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) await redis_client.srem("trp_gated_keys", key_hash)
return None return None
except Exception as e: 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: def get_client_ip(request: Request) -> str:
""" """Extract client IP. X-Forwarded-For is only honoured from TRUSTED_PROXIES."""
Extract client IP from request. Fix #2: Only trust X-Forwarded-For from known proxies.
"""
client_host = request.client.host if request.client else "unknown" client_host = request.client.host if request.client else "unknown"
# Only trust X-Forwarded-For if connecting IP is a trusted proxy # Only trust X-Forwarded-For if connecting IP is a trusted proxy
@ -725,10 +685,10 @@ async def get_auth_context(
if key_info: if key_info:
return { return {
"tier": key_info.get("tier", "standard"), "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"), "label": key_info.get("label", "unknown"),
"owner": key_info.get("owner"), "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} 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)): async def require_standard_tier(auth: dict = Depends(get_auth_context)):
""" """Standard tier or higher — node read endpoints. Anonymous = db-sync only."""
Require standard tier or higher for node read endpoints.
Anonymous users get db-sync only - no direct node access.
"""
if auth["tier"] == "anonymous": if auth["tier"] == "anonymous":
raise HTTPException( raise HTTPException(
status_code=403, 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)): async def require_elevated_tier(auth: dict = Depends(get_auth_context)):
""" """Elevated tier — tx submission. Standard tier is read-only on the node."""
Require elevated tier for transaction submission.
Standard tier gets read-only node access, elevated gets write.
"""
if auth["tier"] not in ("elevated", "master"): if auth["tier"] not in ("elevated", "master"):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
@ -807,7 +761,7 @@ async def rate_limit_middleware(request: Request, call_next):
key_info = await get_api_key_info(key) key_info = await get_api_key_info(key)
if key_info: if key_info:
tier = key_info.get("tier", "standard") 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") label = key_info.get("label", "unknown")
# Check rate limit (general) # 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. Query UTxOs for an address directly from the node.
Faster than db-sync for current unspent outputs. Faster than db-sync for current unspent outputs.
Requires: standard tier (50+ TRP) or higher. Standard tier (50+ TRP) or higher. Anonymous tier should use
Anonymous users should use /v1/address/{address}/balance (db-sync) instead. /v1/address/{address}/balance (db-sync) instead.
""" """
# Fix #8: Validate address format
if not validate_address(address): if not validate_address(address):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -927,7 +880,6 @@ async def get_address_utxos(address: str, auth: dict = Depends(require_standard_
status_code=503, status_code=503,
detail={"error": "node_unavailable", "message": "Cardano node not available"} 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}") logger.error(f"cardano-cli utxo query failed: {stderr}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
@ -991,8 +943,7 @@ async def submit_transaction(
Submit a signed transaction to the network. Submit a signed transaction to the network.
Accepts hex or base64 encoded CBOR transaction. Accepts hex or base64 encoded CBOR transaction.
Requires: elevated tier (500+ TRP) or master key. Elevated tier (500+ TRP) or master key. Standard tier is node-read-only.
Standard tier gets read-only node access.
""" """
# Check tx submission rate limit (elevated tier still has limits) # Check tx submission rate limit (elevated tier still has limits)
allowed, retry_after = await check_rate_limit(auth["identifier"], auth["tier"], "tx_submit") 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)} detail={"error": "invalid_encoding", "message": str(e)}
) )
# Fix #7: Validate CBOR before proceeding # Validate CBOR before shelling out.
try: try:
tx_cbor = cbor2.loads(tx_bytes) tx_cbor = cbor2.loads(tx_bytes)
except Exception: except Exception:
@ -1020,7 +971,7 @@ async def submit_transaction(
detail={"error": "invalid_tx", "message": "Transaction is not valid CBOR"} 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: try:
if isinstance(tx_cbor, (list, tuple)) and len(tx_cbor) > 0: if isinstance(tx_cbor, (list, tuple)) and len(tx_cbor) > 0:
tx_body_cbor = cbor2.dumps(tx_cbor[0]) tx_body_cbor = cbor2.dumps(tx_cbor[0])
@ -1067,7 +1018,6 @@ async def submit_transaction(
status_code=400, status_code=400,
detail={"error": "value_not_conserved", "message": "Input/output value mismatch"} 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}") logger.error(f"tx submit failed: {stderr}")
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -1092,14 +1042,12 @@ async def submit_transaction(
@app.get("/v1/protocol-params") @app.get("/v1/protocol-params")
async def get_protocol_params(auth: dict = Depends(require_standard_tier)): async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
""" """
Get current epoch protocol parameters from the node. Get current epoch protocol parameters from the node. Cached 5 min.
Cached for 5 minutes. Standard tier (50+ TRP) or higher.
Requires: standard tier (50+ TRP) or higher.
""" """
global protocol_params_cache 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: async with _params_lock:
# Check cache inside the lock # Check cache inside the lock
if protocol_params_cache["data"] and protocol_params_cache["expires"] > time.time(): 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, status_code=503,
detail={"error": "node_unavailable", "message": "Cardano node not available"} detail={"error": "node_unavailable", "message": "Cardano node not available"}
) )
# Fix #5: Don't leak stderr
logger.error(f"protocol-params query failed: {stderr}") logger.error(f"protocol-params query failed: {stderr}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
@ -1149,18 +1096,17 @@ async def create_auth_challenge(request: AuthChallengeRequest, req: Request):
Create a challenge nonce for wallet signature verification. Create a challenge nonce for wallet signature verification.
The nonce must be signed with CIP-8 and submitted to /v1/auth/verify. 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() address = request.address.strip()
# Fix #8: Validate address format
if not validate_address(address): if not validate_address(address):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail={"error": "invalid_address", "message": "Invalid Cardano address format"} 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)}" challenge_rate_key = f"challenge_rate:{address}:{int(time.time() // 60)}"
current_count = await redis_client.incr(challenge_rate_key) current_count = await redis_client.incr(challenge_rate_key)
if current_count == 1: 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."} 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_key = f"challenge_count:{address}"
outstanding = await redis_client.incr(outstanding_key) outstanding = await redis_client.incr(outstanding_key)
if outstanding == 1: if outstanding == 1:
@ -1216,14 +1162,13 @@ async def verify_auth(request: AuthVerifyRequest):
address = request.address address = request.address
nonce = request.nonce nonce = request.nonce
# Fix #8: Validate address
if not validate_address(address): if not validate_address(address):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail={"error": "invalid_address", "message": "Invalid Cardano address format"} 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}" challenge_key = f"auth_challenge:{address}:{nonce}"
async with redis_client.pipeline(transaction=True) as pipe: 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"} 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}" outstanding_key = f"challenge_count:{address}"
await redis_client.decr(outstanding_key) await redis_client.decr(outstanding_key)
@ -1267,10 +1212,9 @@ async def verify_auth(request: AuthVerifyRequest):
# Generate API key # Generate API key
new_key = f"capi_{secrets.token_hex(24)}" 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) 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) key_hash = hash_api_key(new_key)
await redis_client.hset(f"apikey:{key_hash}", mapping={ await redis_client.hset(f"apikey:{key_hash}", mapping={
@ -1279,10 +1223,11 @@ async def verify_auth(request: AuthVerifyRequest):
"owner": address, "owner": address,
"trp_balance": str(trp_balance), "trp_balance": str(trp_balance),
"created_at": datetime.now(timezone.utc).isoformat(), "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) 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()})") 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. 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 Self-service only. The API key in the header is both the authentication
is both the authentication AND the key being refreshed. You cannot AND the key being refreshed you cannot use key A to refresh key B.
use key A to refresh key B.
""" """
if auth["tier"] == "anonymous": if auth["tier"] == "anonymous":
raise HTTPException( raise HTTPException(
@ -1331,17 +1275,15 @@ async def refresh_auth(
new_tier = get_tier_from_trp_balance(trp_balance) new_tier = get_tier_from_trp_balance(trp_balance)
old_tier = auth["tier"] 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) 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) key_hash = hash_api_key(x_api_key)
# Update key info
await redis_client.hset(f"apikey:{key_hash}", mapping={ await redis_client.hset(f"apikey:{key_hash}", mapping={
"tier": new_tier, "tier": new_tier,
"trp_balance": str(trp_balance), "trp_balance": str(trp_balance),
"expires_at": expires_at.isoformat() # Reset expiry "expires_at": expires_at.isoformat()
}) })
tier_changed = new_tier != old_tier 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") @app.get("/v1/address/{address}/balance")
async def get_address_balance(address: str, auth: dict = Depends(get_auth_context)): async def get_address_balance(address: str, auth: dict = Depends(get_auth_context)):
"""Get address balance including native tokens.""" """Get address balance including native tokens."""
# Fix #8: Validate address # Validate address
if not validate_address(address): if not validate_address(address):
raise HTTPException( raise HTTPException(
status_code=400, 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)"), limit: int = Query(100, ge=1, le=1000, description="Results per page (max 1000)"),
auth: dict = Depends(get_auth_context) auth: dict = Depends(get_auth_context)
): ):
"""Get native tokens held by an address. Fix #13: Now paginated.""" """Get native tokens held by an address. Paginated."""
# Fix #8: Validate address # Validate address
if not validate_address(address): if not validate_address(address):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -1526,7 +1468,7 @@ async def get_address_tokens(
total_count = count_result["total"] if count_result else 0 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(""" tokens = await conn.fetch("""
SELECT SELECT
encode(ma.policy, 'hex') as policy_id, encode(ma.policy, 'hex') as policy_id,
@ -1576,7 +1518,7 @@ async def get_address_transactions(
auth: dict = Depends(get_auth_context) auth: dict = Depends(get_auth_context)
): ):
"""Get transactions for an address.""" """Get transactions for an address."""
# Fix #8: Validate address # Validate address
if not validate_address(address): if not validate_address(address):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -1651,7 +1593,7 @@ async def get_address_transactions(
@app.get("/v1/tx/{tx_hash}") @app.get("/v1/tx/{tx_hash}")
async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)): async def get_transaction(tx_hash: str, auth: dict = Depends(get_auth_context)):
"""Get transaction details by hash.""" """Get transaction details by hash."""
# Fix #8: Validate tx hash # Validate tx hash
if not validate_tx_hash(tx_hash): if not validate_tx_hash(tx_hash):
raise HTTPException( raise HTTPException(
status_code=400, 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)"), limit: int = Query(100, ge=1, le=500, description="Results per page (max 500)"),
auth: dict = Depends(get_auth_context) auth: dict = Depends(get_auth_context)
): ):
"""Get info about all assets under a policy ID. Fix #13: Now paginated.""" """Get info about all assets under a policy ID. Paginated."""
# Fix #8: Validate policy ID # Validate policy ID
if not validate_policy_id(policy_id): if not validate_policy_id(policy_id):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -1769,7 +1711,7 @@ async def get_asset_info(
total_count = count_result["total"] if count_result else 0 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(""" assets = await conn.fetch("""
SELECT SELECT
encode(ma.name, 'hex') as asset_name_hex, encode(ma.name, 'hex') as asset_name_hex,
@ -1816,7 +1758,7 @@ async def get_asset_holders(
auth: dict = Depends(get_auth_context) auth: dict = Depends(get_auth_context)
): ):
"""Get top holders of a specific asset.""" """Get top holders of a specific asset."""
# Fix #8: Validate policy ID # Validate policy ID
if not validate_policy_id(policy_id): if not validate_policy_id(policy_id):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -1853,7 +1795,8 @@ async def get_asset_holders(
LIMIT $2 LIMIT $2
""", asset["id"], limit) """, 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(""" holder_count = await conn.fetchrow("""
SELECT COUNT(*) as count FROM ( SELECT COUNT(*) as count FROM (
SELECT txo.address 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).""" """Create a new API key (admin-created keys don't expire unless explicitly set)."""
new_key = f"capi_{secrets.token_hex(24)}" new_key = f"capi_{secrets.token_hex(24)}"
# Fix #4: Store using hashed key
key_hash = hash_api_key(new_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={ await redis_client.hset(f"apikey:{key_hash}", mapping={
"label": key_data.label, "label": key_data.label,
"tier": key_data.tier, "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}") @app.delete("/admin/keys/{key}")
async def revoke_api_key(key: str, auth: dict = Depends(require_master_key)): async def revoke_api_key(key: str, auth: dict = Depends(require_master_key)):
"""Revoke an API key.""" """Revoke an API key."""
# Fix #4: Hash the key for lookup
key_hash = hash_api_key(key) key_hash = hash_api_key(key)
deleted = await redis_client.delete(f"apikey:{key_hash}") deleted = await redis_client.delete(f"apikey:{key_hash}")
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail={"error": "not_found", "message": "API key not found"}) 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) await redis_client.srem("trp_gated_keys", key_hash)
return {"status": "revoked", "key": key[:16] + "..."} return {"status": "revoked", "key": key[:16] + "..."}