feat: add multi-chain support to blockchain explorer and improve GPU review handling

- Add multi-chain configuration with devnet, testnet, and mainnet RPC URLs
- Add chain selector dropdown in explorer UI for network switching
- Add chain_id parameter to all API endpoints (chain/head, blocks, transactions, search)
- Add /api/chains endpoint to list supported blockchain networks
- Update blockchain explorer port from 3001 to 8016
- Update devnet RPC port from 8080 to 8026
- Add GPU reviews table
This commit is contained in:
oib
2026-03-07 18:44:15 +01:00
parent 89e161c906
commit 7341808f01
14 changed files with 634 additions and 98 deletions

View File

@@ -19,8 +19,13 @@ import uvicorn
app = FastAPI(title="AITBC Blockchain Explorer", version="2.0.0")
# Configuration
BLOCKCHAIN_RPC_URL = "http://localhost:8082" # Local blockchain node
# Configuration - Multi-chain support
BLOCKCHAIN_RPC_URLS = {
"ait-devnet": "http://localhost:8025",
"ait-testnet": "http://localhost:8026",
"ait-mainnet": "http://aitbc.keisanki.net:8082"
}
DEFAULT_CHAIN = "ait-devnet"
EXTERNAL_RPC_URL = "http://aitbc.keisanki.net:8082" # External access
# Pydantic models for API
@@ -72,7 +77,11 @@ HTML_TEMPLATE = r"""
<h1 class="text-2xl font-bold">AITBC Blockchain Explorer</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm">Network: <span class="font-mono bg-blue-700 px-2 py-1 rounded">ait-devnet</span></span>
<select id="chain-selector" class="bg-blue-700 px-3 py-1 rounded text-sm font-mono" onchange="switchChain()">
<option value="ait-devnet" selected>ait-devnet</option>
<option value="ait-testnet">ait-testnet</option>
<option value="ait-mainnet">ait-mainnet</option>
</select>
<button onclick="refreshData()" class="bg-blue-500 hover:bg-blue-400 px-3 py-1 rounded flex items-center space-x-1">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
<span>Refresh</span>
@@ -404,13 +413,17 @@ HTML_TEMPLATE = r"""
// Load chain statistics
async function loadChainStats() {
const response = await fetch('/api/chain/head');
const response = await fetch(`/api/chain/head?chain_id=${currentChain}`);
const data = await response.json();
document.getElementById('chain-height').textContent = data.height || '-';
document.getElementById('latest-hash').textContent = data.hash ? data.hash.substring(0, 16) + '...' : '-';
document.getElementById('node-status').innerHTML = '<span class="text-green-500">Online</span>';
// Update network display
const selector = document.getElementById('chain-selector');
selector.value = currentChain;
currentData.head = data;
}
@@ -895,80 +908,85 @@ HTML_TEMPLATE = r"""
"""
async def get_transaction(tx_hash: str) -> Dict[str, Any]:
"""Get transaction by hash"""
async def get_chain_head(chain_id: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""Get chain head from specified chain"""
try:
rpc_url = BLOCKCHAIN_RPC_URLS.get(chain_id, BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/tx/{tx_hash}")
response = await client.get(f"{rpc_url}/rpc/head", params={"chain_id": chain_id})
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"Error getting transaction: {e}")
print(f"Error getting chain head for {chain_id}: {e}")
return {}
async def get_block(height: int) -> Dict[str, Any]:
"""Get a specific block by height"""
async def get_transaction(tx_hash: str, chain_id: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""Get transaction by hash from specified chain"""
try:
rpc_url = BLOCKCHAIN_RPC_URLS.get(chain_id, BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/blocks/{height}")
response = await client.get(f"{rpc_url}/rpc/tx/{tx_hash}", params={"chain_id": chain_id})
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"Error getting block {height}: {e}")
print(f"Error getting transaction {tx_hash} for {chain_id}: {e}")
return {}
async def get_block(height: int, chain_id: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""Get a specific block by height from specified chain"""
try:
rpc_url = BLOCKCHAIN_RPC_URLS.get(chain_id, BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
async with httpx.AsyncClient() as client:
response = await client.get(f"{rpc_url}/rpc/blocks/{height}", params={"chain_id": chain_id})
if response.status_code == 200:
return response.json()
except Exception as e:
print(f"Error getting block {height} for {chain_id}: {e}")
return {}
@app.get("/", response_class=HTMLResponse)
async def root():
"""Serve the explorer UI"""
return HTML_TEMPLATE.replace("{node_url}", BLOCKCHAIN_RPC_URL)
return HTML_TEMPLATE.replace("{node_url}", BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
@app.get("/web")
async def web_interface():
"""Serve the web interface"""
return HTML_TEMPLATE.replace("{node_url}", BLOCKCHAIN_RPC_URL)
return HTML_TEMPLATE.replace("{node_url}", BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
@app.get("/api/chain/head")
async def api_chain_head():
async def api_chain_head(chain_id: Optional[str] = DEFAULT_CHAIN):
"""API endpoint for chain head"""
return await get_chain_head()
return await get_chain_head(chain_id)
@app.get("/api/blocks/{height}")
async def api_block(height: int):
async def api_block(height: int, chain_id: Optional[str] = DEFAULT_CHAIN):
"""API endpoint for block data"""
return await get_block(height)
return await get_block(height, chain_id)
@app.get("/api/transactions/{tx_hash}")
async def api_transaction(tx_hash: str):
async def api_transaction(tx_hash: str, chain_id: Optional[str] = DEFAULT_CHAIN):
"""API endpoint for transaction data, normalized for frontend"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/tx/{tx_hash}")
if response.status_code == 200:
tx = response.json()
# Normalize for frontend expectations
payload = tx.get("payload", {})
return {
"hash": tx.get("tx_hash"),
"block_height": tx.get("block_height"),
"from": tx.get("sender"),
"to": tx.get("recipient"),
"type": payload.get("type", "transfer"),
"amount": payload.get("amount", 0),
"fee": payload.get("fee", 0),
"timestamp": tx.get("created_at")
}
elif response.status_code == 404:
raise HTTPException(status_code=404, detail="Transaction not found")
except httpx.RequestError as e:
print(f"Error fetching transaction: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
tx = await get_transaction(tx_hash, chain_id)
payload = tx.get("payload", {})
return {
"hash": tx.get("tx_hash"),
"block_height": tx.get("block_height"),
"from": tx.get("sender"),
"to": tx.get("recipient"),
"type": payload.get("type", "transfer"),
"amount": payload.get("amount", 0),
"fee": payload.get("fee", 0),
"timestamp": tx.get("created_at")
}
# Enhanced API endpoints
@@ -981,7 +999,8 @@ async def search_transactions(
since: Optional[str] = None,
until: Optional[str] = None,
limit: int = 50,
offset: int = 0
offset: int = 0,
chain_id: Optional[str] = DEFAULT_CHAIN
):
"""Advanced transaction search"""
try:
@@ -1001,9 +1020,11 @@ async def search_transactions(
params["until"] = until
params["limit"] = limit
params["offset"] = offset
params["chain_id"] = chain_id
rpc_url = BLOCKCHAIN_RPC_URLS.get(chain_id, BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/search/transactions", params=params)
response = await client.get(f"{rpc_url}/rpc/search/transactions", params=params)
if response.status_code == 200:
return response.json()
else:
@@ -1029,7 +1050,8 @@ async def search_blocks(
until: Optional[str] = None,
min_tx: Optional[int] = None,
limit: int = 50,
offset: int = 0
offset: int = 0,
chain_id: Optional[str] = DEFAULT_CHAIN
):
"""Advanced block search"""
try:
@@ -1045,9 +1067,11 @@ async def search_blocks(
params["min_tx"] = min_tx
params["limit"] = limit
params["offset"] = offset
params["chain_id"] = chain_id
rpc_url = BLOCKCHAIN_RPC_URLS.get(chain_id, BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/search/blocks", params=params)
response = await client.get(f"{rpc_url}/rpc/search/blocks", params=params)
if response.status_code == 200:
return response.json()
else:
@@ -1206,11 +1230,12 @@ async def export_blocks(format: str = "csv"):
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
# Helper functions
async def get_latest_blocks(limit: int = 10) -> List[Dict]:
async def get_latest_blocks(limit: int = 10, chain_id: str = DEFAULT_CHAIN) -> List[Dict]:
"""Get latest blocks"""
try:
rpc_url = BLOCKCHAIN_RPC_URLS.get(chain_id, BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN])
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/blocks?limit={limit}")
response = await client.get(f"{rpc_url}/rpc/blocks?limit={limit}", params={"chain_id": chain_id})
if response.status_code == 200:
return response.json()
else:
@@ -1228,13 +1253,25 @@ async def get_latest_blocks(limit: int = 10) -> List[Dict]:
except Exception:
return []
@app.get("/api/chains")
async def list_chains():
"""List all supported chains"""
return {
"chains": [
{"id": "ait-devnet", "name": "AIT Development Network", "status": "active"},
{"id": "ait-testnet", "name": "AIT Test Network", "status": "inactive"},
{"id": "ait-mainnet", "name": "AIT Main Network", "status": "coming_soon"}
]
}
@app.get("/health")
async def health():
"""Health check endpoint"""
try:
# Test blockchain node connectivity
async with httpx.AsyncClient() as client:
response = await client.get(f"{BLOCKCHAIN_RPC_URL}/rpc/head", timeout=5.0)
response = await client.get(f"{BLOCKCHAIN_RPC_URLS[DEFAULT_CHAIN]}/rpc/head", timeout=5.0)
node_status = "ok" if response.status_code == 200 else "error"
except Exception:
node_status = "error"
@@ -1247,4 +1284,4 @@ async def health():
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=3001)
uvicorn.run(app, host="0.0.0.0", port=8016)

View File

@@ -19,5 +19,5 @@
"fee_per_byte": 1,
"mint_per_unit": 1000
},
"timestamp": 1768834652
"timestamp": 1772895053
}

View File

@@ -25,9 +25,9 @@ echo "[devnet] Blockchain node started (PID ${CHILD_PIDS[-1]})"
sleep 1
python -m uvicorn aitbc_chain.app:app --host 127.0.0.1 --port 8080 --log-level info &
python -m uvicorn aitbc_chain.app:app --host 127.0.0.1 --port 8026 --log-level info &
CHILD_PIDS+=($!)
echo "[devnet] RPC API serving at http://127.0.0.1:8080"
echo "[devnet] RPC API serving at http://127.0.0.1:8026"
python -m uvicorn mock_coordinator:app --host 127.0.0.1 --port 8090 --log-level info &
CHILD_PIDS+=($!)

View File

@@ -116,6 +116,19 @@ CREATE TABLE IF NOT EXISTS job_history (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- GPU reviews table
CREATE TABLE IF NOT EXISTS gpu_reviews (
id VARCHAR(255) PRIMARY KEY,
gpu_id VARCHAR(255) NOT NULL,
user_id VARCHAR(255) DEFAULT '',
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
comment TEXT DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_gpu_reviews_gpu_id ON gpu_reviews(gpu_id);
CREATE INDEX IF NOT EXISTS idx_gpu_reviews_created_at ON gpu_reviews(created_at);
-- Comments for documentation
COMMENT ON TABLE jobs IS 'AI compute jobs submitted to the network';
COMMENT ON TABLE miners IS 'Registered GPU miners';
@@ -124,3 +137,4 @@ COMMENT ON TABLE blocks IS 'Blockchain blocks for transaction ordering';
COMMENT ON TABLE transactions IS 'On-chain transactions';
COMMENT ON TABLE api_keys IS 'API authentication keys';
COMMENT ON TABLE job_history IS 'Job event history for analytics';
COMMENT ON TABLE gpu_reviews IS 'User reviews for GPU marketplace';

View File

@@ -0,0 +1,2 @@
# Import the FastAPI app from main.py for compatibility
from main import app

View File

@@ -0,0 +1,5 @@
# Import the FastAPI app from main.py for uvicorn compatibility
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from main import app

View File

@@ -8,7 +8,7 @@ engine = create_engine(
"sqlite:////home/oib/windsurf/aitbc/apps/coordinator-api/aitbc_coordinator.db",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
echo=False
echo=True # Enable SQL logging for debugging
)

View File

@@ -498,36 +498,94 @@ async def add_gpu_review(
gpu_id: str, request: GPUReviewRequest, session: Annotated[Session, Depends(get_session)]
) -> Dict[str, Any]:
"""Add a review for a GPU."""
gpu = _get_gpu_or_404(session, gpu_id)
try:
gpu = _get_gpu_or_404(session, gpu_id)
# Validate request data
if not (1 <= request.rating <= 5):
raise HTTPException(
status_code=http_status.HTTP_400_BAD_REQUEST,
detail="Rating must be between 1 and 5"
)
# Create review object
review = GPUReview(
gpu_id=gpu_id,
user_id="current_user",
rating=request.rating,
comment=request.comment,
)
# Log transaction start
logger.info(f"Starting review transaction for GPU {gpu_id}", extra={
"gpu_id": gpu_id,
"rating": request.rating,
"user_id": "current_user"
})
# Add review to session
session.add(review)
session.flush() # ensure the new review is visible to aggregate queries
# Recalculate average from DB (new review already included after flush)
total_count_result = session.execute(
select(func.count(GPUReview.id)).where(GPUReview.gpu_id == gpu_id)
).one()
total_count = total_count_result[0] if hasattr(total_count_result, '__getitem__') else total_count_result
avg_rating_result = session.execute(
select(func.avg(GPUReview.rating)).where(GPUReview.gpu_id == gpu_id)
).one()
avg_rating = avg_rating_result[0] if hasattr(avg_rating_result, '__getitem__') else avg_rating_result
avg_rating = avg_rating or 0.0
review = GPUReview(
gpu_id=gpu_id,
user_id="current_user",
rating=request.rating,
comment=request.comment,
)
session.add(review)
session.flush() # ensure the new review is visible to aggregate queries
# Recalculate average from DB (new review already included after flush)
total_count = session.execute(
select(func.count(GPUReview.id)).where(GPUReview.gpu_id == gpu_id)
).one()
avg_rating = session.execute(
select(func.avg(GPUReview.rating)).where(GPUReview.gpu_id == gpu_id)
).one() or 0.0
gpu.average_rating = round(float(avg_rating), 2)
gpu.total_reviews = total_count
session.commit()
session.refresh(review)
return {
"status": "review_added",
"gpu_id": gpu_id,
"review_id": review.id,
"average_rating": gpu.average_rating,
}
# Update GPU stats
gpu.average_rating = round(float(avg_rating), 2)
gpu.total_reviews = total_count
# Commit transaction
session.commit()
# Refresh review object
session.refresh(review)
# Log success
logger.info(f"Review transaction completed successfully for GPU {gpu_id}", extra={
"gpu_id": gpu_id,
"review_id": review.id,
"total_reviews": total_count,
"average_rating": gpu.average_rating
})
return {
"status": "review_added",
"gpu_id": gpu_id,
"review_id": review.id,
"average_rating": gpu.average_rating,
}
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
# Log error and rollback transaction
logger.error(f"Failed to add review for GPU {gpu_id}: {str(e)}", extra={
"gpu_id": gpu_id,
"error": str(e),
"error_type": type(e).__name__
})
# Rollback on error
try:
session.rollback()
except Exception as rollback_error:
logger.error(f"Failed to rollback transaction: {str(rollback_error)}")
# Return generic error
raise HTTPException(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add review"
)
@router.get("/marketplace/orders")