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:
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user