feat: add blockchain RPC blocks-range endpoint and marketplace bid listing

Blockchain Node:
- Replace /blocks (pagination) with /blocks-range (height range query)
- Add start/end height parameters with 1000-block max range validation
- Return blocks in ascending height order instead of descending
- Update metrics names (rpc_get_blocks_range_*)
- Remove total count from response (return start/end/count instead)

Coordinator API:
- Add effective_url property to DatabaseConfig (SQLite/PostgreSQL defaults
This commit is contained in:
oib
2026-02-16 22:54:08 +01:00
parent fdc3012780
commit 31d3d70836
20 changed files with 3330 additions and 80 deletions

View File

@@ -18,6 +18,19 @@ class DatabaseConfig(BaseSettings):
max_overflow: int = 20
pool_pre_ping: bool = True
@property
def effective_url(self) -> str:
"""Get the effective database URL."""
if self.url:
return self.url
# Default SQLite path
if self.adapter == "sqlite":
return "sqlite:///./coordinator.db"
# Default PostgreSQL connection string
return f"{self.adapter}://localhost:5432/coordinator"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",

View File

@@ -12,7 +12,15 @@ import logging
logger = logging.getLogger(__name__)
from ..schemas import ExchangePaymentRequest, ExchangePaymentResponse
from ..schemas import (
ExchangePaymentRequest,
ExchangePaymentResponse,
ExchangeRatesResponse,
PaymentStatusResponse,
MarketStatsResponse,
WalletBalanceResponse,
WalletInfoResponse
)
from ..services.bitcoin_wallet import get_wallet_balance, get_wallet_info
router = APIRouter(tags=["exchange"])
@@ -70,7 +78,8 @@ async def create_payment(
return payment
@router.get("/exchange/payment-status/{payment_id}")
@router.get("/exchange/payment-status/{payment_id}", response_model=PaymentStatusResponse)
async def get_payment_status(payment_id: str) -> Dict[str, Any]:
"""Get payment status"""
@@ -85,6 +94,7 @@ async def get_payment_status(payment_id: str) -> Dict[str, Any]:
return payment
@router.post("/exchange/confirm-payment/{payment_id}")
async def confirm_payment(
payment_id: str,
@@ -121,18 +131,20 @@ async def confirm_payment(
'aitbc_amount': payment['aitbc_amount']
}
@router.get("/exchange/rates")
async def get_exchange_rates() -> Dict[str, float]:
@router.get("/exchange/rates", response_model=ExchangeRatesResponse)
async def get_exchange_rates() -> ExchangeRatesResponse:
"""Get current exchange rates"""
return {
'btc_to_aitbc': BITCOIN_CONFIG['exchange_rate'],
'aitbc_to_btc': 1.0 / BITCOIN_CONFIG['exchange_rate'],
'fee_percent': 0.5
}
return ExchangeRatesResponse(
btc_to_aitbc=BITCOIN_CONFIG['exchange_rate'],
aitbc_to_btc=1.0 / BITCOIN_CONFIG['exchange_rate'],
fee_percent=0.5
)
@router.get("/exchange/market-stats")
async def get_market_stats() -> Dict[str, Any]:
@router.get("/exchange/market-stats", response_model=MarketStatsResponse)
async def get_market_stats() -> MarketStatsResponse:
"""Get market statistics"""
# Calculate 24h volume from payments
@@ -148,28 +160,32 @@ async def get_market_stats() -> Dict[str, Any]:
base_price = 1.0 / BITCOIN_CONFIG['exchange_rate']
price_change_percent = 5.2 # Simulated +5.2%
return {
'price': base_price,
'price_change_24h': price_change_percent,
'daily_volume': daily_volume,
'daily_volume_btc': daily_volume / BITCOIN_CONFIG['exchange_rate'],
'total_payments': len([p for p in payments.values() if p['status'] == 'confirmed']),
'pending_payments': len([p for p in payments.values() if p['status'] == 'pending'])
}
return MarketStatsResponse(
price=base_price,
price_change_24h=price_change_percent,
daily_volume=daily_volume,
daily_volume_btc=daily_volume / BITCOIN_CONFIG['exchange_rate'],
total_payments=len([p for p in payments.values() if p['status'] == 'confirmed']),
pending_payments=len([p for p in payments.values() if p['status'] == 'pending'])
)
@router.get("/exchange/wallet/balance")
async def get_wallet_balance_api() -> Dict[str, Any]:
@router.get("/exchange/wallet/balance", response_model=WalletBalanceResponse)
async def get_wallet_balance_api() -> WalletBalanceResponse:
"""Get Bitcoin wallet balance"""
try:
return get_wallet_balance()
balance_data = get_wallet_balance()
return WalletBalanceResponse(**balance_data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/exchange/wallet/info")
async def get_wallet_info_api() -> Dict[str, Any]:
@router.get("/exchange/wallet/info", response_model=WalletInfoResponse)
async def get_wallet_info_api() -> WalletInfoResponse:
"""Get comprehensive wallet information"""
try:
return get_wallet_info()
wallet_data = get_wallet_info()
return WalletInfoResponse(**wallet_data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import status as http_status
from ..schemas import MarketplaceBidRequest, MarketplaceOfferView, MarketplaceStatsView
from ..schemas import MarketplaceBidRequest, MarketplaceOfferView, MarketplaceStatsView, MarketplaceBidView
from ..services import MarketplaceService
from ..storage import SessionDep
from ..metrics import marketplace_requests_total, marketplace_errors_total
@@ -74,3 +74,51 @@ async def submit_marketplace_bid(
except Exception:
marketplace_errors_total.labels(endpoint="/marketplace/bids", method="POST", error_type="internal").inc()
raise
@router.get(
"/marketplace/bids",
response_model=list[MarketplaceBidView],
summary="List marketplace bids",
)
async def list_marketplace_bids(
*,
session: SessionDep,
status_filter: str | None = Query(default=None, alias="status", description="Filter by bid status"),
provider_filter: str | None = Query(default=None, alias="provider", description="Filter by provider ID"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[MarketplaceBidView]:
marketplace_requests_total.labels(endpoint="/marketplace/bids", method="GET").inc()
service = _get_service(session)
try:
return service.list_bids(status=status_filter, provider=provider_filter, limit=limit, offset=offset)
except ValueError:
marketplace_errors_total.labels(endpoint="/marketplace/bids", method="GET", error_type="invalid_request").inc()
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail="invalid filter") from None
except Exception:
marketplace_errors_total.labels(endpoint="/marketplace/bids", method="GET", error_type="internal").inc()
raise
@router.get(
"/marketplace/bids/{bid_id}",
response_model=MarketplaceBidView,
summary="Get bid details",
)
async def get_marketplace_bid(
bid_id: str,
session: SessionDep,
) -> MarketplaceBidView:
marketplace_requests_total.labels(endpoint="/marketplace/bids/{bid_id}", method="GET").inc()
service = _get_service(session)
try:
bid = service.get_bid(bid_id)
if not bid:
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="bid not found")
return bid
except HTTPException:
raise
except Exception:
marketplace_errors_total.labels(endpoint="/marketplace/bids/{bid_id}", method="GET", error_type="internal").inc()
raise

View File

@@ -14,6 +14,8 @@ class WebVitalsEntry(BaseModel):
name: str
startTime: Optional[float] = None
duration: Optional[float] = None
value: Optional[float] = None
hadRecentInput: Optional[bool] = None
class WebVitalsMetric(BaseModel):
name: str
@@ -31,6 +33,20 @@ async def collect_web_vitals(metric: WebVitalsMetric):
This endpoint receives Core Web Vitals (LCP, FID, CLS, TTFB, FCP) for monitoring.
"""
try:
# Filter entries to only include supported fields
filtered_entries = []
for entry in metric.entries:
filtered_entry = {
"name": entry.name,
"startTime": entry.startTime,
"duration": entry.duration,
"value": entry.value,
"hadRecentInput": entry.hadRecentInput
}
# Remove None values
filtered_entry = {k: v for k, v in filtered_entry.items() if v is not None}
filtered_entries.append(filtered_entry)
# Log the metric for monitoring/analysis
logging.info(f"Web Vitals - {metric.name}: {metric.value}ms (ID: {metric.id}) from {metric.url or 'unknown'}")

View File

@@ -109,6 +109,7 @@ class TransactionHistory(BaseModel):
user_id: str
transactions: List[Transaction]
total: int
class ExchangePaymentRequest(BaseModel):
user_id: str
aitbc_amount: float
@@ -124,6 +125,48 @@ class ExchangePaymentResponse(BaseModel):
created_at: int
expires_at: int
class ExchangeRatesResponse(BaseModel):
btc_to_aitbc: float
aitbc_to_btc: float
fee_percent: float
class PaymentStatusResponse(BaseModel):
payment_id: str
user_id: str
aitbc_amount: float
btc_amount: float
payment_address: str
status: str
created_at: int
expires_at: int
confirmations: int = 0
tx_hash: Optional[str] = None
confirmed_at: Optional[int] = None
class MarketStatsResponse(BaseModel):
price: float
price_change_24h: float
daily_volume: float
daily_volume_btc: float
total_payments: int
pending_payments: int
class WalletBalanceResponse(BaseModel):
address: str
balance: float
unconfirmed_balance: float
total_received: float
total_sent: float
class WalletInfoResponse(BaseModel):
address: str
balance: float
unconfirmed_balance: float
total_received: float
total_sent: float
transactions: list
network: str
block_height: int
class JobCreate(BaseModel):
payload: Dict[str, Any]
@@ -213,6 +256,16 @@ class MarketplaceBidRequest(BaseModel):
notes: Optional[str] = Field(default=None, max_length=1024)
class MarketplaceBidView(BaseModel):
id: str
provider: str
capacity: int
price: float
notes: Optional[str] = None
status: str
submitted_at: datetime
class BlockSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import httpx
from collections import defaultdict, deque
from datetime import datetime
from typing import Optional
@@ -38,25 +39,51 @@ class ExplorerService:
self.session = session
def list_blocks(self, *, limit: int = 20, offset: int = 0) -> BlockListResponse:
statement = select(Job).order_by(Job.requested_at.desc())
jobs = self.session.exec(statement.offset(offset).limit(limit)).all()
# Fetch real blockchain data from RPC API
try:
# Use the blockchain RPC API running on localhost:8082
with httpx.Client(timeout=10.0) as client:
response = client.get("http://localhost:8082/rpc/blocks", params={"limit": limit, "offset": offset})
response.raise_for_status()
rpc_data = response.json()
items: list[BlockSummary] = []
for block in rpc_data.get("blocks", []):
items.append(
BlockSummary(
height=block["height"],
hash=block["hash"],
timestamp=datetime.fromisoformat(block["timestamp"]),
txCount=block["tx_count"],
proposer=block["proposer"],
)
)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return BlockListResponse(items=items, next_offset=next_offset)
except Exception as e:
# Fallback to fake data if RPC is unavailable
print(f"Warning: Failed to fetch blocks from RPC: {e}, falling back to fake data")
statement = select(Job).order_by(Job.requested_at.desc())
jobs = self.session.exec(statement.offset(offset).limit(limit)).all()
items: list[BlockSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
proposer = job.assigned_miner_id or "unassigned"
items.append(
BlockSummary(
height=height,
hash=job.id,
timestamp=job.requested_at,
txCount=1,
proposer=proposer,
items: list[BlockSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
proposer = job.assigned_miner_id or "unassigned"
items.append(
BlockSummary(
height=height,
hash=job.id,
timestamp=job.requested_at,
txCount=1,
proposer=proposer,
)
)
)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return BlockListResponse(items=items, next_offset=next_offset)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return BlockListResponse(items=items, next_offset=next_offset)
def list_transactions(self, *, limit: int = 50, offset: int = 0) -> TransactionListResponse:
statement = (

View File

@@ -10,6 +10,7 @@ from ..schemas import (
MarketplaceBidRequest,
MarketplaceOfferView,
MarketplaceStatsView,
MarketplaceBidView,
)
@@ -70,6 +71,47 @@ class MarketplaceService:
self.session.refresh(bid)
return bid
def list_bids(
self,
*,
status: Optional[str] = None,
provider: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> list[MarketplaceBidView]:
stmt = select(MarketplaceBid).order_by(MarketplaceBid.submitted_at.desc())
if status is not None:
normalised = status.strip().lower()
if normalised not in ("pending", "accepted", "rejected"):
raise ValueError(f"invalid status: {status}")
stmt = stmt.where(MarketplaceBid.status == normalised)
if provider is not None:
stmt = stmt.where(MarketplaceBid.provider == provider)
stmt = stmt.offset(offset).limit(limit)
bids = self.session.exec(stmt).all()
return [self._to_bid_view(bid) for bid in bids]
def get_bid(self, bid_id: str) -> Optional[MarketplaceBidView]:
bid = self.session.get(MarketplaceBid, bid_id)
if bid:
return self._to_bid_view(bid)
return None
@staticmethod
def _to_bid_view(bid: MarketplaceBid) -> MarketplaceBidView:
return MarketplaceBidView(
id=bid.id,
provider=bid.provider,
capacity=bid.capacity,
price=bid.price,
notes=bid.notes,
status=bid.status,
submitted_at=bid.submitted_at,
)
@staticmethod
def _to_offer_view(offer: MarketplaceOffer) -> MarketplaceOfferView:
status_val = offer.status.value if hasattr(offer.status, "value") else offer.status