feat: Add node integration, TRP-gated auth, CIP-8 verification

- Node integration endpoints:
  - GET /v1/address/{address}/utxos - query UTxOs directly from node
  - POST /v1/tx/submit - submit signed transactions
  - GET /v1/protocol-params - current epoch protocol parameters

- TRP-gated permissionless API keys:
  - POST /v1/auth/challenge - get nonce for wallet signing
  - POST /v1/auth/verify - verify CIP-8 signature, issue key based on TRP balance
  - POST /v1/auth/refresh - re-check TRP balance and update tier
  - Background task: hourly tier refresh for all TRP-gated keys

- Tier thresholds: 50+ TRP = standard, 500+ TRP = elevated
- TX submit rate limits: anonymous=blocked, standard=2/min, elevated=10/min
- Added pycardano, cbor2, PyNaCl dependencies
- Updated Dockerfile with cardano-cli binary
This commit is contained in:
Sulkta 2026-03-21 08:52:46 -07:00
parent be34425d1e
commit 00c44fd0f2
3 changed files with 756 additions and 22 deletions

View file

@ -1,6 +1,36 @@
FROM python:3.12-slim FROM python:3.12-slim
# Install dependencies for cardano-cli
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
libsodium23 \
libsecp256k1-1 \
libnuma1 \
&& rm -rf /var/lib/apt/lists/*
# Download and install cardano-cli
# Using the official release from cardano-node 10.4.1 (compatible with current network)
ARG CARDANO_CLI_VERSION=10.4.1
RUN curl -L "https://github.com/IntersectMBO/cardano-node/releases/download/${CARDANO_CLI_VERSION}/cardano-node-${CARDANO_CLI_VERSION}-linux.tar.gz" \
-o /tmp/cardano-node.tar.gz && \
mkdir -p /tmp/cardano && \
tar -xzf /tmp/cardano-node.tar.gz -C /tmp/cardano && \
cp /tmp/cardano/bin/cardano-cli /usr/local/bin/ && \
chmod +x /usr/local/bin/cardano-cli && \
rm -rf /tmp/cardano /tmp/cardano-node.tar.gz
# Verify cardano-cli installation
RUN cardano-cli --version
WORKDIR /app WORKDIR /app
# Install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . . COPY . .
# Run with uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765", "--workers", "2"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765", "--workers", "2"]

745
main.py
View file

@ -1,18 +1,17 @@
""" """
Cardano Chain Data REST API Cardano Chain Data REST API
Sits in front of cardano-db-sync PostgreSQL database. 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.
Known policy IDs: Known policy IDs:
- TRP: 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05 - TRP: 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
- MAP: 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c - MAP: 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c
Future TRP gating design: TRP Gating Tiers:
- POST /admin/keys/verify-trp check address TRP balance, auto-upgrade tier
- 0 TRP anonymous rate limits (20 req/min) - 0 TRP anonymous rate limits (20 req/min)
- 50+ TRP standard tier (100 req/min) - 50+ TRP standard tier (100 req/min)
- 500+ TRP elevated tier (1000 req/min) - 500+ TRP elevated tier (1000 req/min)
- Verification: query /v1/address/{addr}/tokens, check TRP policy balance
- Makes API access decentralized and permissionless hold tokens, get access
""" """
import os import os
@ -21,16 +20,21 @@ import hashlib
import secrets import secrets
import time import time
import logging import logging
from datetime import datetime, timezone import asyncio
import subprocess
import tempfile
import base64
from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException, Query, Header, Depends from fastapi import FastAPI, Request, HTTPException, Query, Header, Depends, BackgroundTasks
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
import asyncpg import asyncpg
from asyncpg.exceptions import UndefinedTableError, PostgresError from asyncpg.exceptions import UndefinedTableError, PostgresError
import redis.asyncio as redis import redis.asyncio as redis
import cbor2
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@ -45,6 +49,19 @@ DB_PASS = os.getenv("DB_PASS", "")
REDIS_HOST = os.getenv("REDIS_HOST", "redis-api") REDIS_HOST = os.getenv("REDIS_HOST", "redis-api")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
API_MASTER_KEY = os.getenv("API_MASTER_KEY", "") API_MASTER_KEY = os.getenv("API_MASTER_KEY", "")
CARDANO_NODE_SOCKET_PATH = os.getenv("CARDANO_NODE_SOCKET_PATH", "/node-ipc/node.socket")
CARDANO_NETWORK = os.getenv("CARDANO_NETWORK", "mainnet")
# Known policy IDs
TRP_POLICY_ID = "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05"
MAP_POLICY_ID = "24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c"
# TRP Tier thresholds
TRP_TIERS = {
"elevated": 500,
"standard": 50,
"anonymous": 0
}
# Rate limits (requests per minute) # Rate limits (requests per minute)
RATE_LIMITS = { RATE_LIMITS = {
@ -54,6 +71,14 @@ RATE_LIMITS = {
"master": float("inf") "master": float("inf")
} }
# TX Submit rate limits (per minute)
TX_SUBMIT_LIMITS = {
"anonymous": 0, # blocked
"standard": 2,
"elevated": 10,
"master": float("inf")
}
# Cache TTLs (seconds) # Cache TTLs (seconds)
CACHE_TTLS = { CACHE_TTLS = {
"balance": 60, "balance": 60,
@ -63,12 +88,225 @@ CACHE_TTLS = {
"tx_details": 300, "tx_details": 300,
"asset_info": 120, "asset_info": 120,
"pool_info": 120, "pool_info": 120,
"sync_status": 5 "sync_status": 5,
"protocol_params": 300, # 5 min cache for protocol params
"utxos": 10 # Short cache for UTxOs
} }
# Global connections # Global connections
db_pool: Optional[asyncpg.Pool] = None 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}
# ============ Helper Functions ============
def run_cardano_cli(args: list[str], timeout: int = 30) -> tuple[bool, str, str]:
"""Run cardano-cli command and return (success, stdout, stderr)."""
cmd = ["cardano-cli"] + args
env = os.environ.copy()
env["CARDANO_NODE_SOCKET_PATH"] = CARDANO_NODE_SOCKET_PATH
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=env
)
return result.returncode == 0, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return False, "", "Command timed out"
except FileNotFoundError:
return False, "", "cardano-cli not found"
except Exception as e:
return False, "", str(e)
def decode_hex_or_base64(data: str) -> bytes:
"""Decode hex or base64 encoded data."""
# Try hex first
try:
if data.startswith("0x"):
return bytes.fromhex(data[2:])
return bytes.fromhex(data)
except ValueError:
pass
# Try base64
try:
return base64.b64decode(data)
except Exception:
pass
raise ValueError("Invalid hex or base64 encoding")
def get_tier_from_trp_balance(balance: int) -> str:
"""Determine tier based on TRP balance."""
if balance >= TRP_TIERS["elevated"]:
return "elevated"
elif balance >= TRP_TIERS["standard"]:
return "standard"
return "anonymous"
# ============ CIP-8 Signature Verification ============
def verify_cip8_signature(address: str, nonce: str, signature_hex: str, key_hex: str) -> bool:
"""
Verify a CIP-8 message signature.
CIP-8 spec: https://cips.cardano.org/cip/CIP-8
The signature is over a COSE_Sign1 structure containing the message.
We use pycardano for verification when available, with fallback to manual verification.
"""
try:
from pycardano import Address, VerificationKey
from pycardano.hash import blake2b_224
# Decode the key (should be a 32-byte Ed25519 public key)
key_bytes = bytes.fromhex(key_hex)
vkey = VerificationKey.from_primitive(key_bytes)
# Verify the public key hashes to the address
addr = Address.from_primitive(address)
# Get the payment credential hash from the address
if addr.payment_part is None:
logger.warning("Address has no payment part")
return False
expected_keyhash = addr.payment_part.payload
# Hash the verification key to get keyhash
actual_keyhash = blake2b_224(key_bytes)
if expected_keyhash != actual_keyhash:
logger.warning("Key hash mismatch")
return False
# Decode the COSE signature
signature_bytes = bytes.fromhex(signature_hex)
# CIP-8 uses COSE_Sign1 structure
# The signature is a CBOR array: [protected_headers, unprotected_headers, payload, signature]
cose_sign1 = cbor2.loads(signature_bytes)
if not isinstance(cose_sign1, (list, tuple)) or len(cose_sign1) != 4:
# Try unwrapping if it's tagged
if hasattr(cose_sign1, 'value'):
cose_sign1 = cose_sign1.value
if not isinstance(cose_sign1, (list, tuple)) or len(cose_sign1) != 4:
logger.warning("Invalid COSE_Sign1 structure")
return False
protected, unprotected, payload, sig = cose_sign1
# The payload should contain our nonce
if payload:
payload_decoded = payload if isinstance(payload, bytes) else bytes(payload)
# Payload might be hex-encoded nonce or raw bytes
try:
if payload_decoded.hex() != nonce and payload_decoded.decode('utf-8') != nonce:
logger.warning("Payload doesn't match nonce")
return False
except:
if payload_decoded.hex() != nonce:
logger.warning("Payload doesn't match nonce (hex check)")
return False
# Build the Sig_structure for verification
# Sig_structure = ["Signature1", protected, external_aad, payload]
external_aad = b""
sig_structure = cbor2.dumps(["Signature1", protected, external_aad, payload or b""])
# Verify the Ed25519 signature
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignature
verify_key = VerifyKey(key_bytes)
try:
verify_key.verify(sig_structure, sig)
return True
except BadSignature:
logger.warning("Bad signature")
return False
except ImportError as e:
logger.error(f"Missing dependency for CIP-8 verification: {e}")
return False
except Exception as e:
logger.error(f"CIP-8 verification error: {e}")
return False
# ============ Lifespan ============
async def refresh_trp_tiers_task():
"""Background task to refresh TRP tiers for all gated keys every hour."""
while True:
try:
await asyncio.sleep(3600) # Run every hour
await refresh_all_trp_tiers()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in TRP tier refresh task: {e}")
async def refresh_all_trp_tiers():
"""Check all TRP-gated keys and update tiers if balance changed."""
if not redis_client or not db_pool:
return
cursor = 0
updated = 0
while True:
cursor, keys = await redis_client.scan(cursor, match="apikey:*", count=100)
for key in keys:
data = await redis_client.hgetall(key)
owner = data.get("owner")
if owner and owner.startswith("addr"):
# This is a TRP-gated key, check balance
try:
trp_balance = await get_trp_balance(owner)
new_tier = get_tier_from_trp_balance(trp_balance)
old_tier = data.get("tier", "anonymous")
if new_tier != old_tier:
await redis_client.hset(key, mapping={
"tier": new_tier,
"trp_balance": str(trp_balance)
})
logger.info(f"Updated tier for {owner}: {old_tier} -> {new_tier}")
updated += 1
except Exception as e:
logger.error(f"Error checking TRP balance for {owner}: {e}")
if cursor == 0:
break
if updated > 0:
logger.info(f"TRP tier refresh complete: {updated} keys updated")
async def get_trp_balance(address: str) -> int:
"""Get TRP token balance for an address from db-sync."""
async with db_pool.acquire() as conn:
result = await conn.fetchrow("""
SELECT COALESCE(SUM(mto.quantity), 0) as quantity
FROM ma_tx_out mto
JOIN multi_asset ma ON ma.id = mto.ident
JOIN tx_out txo ON txo.id = mto.tx_out_id
LEFT JOIN tx_in txi ON txi.tx_out_id = txo.tx_id AND txi.tx_out_index = txo.index
WHERE txo.address = $1
AND ma.policy = decode($2, 'hex')
AND txi.id IS NULL
""", address, TRP_POLICY_ID)
return int(result["quantity"]) if result else 0
@asynccontextmanager @asynccontextmanager
@ -92,10 +330,25 @@ async def lifespan(app: FastAPI):
logger.info(f"Connecting to Redis at {REDIS_HOST}:{REDIS_PORT}") logger.info(f"Connecting to Redis at {REDIS_HOST}:{REDIS_PORT}")
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
# Check node socket
if os.path.exists(CARDANO_NODE_SOCKET_PATH):
logger.info(f"Cardano node socket found at {CARDANO_NODE_SOCKET_PATH}")
else:
logger.warning(f"Cardano node socket NOT found at {CARDANO_NODE_SOCKET_PATH} - node endpoints will fail")
# Start background task for TRP tier refresh
tier_refresh_task = asyncio.create_task(refresh_trp_tiers_task())
logger.info("Cardano API started successfully") logger.info("Cardano API started successfully")
yield yield
# Cleanup # Cleanup
tier_refresh_task.cancel()
try:
await tier_refresh_task
except asyncio.CancelledError:
pass
if db_pool: if db_pool:
await db_pool.close() await db_pool.close()
if redis_client: if redis_client:
@ -104,8 +357,8 @@ 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", description="REST API for querying Cardano blockchain data via db-sync and cardano-node",
version="1.0.0", version="2.0.0",
lifespan=lifespan lifespan=lifespan
) )
@ -149,6 +402,37 @@ class APIKeyResponse(BaseModel):
created_at: str created_at: str
class AuthChallengeRequest(BaseModel):
address: str = Field(..., description="Cardano address (bech32)")
class AuthChallengeResponse(BaseModel):
nonce: str
expires_at: str
class AuthVerifyRequest(BaseModel):
address: str = Field(..., description="Cardano address (bech32)")
nonce: str = Field(..., description="Nonce from challenge")
signature: str = Field(..., description="CIP-8 signature (hex)")
key: str = Field(..., description="Public key used for signing (hex)")
class AuthVerifyResponse(BaseModel):
api_key: str
tier: str
trp_balance: int
class TxSubmitRequest(BaseModel):
tx: str = Field(..., description="Signed transaction in hex or base64 CBOR encoding")
class TxSubmitResponse(BaseModel):
tx_hash: str
message: str
# ============ Helpers ============ # ============ Helpers ============
async def get_api_key_info(key: str) -> Optional[dict]: async def get_api_key_info(key: str) -> Optional[dict]:
@ -159,13 +443,18 @@ async def get_api_key_info(key: str) -> Optional[dict]:
return data if data else None return data if data else None
async def check_rate_limit(identifier: str, tier: str) -> tuple[bool, int]: async def check_rate_limit(identifier: str, tier: str, limit_type: str = "general") -> tuple[bool, int]:
"""Check rate limit. Returns (allowed, retry_after_seconds).""" """Check rate limit. Returns (allowed, retry_after_seconds)."""
if tier == "master": if tier == "master":
return True, 0 return True, 0
limit = RATE_LIMITS.get(tier, RATE_LIMITS["anonymous"]) limits = TX_SUBMIT_LIMITS if limit_type == "tx_submit" else RATE_LIMITS
key = f"ratelimit:{identifier}:{int(time.time() // 60)}" limit = limits.get(tier, limits.get("anonymous", 0))
if limit == 0:
return False, 0 # Blocked entirely
key = f"ratelimit:{limit_type}:{identifier}:{int(time.time() // 60)}"
current = await redis_client.incr(key) current = await redis_client.incr(key)
if current == 1: if current == 1:
@ -217,7 +506,8 @@ async def get_auth_context(
return { return {
"tier": key_info.get("tier", "standard"), "tier": key_info.get("tier", "standard"),
"identifier": key, "identifier": key,
"label": key_info.get("label", "unknown") "label": key_info.get("label", "unknown"),
"owner": key_info.get("owner")
} }
return {"tier": "anonymous", "identifier": client_ip, "label": None} return {"tier": "anonymous", "identifier": client_ip, "label": None}
@ -263,7 +553,7 @@ async def rate_limit_middleware(request: Request, call_next):
identifier = key identifier = key
label = key_info.get("label", "unknown") label = key_info.get("label", "unknown")
# Check rate limit # Check rate limit (general)
allowed, retry_after = await check_rate_limit(identifier, tier) allowed, retry_after = await check_rate_limit(identifier, tier)
if not allowed: if not allowed:
logger.warning(f"Rate limit exceeded: {identifier} ({tier})") logger.warning(f"Rate limit exceeded: {identifier} ({tier})")
@ -311,18 +601,413 @@ async def get_sync_status(auth: dict = Depends(get_auth_context)):
if not db_tip: if not db_tip:
return {"error": "db_sync_not_ready", "message": "No blocks synced yet"} return {"error": "db_sync_not_ready", "message": "No blocks synced yet"}
# Check node tip for comparison
node_tip = None
success, stdout, stderr = run_cardano_cli(["query", "tip", "--mainnet"])
if success:
try:
node_tip = json.loads(stdout)
except:
pass
result = { result = {
"db_sync_tip": db_tip["block_no"], "db_sync_tip": db_tip["block_no"],
"epoch_no": db_tip["epoch_no"], "epoch_no": db_tip["epoch_no"],
"slot_no": db_tip["slot_no"], "slot_no": db_tip["slot_no"],
"last_block_time": db_tip["time"].isoformat() if db_tip["time"] else None, "last_block_time": db_tip["time"].isoformat() if db_tip["time"] else None,
"is_syncing": True # Assume syncing unless we have better info "is_syncing": True, # Assume syncing unless we have better info
"node_tip": node_tip.get("block") if node_tip else None,
"node_connected": node_tip is not None
} }
# If we have both tips, calculate sync percentage
if node_tip and db_tip["block_no"]:
node_block = node_tip.get("block", 0)
if node_block > 0:
sync_pct = (db_tip["block_no"] / node_block) * 100
result["sync_percentage"] = round(sync_pct, 2)
result["is_syncing"] = sync_pct < 99.9
await set_cached(cache_key, result, CACHE_TTLS["sync_status"]) await set_cached(cache_key, result, CACHE_TTLS["sync_status"])
return result return result
# ============ Node Integration Endpoints ============
@app.get("/v1/address/{address}/utxos")
async def get_address_utxos(address: str, auth: dict = Depends(get_auth_context)):
"""
Query UTxOs for an address directly from the node.
Faster than db-sync for current unspent outputs.
"""
cache_key = f"utxos_{address}"
cached = await get_cached(cache_key)
if cached:
return cached
# Query node directly using cardano-cli
success, stdout, stderr = run_cardano_cli([
"query", "utxo",
"--address", address,
"--mainnet",
"--output-json"
])
if not success:
if "Network.Socket.connect" in stderr or "does not exist" in stderr:
raise HTTPException(
status_code=503,
detail={"error": "node_unavailable", "message": "Cardano node not available"}
)
raise HTTPException(
status_code=500,
detail={"error": "node_error", "message": stderr}
)
try:
utxo_data = json.loads(stdout)
except json.JSONDecodeError:
raise HTTPException(
status_code=500,
detail={"error": "parse_error", "message": "Failed to parse node response"}
)
# Transform to our API format
utxos = []
total_lovelace = 0
for utxo_id, utxo_info in utxo_data.items():
tx_hash, tx_index = utxo_id.split("#")
lovelace = utxo_info.get("value", {}).get("lovelace", 0)
total_lovelace += lovelace
tokens = []
for policy_id, assets in utxo_info.get("value", {}).items():
if policy_id == "lovelace":
continue
if isinstance(assets, dict):
for asset_name, quantity in assets.items():
tokens.append({
"policy_id": policy_id,
"asset_name": asset_name,
"quantity": quantity
})
utxos.append({
"tx_hash": tx_hash,
"tx_index": int(tx_index),
"lovelace": lovelace,
"tokens": tokens
})
result = {
"address": address,
"utxo_count": len(utxos),
"total_lovelace": total_lovelace,
"total_ada": total_lovelace / 1_000_000,
"utxos": utxos
}
await set_cached(cache_key, result, CACHE_TTLS["utxos"])
return result
@app.post("/v1/tx/submit")
async def submit_transaction(
request: TxSubmitRequest,
auth: dict = Depends(get_auth_context)
):
"""
Submit a signed transaction to the network.
Accepts hex or base64 encoded CBOR transaction.
"""
# Check tx submission rate limit
allowed, retry_after = await check_rate_limit(auth["identifier"], auth["tier"], "tx_submit")
if not allowed:
if auth["tier"] == "anonymous":
raise HTTPException(
status_code=403,
detail={"error": "forbidden", "message": "Transaction submission requires an API key"}
)
raise HTTPException(
status_code=429,
detail={"error": "rate_limit_exceeded", "retry_after": retry_after}
)
# Decode the transaction
try:
tx_bytes = decode_hex_or_base64(request.tx)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={"error": "invalid_encoding", "message": str(e)}
)
# Write to temp file for cardano-cli
with tempfile.NamedTemporaryFile(suffix=".signed", delete=False) as f:
f.write(tx_bytes)
tx_file = f.name
try:
# Submit via cardano-cli
success, stdout, stderr = run_cardano_cli([
"transaction", "submit",
"--tx-file", tx_file,
"--mainnet"
], timeout=60)
if not success:
if "Network.Socket.connect" in stderr or "does not exist" in stderr:
raise HTTPException(
status_code=503,
detail={"error": "node_unavailable", "message": "Cardano node not available"}
)
# Parse common errors
if "OutsideValidityIntervalUTxO" in stderr:
raise HTTPException(
status_code=400,
detail={"error": "validity_interval", "message": "Transaction validity interval expired"}
)
if "BadInputsUTxO" in stderr:
raise HTTPException(
status_code=400,
detail={"error": "bad_inputs", "message": "One or more inputs already spent"}
)
if "ValueNotConservedUTxO" in stderr:
raise HTTPException(
status_code=400,
detail={"error": "value_not_conserved", "message": "Input/output value mismatch"}
)
raise HTTPException(
status_code=400,
detail={"error": "submit_failed", "message": stderr.strip()}
)
# Calculate tx hash from the CBOR
# The hash is blake2b-256 of the transaction body
tx_hash = hashlib.blake2b(tx_bytes, digest_size=32).hexdigest()
# Try to get actual hash from output
if "Transaction successfully submitted" in stdout or success:
# cardano-cli doesn't output the hash, but we calculated it
pass
logger.info(f"Transaction submitted: {tx_hash[:16]}... by {auth['identifier']}")
return TxSubmitResponse(
tx_hash=tx_hash,
message="Transaction successfully submitted"
)
finally:
# Clean up temp file
try:
os.unlink(tx_file)
except:
pass
@app.get("/v1/protocol-params")
async def get_protocol_params(auth: dict = Depends(get_auth_context)):
"""
Get current epoch protocol parameters from the node.
Cached for 5 minutes.
"""
global protocol_params_cache
# Check cache
if protocol_params_cache["data"] and protocol_params_cache["expires"] > time.time():
return protocol_params_cache["data"]
# Query from node
success, stdout, stderr = run_cardano_cli([
"query", "protocol-parameters",
"--mainnet"
])
if not success:
if "Network.Socket.connect" in stderr or "does not exist" in stderr:
raise HTTPException(
status_code=503,
detail={"error": "node_unavailable", "message": "Cardano node not available"}
)
raise HTTPException(
status_code=500,
detail={"error": "node_error", "message": stderr}
)
try:
params = json.loads(stdout)
except json.JSONDecodeError:
raise HTTPException(
status_code=500,
detail={"error": "parse_error", "message": "Failed to parse protocol parameters"}
)
# Cache for 5 minutes
protocol_params_cache = {
"data": params,
"expires": time.time() + CACHE_TTLS["protocol_params"]
}
return params
# ============ Auth Endpoints (TRP-Gated) ============
@app.post("/v1/auth/challenge")
async def create_auth_challenge(request: AuthChallengeRequest):
"""
Create a challenge nonce for wallet signature verification.
The nonce must be signed with CIP-8 and submitted to /v1/auth/verify.
"""
address = request.address
# Validate address format
if not address.startswith(("addr1", "addr_test1")):
raise HTTPException(
status_code=400,
detail={"error": "invalid_address", "message": "Invalid Cardano address format"}
)
# Generate nonce
nonce = secrets.token_hex(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=5)
# Store in Redis with TTL
await redis_client.setex(
f"auth_challenge:{address}:{nonce}",
300, # 5 minutes TTL
json.dumps({"address": address, "created_at": datetime.now(timezone.utc).isoformat()})
)
return AuthChallengeResponse(
nonce=nonce,
expires_at=expires_at.isoformat()
)
@app.post("/v1/auth/verify")
async def verify_auth(request: AuthVerifyRequest):
"""
Verify CIP-8 signature and issue API key based on TRP balance.
Tiers:
- 0 TRP: anonymous (no key issued)
- 50+ TRP: standard (100 req/min)
- 500+ TRP: elevated (1000 req/min)
"""
address = request.address
nonce = request.nonce
# Check nonce exists and hasn't expired
challenge_key = f"auth_challenge:{address}:{nonce}"
challenge_data = await redis_client.get(challenge_key)
if not challenge_data:
raise HTTPException(
status_code=400,
detail={"error": "invalid_nonce", "message": "Nonce expired or invalid"}
)
# Delete nonce (one-time use)
await redis_client.delete(challenge_key)
# Verify CIP-8 signature
if not verify_cip8_signature(address, nonce, request.signature, request.key):
raise HTTPException(
status_code=401,
detail={"error": "invalid_signature", "message": "Signature verification failed"}
)
# Get TRP balance
trp_balance = await get_trp_balance(address)
tier = get_tier_from_trp_balance(trp_balance)
# Don't issue key for anonymous tier
if tier == "anonymous":
raise HTTPException(
status_code=403,
detail={
"error": "insufficient_trp",
"message": f"Minimum {TRP_TIERS['standard']} TRP required for API key",
"trp_balance": trp_balance,
"required": TRP_TIERS["standard"]
}
)
# Generate API key
new_key = f"capi_{secrets.token_hex(24)}"
# Store key info
await redis_client.hset(f"apikey:{new_key}", mapping={
"label": f"TRP-gated:{address[:20]}...",
"tier": tier,
"owner": address,
"trp_balance": str(trp_balance),
"created_at": datetime.now(timezone.utc).isoformat()
})
logger.info(f"Issued {tier} API key for {address[:20]}... (TRP: {trp_balance})")
return AuthVerifyResponse(
api_key=new_key,
tier=tier,
trp_balance=trp_balance
)
@app.post("/v1/auth/refresh")
async def refresh_auth(
x_api_key: str = Header(..., description="API key to refresh"),
auth: dict = Depends(get_auth_context)
):
"""
Re-check TRP balance and upgrade/downgrade tier for an existing key.
"""
if auth["tier"] == "anonymous":
raise HTTPException(
status_code=401,
detail={"error": "unauthorized", "message": "Valid API key required"}
)
if auth["tier"] == "master":
raise HTTPException(
status_code=400,
detail={"error": "invalid_request", "message": "Cannot refresh master key"}
)
owner = auth.get("owner")
if not owner:
raise HTTPException(
status_code=400,
detail={"error": "not_trp_gated", "message": "This key is not TRP-gated"}
)
# Get current TRP balance
trp_balance = await get_trp_balance(owner)
new_tier = get_tier_from_trp_balance(trp_balance)
old_tier = auth["tier"]
# Update key info
await redis_client.hset(f"apikey:{x_api_key}", mapping={
"tier": new_tier,
"trp_balance": str(trp_balance)
})
tier_changed = new_tier != old_tier
message = f"Tier {'upgraded' if new_tier > old_tier else 'downgraded'} from {old_tier} to {new_tier}" if tier_changed else "Tier unchanged"
return {
"api_key": x_api_key,
"tier": new_tier,
"previous_tier": old_tier,
"trp_balance": trp_balance,
"tier_changed": tier_changed,
"message": message
}
# ============ Block Endpoints ============ # ============ Block Endpoints ============
@app.get("/v1/block/latest") @app.get("/v1/block/latest")
@ -853,6 +1538,7 @@ async def list_api_keys(auth: dict = Depends(require_master_key)):
"label": data.get("label"), "label": data.get("label"),
"tier": data.get("tier"), "tier": data.get("tier"),
"owner": data.get("owner") or None, "owner": data.get("owner") or None,
"trp_balance": int(data.get("trp_balance", 0)),
"created_at": data.get("created_at") "created_at": data.get("created_at")
}) })
if cursor == 0: if cursor == 0:
@ -860,6 +1546,13 @@ async def list_api_keys(auth: dict = Depends(require_master_key)):
return {"keys": keys, "count": len(keys)} return {"keys": keys, "count": len(keys)}
@app.post("/admin/refresh-tiers")
async def admin_refresh_tiers(auth: dict = Depends(require_master_key)):
"""Manually trigger TRP tier refresh for all keys."""
await refresh_all_trp_tiers()
return {"status": "ok", "message": "TRP tier refresh completed"}
@app.get("/admin/stats") @app.get("/admin/stats")
async def get_admin_stats(auth: dict = Depends(require_master_key)): async def get_admin_stats(auth: dict = Depends(require_master_key)):
"""Get API usage statistics.""" """Get API usage statistics."""
@ -868,17 +1561,21 @@ async def get_admin_stats(auth: dict = Depends(require_master_key)):
active_users = 0 active_users = 0
cursor = 0 cursor = 0
while True: while True:
cursor, keys = await redis_client.scan(cursor, match=f"ratelimit:*:{current_minute}", count=100) cursor, keys = await redis_client.scan(cursor, match=f"ratelimit:general:*:{current_minute}", count=100)
active_users += len(keys) active_users += len(keys)
if cursor == 0: if cursor == 0:
break break
# Count API keys # Count API keys by tier
api_key_count = 0 api_key_counts = {"standard": 0, "elevated": 0, "total": 0}
cursor = 0 cursor = 0
while True: while True:
cursor, keys = await redis_client.scan(cursor, match="apikey:*", count=100) cursor, keys = await redis_client.scan(cursor, match="apikey:*", count=100)
api_key_count += len(keys) for key in keys:
tier = await redis_client.hget(key, "tier")
api_key_counts["total"] += 1
if tier in api_key_counts:
api_key_counts[tier] += 1
if cursor == 0: if cursor == 0:
break break
@ -887,12 +1584,16 @@ async def get_admin_stats(auth: dict = Depends(require_master_key)):
latest_block = await conn.fetchrow("SELECT block_no, time FROM block ORDER BY id DESC LIMIT 1") latest_block = await conn.fetchrow("SELECT block_no, time FROM block ORDER BY id DESC LIMIT 1")
tx_count = await conn.fetchrow("SELECT COUNT(*) as count FROM tx") tx_count = await conn.fetchrow("SELECT COUNT(*) as count FROM tx")
# Check node status
node_connected = os.path.exists(CARDANO_NODE_SOCKET_PATH)
return { return {
"active_users_this_minute": active_users, "active_users_this_minute": active_users,
"total_api_keys": api_key_count, "api_keys": api_key_counts,
"db_sync_block": latest_block["block_no"] if latest_block else 0, "db_sync_block": latest_block["block_no"] if latest_block else 0,
"db_sync_time": latest_block["time"].isoformat() if latest_block and latest_block["time"] else None, "db_sync_time": latest_block["time"].isoformat() if latest_block and latest_block["time"] else None,
"total_transactions_synced": tx_count["count"] if tx_count else 0 "total_transactions_synced": tx_count["count"] if tx_count else 0,
"node_socket_exists": node_connected
} }

View file

@ -4,3 +4,6 @@ asyncpg==0.30.0
redis==5.0.0 redis==5.0.0
pydantic==2.9.0 pydantic==2.9.0
python-dotenv==1.0.0 python-dotenv==1.0.0
pycardano==0.11.0
cbor2==5.6.0
PyNaCl==1.5.0