diff --git a/README.md b/README.md index 1c9b728..4b53aed 100644 --- a/README.md +++ b/README.md @@ -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 /v1/sync/status +GET /health +GET /v1/sync/status + +GET /v1/block/latest +GET /v1/block/{block_no} + +GET /v1/address/{address}/balance +GET /v1/address/{address}/tokens?page=&limit= +GET /v1/address/{address}/transactions?page=&limit=&order= +GET /v1/address/{address}/utxos (standard+) + +GET /v1/tx/{tx_hash} +POST /v1/tx/submit (elevated+) + +GET /v1/asset/{policy_id}/info?page=&limit= +GET /v1/asset/{policy_id}/{asset_name}/holders?limit= + +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) ``` -### 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 -``` - -### Transactions -``` -GET /v1/tx/{tx_hash} -``` - -### Assets -``` -GET /v1/asset/{policy_id}/info -GET /v1/asset/{policy_id}/{asset_name}/holders?limit=20 -``` - -### Pools -``` -GET /v1/pool/{pool_id}/info -``` - -### 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: → { - 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. diff --git a/main.py b/main.py index 606cc84..3754ff4 100644 --- a/main.py +++ b/main.py @@ -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() @@ -248,8 +216,7 @@ 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 @@ -289,13 +256,12 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex: return False 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: @@ -310,8 +276,8 @@ def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex: if alg != -8: # -8 = EdDSA per COSE spec 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 @@ -397,8 +362,8 @@ 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) @@ -897,11 +851,10 @@ 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, @@ -990,9 +942,8 @@ 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: @@ -1019,8 +970,8 @@ async def submit_transaction( status_code=400, 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, @@ -1148,19 +1095,18 @@ 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: @@ -1170,8 +1116,8 @@ async def create_auth_challenge(request: AuthChallengeRequest, req: Request): status_code=429, 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: @@ -1215,15 +1161,14 @@ 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) @@ -1266,11 +1211,10 @@ 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()})") @@ -1302,10 +1247,9 @@ 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( @@ -1330,18 +1274,16 @@ async def refresh_auth( trp_balance = await get_trp_balance(owner) 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 @@ -1936,11 +1879,10 @@ async def get_pool_info(pool_id: str, auth: dict = Depends(get_auth_context)): async def create_api_key(key_data: APIKeyCreate, auth: dict = Depends(require_master_key)): """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] + "..."}