diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/router.py b/apps/blockchain-node/src/aitbc_chain/rpc/router.py index 03485945..37da77d4 100644 --- a/apps/blockchain-node/src/aitbc_chain/rpc/router.py +++ b/apps/blockchain-node/src/aitbc_chain/rpc/router.py @@ -105,36 +105,35 @@ async def get_block(height: int) -> Dict[str, Any]: } -@router.get("/blocks", summary="Get latest blocks") -async def get_blocks(limit: int = 10, offset: int = 0) -> Dict[str, Any]: - metrics_registry.increment("rpc_get_blocks_total") - start = time.perf_counter() +@router.get("/blocks-range", summary="Get blocks in height range") +async def get_blocks_range(start: int, end: int) -> Dict[str, Any]: + metrics_registry.increment("rpc_get_blocks_range_total") + start_time = time.perf_counter() # Validate parameters - if limit < 1 or limit > 100: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit must be between 1 and 100") - if offset < 0: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="offset must be non-negative") + if start < 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start must be non-negative") + if end < start: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="end must be greater than or equal to start") + if end - start > 1000: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="range cannot exceed 1000 blocks") with session_scope() as session: - # Get blocks in descending order (newest first) + # Get blocks in the specified height range (ascending order by height) blocks = session.exec( select(Block) - .order_by(Block.height.desc()) - .offset(offset) - .limit(limit) + .where(Block.height >= start) + .where(Block.height <= end) + .order_by(Block.height.asc()) ).all() - # Get total count for pagination info - total_count = len(session.exec(select(Block)).all()) - if not blocks: - metrics_registry.increment("rpc_get_blocks_empty_total") + metrics_registry.increment("rpc_get_blocks_range_empty_total") return { "blocks": [], - "total": total_count, - "limit": limit, - "offset": offset, + "start": start, + "end": end, + "count": 0, } # Serialize blocks @@ -149,14 +148,14 @@ async def get_blocks(limit: int = 10, offset: int = 0) -> Dict[str, Any]: "state_root": block.state_root, }) - metrics_registry.increment("rpc_get_blocks_success_total") - metrics_registry.observe("rpc_get_blocks_duration_seconds", time.perf_counter() - start) + metrics_registry.increment("rpc_get_blocks_range_success_total") + metrics_registry.observe("rpc_get_blocks_range_duration_seconds", time.perf_counter() - start_time) return { "blocks": block_list, - "total": total_count, - "limit": limit, - "offset": offset, + "start": start, + "end": end, + "count": len(block_list), } diff --git a/apps/coordinator-api/src/app/config.py b/apps/coordinator-api/src/app/config.py index b39f7044..c674a041 100644 --- a/apps/coordinator-api/src/app/config.py +++ b/apps/coordinator-api/src/app/config.py @@ -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", diff --git a/apps/coordinator-api/src/app/routers/exchange.py b/apps/coordinator-api/src/app/routers/exchange.py index 43740a28..10b1c30e 100644 --- a/apps/coordinator-api/src/app/routers/exchange.py +++ b/apps/coordinator-api/src/app/routers/exchange.py @@ -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)) diff --git a/apps/coordinator-api/src/app/routers/marketplace.py b/apps/coordinator-api/src/app/routers/marketplace.py index 040e3973..c715dcc4 100644 --- a/apps/coordinator-api/src/app/routers/marketplace.py +++ b/apps/coordinator-api/src/app/routers/marketplace.py @@ -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 diff --git a/apps/coordinator-api/src/app/routers/web_vitals.py b/apps/coordinator-api/src/app/routers/web_vitals.py index 2c1c13f6..6207e52f 100644 --- a/apps/coordinator-api/src/app/routers/web_vitals.py +++ b/apps/coordinator-api/src/app/routers/web_vitals.py @@ -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'}") diff --git a/apps/coordinator-api/src/app/schemas.py b/apps/coordinator-api/src/app/schemas.py index 432142f5..e8b96f58 100644 --- a/apps/coordinator-api/src/app/schemas.py +++ b/apps/coordinator-api/src/app/schemas.py @@ -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) diff --git a/apps/coordinator-api/src/app/services/explorer.py b/apps/coordinator-api/src/app/services/explorer.py index e5260b92..d2a31329 100644 --- a/apps/coordinator-api/src/app/services/explorer.py +++ b/apps/coordinator-api/src/app/services/explorer.py @@ -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 = ( diff --git a/apps/coordinator-api/src/app/services/marketplace.py b/apps/coordinator-api/src/app/services/marketplace.py index 0fd871ba..10f57edc 100644 --- a/apps/coordinator-api/src/app/services/marketplace.py +++ b/apps/coordinator-api/src/app/services/marketplace.py @@ -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 diff --git a/apps/coordinator-api/tests/test_exchange.py b/apps/coordinator-api/tests/test_exchange.py new file mode 100644 index 00000000..977b3374 --- /dev/null +++ b/apps/coordinator-api/tests/test_exchange.py @@ -0,0 +1,470 @@ +"""Tests for exchange API endpoints""" + +import pytest +import time +import uuid +from fastapi.testclient import TestClient +from sqlmodel import Session, delete + +from app.config import settings +from app.main import create_app +from app.routers.exchange import payments, BITCOIN_CONFIG + + +@pytest.fixture(scope="module", autouse=True) +def _init_db(tmp_path_factory): + db_file = tmp_path_factory.mktemp("data") / "exchange.db" + settings.database_url = f"sqlite:///{db_file}" + # Initialize database if needed + yield + + +@pytest.fixture() +def session(): + # For this test, we'll use in-memory storage + yield None + + +@pytest.fixture() +def client(): + app = create_app() + return TestClient(app) + + +@pytest.fixture(autouse=True) +def clear_payments(): + """Clear payments before each test""" + payments.clear() + yield + payments.clear() + + +class TestExchangeRatesEndpoint: + """Test exchange rates endpoint""" + + def test_get_exchange_rates_success(self, client: TestClient): + """Test successful exchange rates retrieval""" + response = client.get("/v1/exchange/rates") + + assert response.status_code == 200 + data = response.json() + + assert "btc_to_aitbc" in data + assert "aitbc_to_btc" in data + assert "fee_percent" in data + + # Verify rates are reasonable + assert data["btc_to_aitbc"] > 0 + assert data["aitbc_to_btc"] > 0 + assert data["fee_percent"] >= 0 + + # Verify mathematical relationship + expected_aitbc_to_btc = 1.0 / data["btc_to_aitbc"] + assert abs(data["aitbc_to_btc"] - expected_aitbc_to_btc) < 0.00000001 + + +class TestExchangeCreatePaymentEndpoint: + """Test exchange create-payment endpoint""" + + def test_create_payment_success(self, client: TestClient): + """Test successful payment creation""" + payload = { + "user_id": "test_user_123", + "aitbc_amount": 1000, + "btc_amount": 0.01 + } + + response = client.post("/v1/exchange/create-payment", json=payload) + + assert response.status_code == 200 + data = response.json() + + # Verify response structure + assert "payment_id" in data + assert data["user_id"] == payload["user_id"] + assert data["aitbc_amount"] == payload["aitbc_amount"] + assert data["btc_amount"] == payload["btc_amount"] + assert data["payment_address"] == BITCOIN_CONFIG['main_address'] + assert data["status"] == "pending" + assert "created_at" in data + assert "expires_at" in data + + # Verify payment was stored + assert data["payment_id"] in payments + stored_payment = payments[data["payment_id"]] + assert stored_payment["user_id"] == payload["user_id"] + assert stored_payment["aitbc_amount"] == payload["aitbc_amount"] + assert stored_payment["btc_amount"] == payload["btc_amount"] + + def test_create_payment_invalid_amounts(self, client: TestClient): + """Test payment creation with invalid amounts""" + # Test zero AITBC amount + payload1 = { + "user_id": "test_user", + "aitbc_amount": 0, + "btc_amount": 0.01 + } + response1 = client.post("/v1/exchange/create-payment", json=payload1) + assert response1.status_code == 400 + assert "Invalid amount" in response1.json()["detail"] + + # Test negative BTC amount + payload2 = { + "user_id": "test_user", + "aitbc_amount": 1000, + "btc_amount": -0.01 + } + response2 = client.post("/v1/exchange/create-payment", json=payload2) + assert response2.status_code == 400 + assert "Invalid amount" in response2.json()["detail"] + + def test_create_payment_amount_mismatch(self, client: TestClient): + """Test payment creation with amount mismatch""" + payload = { + "user_id": "test_user", + "aitbc_amount": 1000, # Should be 0.01 BTC at 100000 rate + "btc_amount": 0.02 # This is double the expected amount + } + + response = client.post("/v1/exchange/create-payment", json=payload) + assert response.status_code == 400 + assert "Amount mismatch" in response.json()["detail"] + + def test_create_payment_rounding_tolerance(self, client: TestClient): + """Test payment creation with small rounding differences""" + payload = { + "user_id": "test_user", + "aitbc_amount": 1000, + "btc_amount": 0.01000000001 # Very small difference should be allowed + } + + response = client.post("/v1/exchange/create-payment", json=payload) + assert response.status_code == 200 + + +class TestExchangePaymentStatusEndpoint: + """Test exchange payment-status endpoint""" + + def test_get_payment_status_success(self, client: TestClient): + """Test successful payment status retrieval""" + # First create a payment + create_payload = { + "user_id": "test_user", + "aitbc_amount": 500, + "btc_amount": 0.005 + } + create_response = client.post("/v1/exchange/create-payment", json=create_payload) + payment_id = create_response.json()["payment_id"] + + # Get payment status + response = client.get(f"/v1/exchange/payment-status/{payment_id}") + + assert response.status_code == 200 + data = response.json() + + assert data["payment_id"] == payment_id + assert data["user_id"] == create_payload["user_id"] + assert data["aitbc_amount"] == create_payload["aitbc_amount"] + assert data["btc_amount"] == create_payload["btc_amount"] + assert data["status"] == "pending" + assert data["confirmations"] == 0 + assert data["tx_hash"] is None + + def test_get_payment_status_not_found(self, client: TestClient): + """Test payment status for non-existent payment""" + fake_payment_id = "nonexistent_payment_id" + response = client.get(f"/v1/exchange/payment-status/{fake_payment_id}") + + assert response.status_code == 404 + assert "Payment not found" in response.json()["detail"] + + def test_get_payment_status_expired(self, client: TestClient): + """Test payment status for expired payment""" + # Create a payment with expired timestamp + payment_id = str(uuid.uuid4()) + expired_payment = { + 'payment_id': payment_id, + 'user_id': 'test_user', + 'aitbc_amount': 1000, + 'btc_amount': 0.01, + 'payment_address': BITCOIN_CONFIG['main_address'], + 'status': 'pending', + 'created_at': int(time.time()) - 7200, # 2 hours ago + 'expires_at': int(time.time()) - 3600, # 1 hour ago (expired) + 'confirmations': 0, + 'tx_hash': None + } + payments[payment_id] = expired_payment + + # Get payment status + response = client.get(f"/v1/exchange/payment-status/{payment_id}") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "expired" + + +class TestExchangeConfirmPaymentEndpoint: + """Test exchange confirm-payment endpoint""" + + def test_confirm_payment_success(self, client: TestClient): + """Test successful payment confirmation""" + # First create a payment + create_payload = { + "user_id": "test_user", + "aitbc_amount": 1000, + "btc_amount": 0.01 + } + create_response = client.post("/v1/exchange/create-payment", json=create_payload) + payment_id = create_response.json()["payment_id"] + + # Confirm payment + confirm_payload = {"tx_hash": "test_transaction_hash_123"} + response = client.post(f"/v1/exchange/confirm-payment/{payment_id}", + json=confirm_payload) + + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "ok" + assert data["payment_id"] == payment_id + assert data["aitbc_amount"] == create_payload["aitbc_amount"] + + # Verify payment was updated + payment = payments[payment_id] + assert payment["status"] == "confirmed" + assert payment["tx_hash"] == confirm_payload["tx_hash"] + assert "confirmed_at" in payment + + def test_confirm_payment_not_found(self, client: TestClient): + """Test payment confirmation for non-existent payment""" + fake_payment_id = "nonexistent_payment_id" + confirm_payload = {"tx_hash": "test_tx_hash"} + response = client.post(f"/v1/exchange/confirm-payment/{fake_payment_id}", + json=confirm_payload) + + assert response.status_code == 404 + assert "Payment not found" in response.json()["detail"] + + def test_confirm_payment_not_pending(self, client: TestClient): + """Test payment confirmation for non-pending payment""" + # Create and confirm a payment + create_payload = { + "user_id": "test_user", + "aitbc_amount": 1000, + "btc_amount": 0.01 + } + create_response = client.post("/v1/exchange/create-payment", json=create_payload) + payment_id = create_response.json()["payment_id"] + + # First confirmation + confirm_payload = {"tx_hash": "test_tx_hash_1"} + client.post(f"/v1/exchange/confirm-payment/{payment_id}", json=confirm_payload) + + # Try to confirm again + confirm_payload2 = {"tx_hash": "test_tx_hash_2"} + response = client.post(f"/v1/exchange/confirm-payment/{payment_id}", + json=confirm_payload2) + + assert response.status_code == 400 + assert "Payment not in pending state" in response.json()["detail"] + + +class TestExchangeMarketStatsEndpoint: + """Test exchange market-stats endpoint""" + + def test_get_market_stats_empty(self, client: TestClient): + """Test market stats with no payments""" + response = client.get("/v1/exchange/market-stats") + + assert response.status_code == 200 + data = response.json() + + assert "price" in data + assert "price_change_24h" in data + assert "daily_volume" in data + assert "daily_volume_btc" in data + assert "total_payments" in data + assert "pending_payments" in data + + # With no payments, these should be 0 + assert data["daily_volume"] == 0 + assert data["daily_volume_btc"] == 0 + assert data["total_payments"] == 0 + assert data["pending_payments"] == 0 + + def test_get_market_stats_with_payments(self, client: TestClient): + """Test market stats with payments""" + current_time = int(time.time()) + + # Create some confirmed payments (within 24h) + for i in range(3): + payment_id = str(uuid.uuid4()) + payment = { + 'payment_id': payment_id, + 'user_id': f'user_{i}', + 'aitbc_amount': 1000 * (i + 1), + 'btc_amount': 0.01 * (i + 1), + 'payment_address': BITCOIN_CONFIG['main_address'], + 'status': 'confirmed', + 'created_at': current_time - 3600, # 1 hour ago + 'expires_at': current_time + 3600, + 'confirmations': 1, + 'tx_hash': f'tx_hash_{i}', + 'confirmed_at': current_time - 1800 # 30 minutes ago + } + payments[payment_id] = payment + + # Create some pending payments + for i in range(2): + payment_id = str(uuid.uuid4()) + payment = { + 'payment_id': payment_id, + 'user_id': f'pending_user_{i}', + 'aitbc_amount': 500 * (i + 1), + 'btc_amount': 0.005 * (i + 1), + 'payment_address': BITCOIN_CONFIG['main_address'], + 'status': 'pending', + 'created_at': current_time - 1800, # 30 minutes ago + 'expires_at': current_time + 1800, + 'confirmations': 0, + 'tx_hash': None + } + payments[payment_id] = payment + + # Create an old confirmed payment (older than 24h) + old_payment_id = str(uuid.uuid4()) + old_payment = { + 'payment_id': old_payment_id, + 'user_id': 'old_user', + 'aitbc_amount': 2000, + 'btc_amount': 0.02, + 'payment_address': BITCOIN_CONFIG['main_address'], + 'status': 'confirmed', + 'created_at': current_time - 86400 - 3600, # 25 hours ago + 'expires_at': current_time - 86400 + 3600, + 'confirmations': 1, + 'tx_hash': 'old_tx_hash', + 'confirmed_at': current_time - 86400 # 24 hours ago + } + payments[old_payment_id] = old_payment + + response = client.get("/v1/exchange/market-stats") + + assert response.status_code == 200 + data = response.json() + + # Verify calculations + # Confirmed payments: 1000 + 2000 + 3000 = 6000 AITBC + # Pending payments: 500 + 1000 = 1500 AITBC + # Daily volume should only include recent confirmed payments + expected_daily_volume = 1000 + 2000 + 3000 # 6000 AITBC + expected_daily_volume_btc = expected_daily_volume / BITCOIN_CONFIG['exchange_rate'] + + assert data["total_payments"] == 3 # Only confirmed payments + assert data["pending_payments"] == 2 + assert data["daily_volume"] == expected_daily_volume + assert abs(data["daily_volume_btc"] - expected_daily_volume_btc) < 0.00000001 + + +class TestExchangeWalletEndpoints: + """Test exchange wallet endpoints""" + + def test_wallet_balance_endpoint(self, client: TestClient): + """Test wallet balance endpoint""" + # This test may fail if bitcoin_wallet service is not available + # We'll test the endpoint structure and error handling + response = client.get("/v1/exchange/wallet/balance") + + # The endpoint should exist, but may return 500 if service is unavailable + assert response.status_code in [200, 500] + + if response.status_code == 200: + data = response.json() + # Verify response structure if successful + expected_fields = ["address", "balance", "unconfirmed_balance", + "total_received", "total_sent"] + for field in expected_fields: + assert field in data + + def test_wallet_info_endpoint(self, client: TestClient): + """Test wallet info endpoint""" + response = client.get("/v1/exchange/wallet/info") + + # The endpoint should exist, but may return 500 if service is unavailable + assert response.status_code in [200, 500] + + if response.status_code == 200: + data = response.json() + # Verify response structure if successful + expected_fields = ["address", "balance", "unconfirmed_balance", + "total_received", "total_sent", "transactions", + "network", "block_height"] + for field in expected_fields: + assert field in data + + +class TestExchangeIntegration: + """Test exchange integration scenarios""" + + def test_complete_payment_lifecycle(self, client: TestClient): + """Test complete payment lifecycle: create → check status → confirm""" + # Step 1: Create payment + create_payload = { + "user_id": "integration_user", + "aitbc_amount": 1500, + "btc_amount": 0.015 + } + create_response = client.post("/v1/exchange/create-payment", json=create_payload) + assert create_response.status_code == 200 + payment_id = create_response.json()["payment_id"] + + # Step 2: Check initial status + status_response = client.get(f"/v1/exchange/payment-status/{payment_id}") + assert status_response.status_code == 200 + status_data = status_response.json() + assert status_data["status"] == "pending" + assert status_data["confirmations"] == 0 + + # Step 3: Confirm payment + confirm_payload = {"tx_hash": "integration_tx_hash"} + confirm_response = client.post(f"/v1/exchange/confirm-payment/{payment_id}", + json=confirm_payload) + assert confirm_response.status_code == 200 + + # Step 4: Check final status + final_status_response = client.get(f"/v1/exchange/payment-status/{payment_id}") + assert final_status_response.status_code == 200 + final_status_data = final_status_response.json() + assert final_status_data["status"] == "confirmed" + assert final_status_data["tx_hash"] == "integration_tx_hash" + assert "confirmed_at" in final_status_data + + def test_market_stats_update_after_payment(self, client: TestClient): + """Test that market stats update after payment confirmation""" + # Get initial stats + initial_stats_response = client.get("/v1/exchange/market-stats") + assert initial_stats_response.status_code == 200 + initial_stats = initial_stats_response.json() + initial_total = initial_stats["total_payments"] + + # Create and confirm payment + create_payload = { + "user_id": "stats_user", + "aitbc_amount": 2000, + "btc_amount": 0.02 + } + create_response = client.post("/v1/exchange/create-payment", json=create_payload) + payment_id = create_response.json()["payment_id"] + + confirm_payload = {"tx_hash": "stats_tx_hash"} + client.post(f"/v1/exchange/confirm-payment/{payment_id}", json=confirm_payload) + + # Check updated stats + updated_stats_response = client.get("/v1/exchange/market-stats") + assert updated_stats_response.status_code == 200 + updated_stats = updated_stats_response.json() + + # Total payments should have increased + assert updated_stats["total_payments"] == initial_total + 1 + assert updated_stats["daily_volume"] >= initial_stats["daily_volume"] diff --git a/apps/coordinator-api/tests/test_marketplace.py b/apps/coordinator-api/tests/test_marketplace.py index edd6d555..860a8a97 100644 --- a/apps/coordinator-api/tests/test_marketplace.py +++ b/apps/coordinator-api/tests/test_marketplace.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient from sqlmodel import Session, delete from app.config import settings -from app.domain import MarketplaceOffer, OfferStatus, MarketplaceBid +from app.domain import MarketplaceOffer, MarketplaceBid from app.main import create_app from app.services.marketplace import MarketplaceService from app.storage.db import init_db, session_scope @@ -33,8 +33,8 @@ def client(): def test_list_offers_filters_by_status(client: TestClient, session: Session): - open_offer = MarketplaceOffer(provider="Alpha", capacity=250, price=12.5, sla="99.9%", status=OfferStatus.open) - reserved_offer = MarketplaceOffer(provider="Beta", capacity=100, price=15.0, sla="99.5%", status=OfferStatus.reserved) + open_offer = MarketplaceOffer(provider="Alpha", capacity=250, price=12.5, sla="99.9%", status="open") + reserved_offer = MarketplaceOffer(provider="Beta", capacity=100, price=15.0, sla="99.5%", status="reserved") session.add(open_offer) session.add(reserved_offer) session.commit() @@ -99,9 +99,9 @@ def test_submit_bid_creates_record(client: TestClient, session: Session): def test_marketplace_service_list_offers_handles_limit_offset(session: Session): session.add_all( [ - MarketplaceOffer(provider="A", capacity=50, price=9.0, sla="99.0%", status=OfferStatus.open), - MarketplaceOffer(provider="B", capacity=70, price=11.0, sla="99.0%", status=OfferStatus.open), - MarketplaceOffer(provider="C", capacity=90, price=13.0, sla="99.0%", status=OfferStatus.open), + MarketplaceOffer(provider="A", capacity=50, price=9.0, sla="99.0%", status="open"), + MarketplaceOffer(provider="B", capacity=70, price=11.0, sla="99.0%", status="open"), + MarketplaceOffer(provider="C", capacity=90, price=13.0, sla="99.0%", status="open"), ] ) session.commit() @@ -111,3 +111,161 @@ def test_marketplace_service_list_offers_handles_limit_offset(session: Session): assert len(limited) == 2 # Offers ordered by created_at descending → last inserted first assert {offer.provider for offer in limited} == {"B", "A"} + + +def test_submit_bid_creates_record(client: TestClient, session: Session): + payload = { + "provider": "TestProvider", + "capacity": 150, + "price": 0.075, + "notes": "Test bid for GPU capacity" + } + resp = client.post("/v1/marketplace/bids", json=payload) + assert resp.status_code == 202 + response_payload = resp.json() + assert "id" in response_payload + + bid = session.get(MarketplaceBid, response_payload["id"]) + assert bid is not None + assert bid.provider == payload["provider"] + assert bid.capacity == payload["capacity"] + assert bid.price == payload["price"] + assert bid.notes == payload["notes"] + assert bid.status == "pending" + + +def test_list_bids_filters_by_status_and_provider(client: TestClient, session: Session): + # Create test bids + pending_bid = MarketplaceBid(provider="ProviderA", capacity=100, price=0.05, notes="Pending bid") + accepted_bid = MarketplaceBid(provider="ProviderB", capacity=200, price=0.08, notes="Accepted bid", status="accepted") + rejected_bid = MarketplaceBid(provider="ProviderA", capacity=150, price=0.06, notes="Rejected bid", status="rejected") + + session.add_all([pending_bid, accepted_bid, rejected_bid]) + session.commit() + + # List all bids + resp = client.get("/v1/marketplace/bids") + assert resp.status_code == 200 + payload = resp.json() + assert len(payload) == 3 + + # Filter by status + resp_pending = client.get("/v1/marketplace/bids", params={"status": "pending"}) + assert resp_pending.status_code == 200 + pending_payload = resp_pending.json() + assert len(pending_payload) == 1 + assert pending_payload[0]["provider"] == "ProviderA" + assert pending_payload[0]["status"] == "pending" + + # Filter by provider + resp_provider = client.get("/v1/marketplace/bids", params={"provider": "ProviderA"}) + assert resp_provider.status_code == 200 + provider_payload = resp_provider.json() + assert len(provider_payload) == 2 + assert all(bid["provider"] == "ProviderA" for bid in provider_payload) + + # Filter by both status and provider + resp_both = client.get("/v1/marketplace/bids", params={"status": "pending", "provider": "ProviderA"}) + assert resp_both.status_code == 200 + both_payload = resp_both.json() + assert len(both_payload) == 1 + assert both_payload[0]["provider"] == "ProviderA" + assert both_payload[0]["status"] == "pending" + + # Invalid status yields 400 + resp_invalid = client.get("/v1/marketplace/bids", params={"status": "invalid"}) + assert resp_invalid.status_code == 400 + + +def test_get_bid_details(client: TestClient, session: Session): + # Create a test bid + bid = MarketplaceBid( + provider="TestProvider", + capacity=100, + price=0.05, + notes="Test bid details", + status="pending" + ) + session.add(bid) + session.commit() + session.refresh(bid) + + # Get bid details + resp = client.get(f"/v1/marketplace/bids/{bid.id}") + assert resp.status_code == 200 + payload = resp.json() + assert payload["id"] == bid.id + assert payload["provider"] == bid.provider + assert payload["capacity"] == bid.capacity + assert payload["price"] == bid.price + assert payload["notes"] == bid.notes + assert payload["status"] == bid.status + assert "submitted_at" in payload + + # Non-existent bid yields 404 + resp_not_found = client.get("/v1/marketplace/bids/nonexistent") + assert resp_not_found.status_code == 404 + + +def test_marketplace_service_list_bids_handles_limit_offset(session: Session): + session.add_all( + [ + MarketplaceBid(provider="A", capacity=50, price=0.05, notes="Bid A"), + MarketplaceBid(provider="B", capacity=70, price=0.07, notes="Bid B"), + MarketplaceBid(provider="C", capacity=90, price=0.09, notes="Bid C"), + ] + ) + session.commit() + + service = MarketplaceService(session) + limited = service.list_bids(limit=2, offset=1) + assert len(limited) == 2 + # Bids ordered by submitted_at descending → last inserted first + assert {bid.provider for bid in limited} == {"B", "A"} + + +def test_marketplace_stats_includes_bids(client: TestClient, session: Session): + # Create offers and bids + session.add_all( + [ + MarketplaceOffer(provider="Alpha", capacity=200, price=10.0, sla="99.9%", status="open"), + MarketplaceOffer(provider="Beta", capacity=150, price=20.0, sla="99.5%", status="reserved"), + MarketplaceBid(provider="ProviderA", capacity=100, price=0.05, notes="Active bid 1"), + MarketplaceBid(provider="ProviderB", capacity=200, price=0.08, notes="Active bid 2"), + MarketplaceBid(provider="ProviderC", capacity=150, price=0.06, notes="Accepted bid", status="accepted"), + ] + ) + session.commit() + + resp = client.get("/v1/marketplace/stats") + assert resp.status_code == 200 + stats = resp.json() + assert stats["totalOffers"] == 2 + assert stats["openCapacity"] == 200 # Only open offers + assert pytest.approx(stats["averagePrice"], rel=1e-3) == 10.0 # Only open offers + assert stats["activeBids"] == 2 # Only pending bids + + +def test_bid_validation(client: TestClient): + # Test invalid capacity (zero) + resp_zero_capacity = client.post("/v1/marketplace/bids", json={ + "provider": "TestProvider", + "capacity": 0, + "price": 0.05 + }) + assert resp_zero_capacity.status_code == 400 + + # Test invalid price (negative) + resp_negative_price = client.post("/v1/marketplace/bids", json={ + "provider": "TestProvider", + "capacity": 100, + "price": -0.05 + }) + assert resp_negative_price.status_code == 400 + + # Test missing required field + resp_missing_provider = client.post("/v1/marketplace/bids", json={ + "capacity": 100, + "price": 0.05 + }) + assert resp_missing_provider.status_code == 422 # Validation error diff --git a/cli/aitbc_cli/commands/exchange.py b/cli/aitbc_cli/commands/exchange.py new file mode 100644 index 00000000..3236ecc6 --- /dev/null +++ b/cli/aitbc_cli/commands/exchange.py @@ -0,0 +1,224 @@ +"""Exchange commands for AITBC CLI""" + +import click +import httpx +from typing import Optional + +from ..config import get_config +from ..utils import success, error, output + + +@click.group() +def exchange(): + """Bitcoin exchange operations""" + pass + + +@exchange.command() +@click.pass_context +def rates(ctx): + """Get current exchange rates""" + config = ctx.obj['config'] + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/exchange/rates", + timeout=10 + ) + + if response.status_code == 200: + rates_data = response.json() + success("Current exchange rates:") + output(rates_data, ctx.obj['output_format']) + else: + error(f"Failed to get exchange rates: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") + + +@exchange.command() +@click.option("--aitbc-amount", type=float, help="Amount of AITBC to buy") +@click.option("--btc-amount", type=float, help="Amount of BTC to spend") +@click.option("--user-id", help="User ID for the payment") +@click.option("--notes", help="Additional notes for the payment") +@click.pass_context +def create_payment(ctx, aitbc_amount: Optional[float], btc_amount: Optional[float], + user_id: Optional[str], notes: Optional[str]): + """Create a Bitcoin payment request for AITBC purchase""" + config = ctx.obj['config'] + + # Validate input + if aitbc_amount is not None and aitbc_amount <= 0: + error("AITBC amount must be greater than 0") + return + + if btc_amount is not None and btc_amount <= 0: + error("BTC amount must be greater than 0") + return + + if not aitbc_amount and not btc_amount: + error("Either --aitbc-amount or --btc-amount must be specified") + return + + # Get exchange rates to calculate missing amount + try: + with httpx.Client() as client: + rates_response = client.get( + f"{config.coordinator_url}/v1/exchange/rates", + timeout=10 + ) + + if rates_response.status_code != 200: + error("Failed to get exchange rates") + return + + rates = rates_response.json() + btc_to_aitbc = rates.get('btc_to_aitbc', 100000) + + # Calculate missing amount + if aitbc_amount and not btc_amount: + btc_amount = aitbc_amount / btc_to_aitbc + elif btc_amount and not aitbc_amount: + aitbc_amount = btc_amount * btc_to_aitbc + + # Prepare payment request + payment_data = { + "user_id": user_id or "cli_user", + "aitbc_amount": aitbc_amount, + "btc_amount": btc_amount + } + + if notes: + payment_data["notes"] = notes + + # Create payment + response = client.post( + f"{config.coordinator_url}/v1/exchange/create-payment", + json=payment_data, + timeout=10 + ) + + if response.status_code == 200: + payment = response.json() + success(f"Payment created: {payment.get('payment_id')}") + success(f"Send {btc_amount:.8f} BTC to: {payment.get('payment_address')}") + success(f"Expires at: {payment.get('expires_at')}") + output(payment, ctx.obj['output_format']) + else: + error(f"Failed to create payment: {response.status_code}") + if response.text: + error(f"Error details: {response.text}") + + except Exception as e: + error(f"Network error: {e}") + + +@exchange.command() +@click.option("--payment-id", required=True, help="Payment ID to check") +@click.pass_context +def payment_status(ctx, payment_id: str): + """Check payment confirmation status""" + config = ctx.obj['config'] + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/exchange/payment-status/{payment_id}", + timeout=10 + ) + + if response.status_code == 200: + status_data = response.json() + status = status_data.get('status', 'unknown') + + if status == 'confirmed': + success(f"Payment {payment_id} is confirmed!") + success(f"AITBC amount: {status_data.get('aitbc_amount', 0)}") + elif status == 'pending': + success(f"Payment {payment_id} is pending confirmation") + elif status == 'expired': + error(f"Payment {payment_id} has expired") + else: + success(f"Payment {payment_id} status: {status}") + + output(status_data, ctx.obj['output_format']) + else: + error(f"Failed to get payment status: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") + + +@exchange.command() +@click.pass_context +def market_stats(ctx): + """Get exchange market statistics""" + config = ctx.obj['config'] + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/exchange/market-stats", + timeout=10 + ) + + if response.status_code == 200: + stats = response.json() + success("Exchange market statistics:") + output(stats, ctx.obj['output_format']) + else: + error(f"Failed to get market stats: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") + + +@exchange.group() +def wallet(): + """Bitcoin wallet operations""" + pass + + +@wallet.command() +@click.pass_context +def balance(ctx): + """Get Bitcoin wallet balance""" + config = ctx.obj['config'] + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/exchange/wallet/balance", + timeout=10 + ) + + if response.status_code == 200: + balance_data = response.json() + success("Bitcoin wallet balance:") + output(balance_data, ctx.obj['output_format']) + else: + error(f"Failed to get wallet balance: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") + + +@wallet.command() +@click.pass_context +def info(ctx): + """Get comprehensive Bitcoin wallet information""" + config = ctx.obj['config'] + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/exchange/wallet/info", + timeout=10 + ) + + if response.status_code == 200: + wallet_info = response.json() + success("Bitcoin wallet information:") + output(wallet_info, ctx.obj['output_format']) + else: + error(f"Failed to get wallet info: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") diff --git a/cli/aitbc_cli/commands/marketplace.py b/cli/aitbc_cli/commands/marketplace.py index dd902e06..801be9cd 100644 --- a/cli/aitbc_cli/commands/marketplace.py +++ b/cli/aitbc_cli/commands/marketplace.py @@ -305,3 +305,164 @@ def review(ctx, gpu_id: str, rating: int, comment: Optional[str]): error(f"Failed to add review: {response.status_code}") except Exception as e: error(f"Network error: {e}") + + +@marketplace.group() +def bid(): + """Marketplace bid operations""" + pass + + +@bid.command() +@click.option("--provider", required=True, help="Provider ID (e.g., miner123)") +@click.option("--capacity", type=int, required=True, help="Bid capacity (number of units)") +@click.option("--price", type=float, required=True, help="Price per unit in AITBC") +@click.option("--notes", help="Additional notes for the bid") +@click.pass_context +def submit(ctx, provider: str, capacity: int, price: float, notes: Optional[str]): + """Submit a bid to the marketplace""" + config = ctx.obj['config'] + + # Validate inputs + if capacity <= 0: + error("Capacity must be greater than 0") + return + if price <= 0: + error("Price must be greater than 0") + return + + # Build bid data + bid_data = { + "provider": provider, + "capacity": capacity, + "price": price + } + if notes: + bid_data["notes"] = notes + + try: + with httpx.Client() as client: + response = client.post( + f"{config.coordinator_url}/v1/marketplace/bids", + headers={ + "Content-Type": "application/json", + "X-Api-Key": config.api_key or "" + }, + json=bid_data + ) + + if response.status_code == 202: + result = response.json() + success(f"Bid submitted successfully: {result.get('id')}") + output(result, ctx.obj['output_format']) + else: + error(f"Failed to submit bid: {response.status_code}") + if response.text: + error(f"Error details: {response.text}") + except Exception as e: + error(f"Network error: {e}") + + +@bid.command() +@click.option("--status", help="Filter by bid status (pending, accepted, rejected)") +@click.option("--provider", help="Filter by provider ID") +@click.option("--limit", type=int, default=20, help="Maximum number of results") +@click.pass_context +def list(ctx, status: Optional[str], provider: Optional[str], limit: int): + """List marketplace bids""" + config = ctx.obj['config'] + + # Build query params + params = {"limit": limit} + if status: + params["status"] = status + if provider: + params["provider"] = provider + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/marketplace/bids", + params=params, + headers={"X-Api-Key": config.api_key or ""} + ) + + if response.status_code == 200: + bids = response.json() + output(bids, ctx.obj['output_format']) + else: + error(f"Failed to list bids: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") + + +@bid.command() +@click.argument("bid_id") +@click.pass_context +def details(ctx, bid_id: str): + """Get bid details""" + config = ctx.obj['config'] + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/marketplace/bids/{bid_id}", + headers={"X-Api-Key": config.api_key or ""} + ) + + if response.status_code == 200: + bid_data = response.json() + output(bid_data, ctx.obj['output_format']) + else: + error(f"Bid not found: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") + + +@marketplace.group() +def offers(): + """Marketplace offers operations""" + pass + + +@offers.command() +@click.option("--status", help="Filter by offer status (open, reserved, closed)") +@click.option("--gpu-model", help="Filter by GPU model") +@click.option("--price-max", type=float, help="Maximum price per hour") +@click.option("--memory-min", type=int, help="Minimum memory in GB") +@click.option("--region", help="Filter by region") +@click.option("--limit", type=int, default=20, help="Maximum number of results") +@click.pass_context +def list(ctx, status: Optional[str], gpu_model: Optional[str], price_max: Optional[float], + memory_min: Optional[int], region: Optional[str], limit: int): + """List marketplace offers""" + config = ctx.obj['config'] + + # Build query params + params = {"limit": limit} + if status: + params["status"] = status + if gpu_model: + params["gpu_model"] = gpu_model + if price_max: + params["price_max"] = price_max + if memory_min: + params["memory_min"] = memory_min + if region: + params["region"] = region + + try: + with httpx.Client() as client: + response = client.get( + f"{config.coordinator_url}/v1/marketplace/offers", + params=params, + headers={"X-Api-Key": config.api_key or ""} + ) + + if response.status_code == 200: + offers = response.json() + output(offers, ctx.obj['output_format']) + else: + error(f"Failed to list offers: {response.status_code}") + except Exception as e: + error(f"Network error: {e}") diff --git a/cli/aitbc_cli/main.py b/cli/aitbc_cli/main.py index 3d344c67..44291839 100644 --- a/cli/aitbc_cli/main.py +++ b/cli/aitbc_cli/main.py @@ -21,6 +21,7 @@ from .commands.admin import admin from .commands.config import config from .commands.monitor import monitor from .commands.governance import governance +from .commands.exchange import exchange from .plugins import plugin, load_plugins @@ -98,6 +99,7 @@ cli.add_command(admin) cli.add_command(config) cli.add_command(monitor) cli.add_command(governance) +cli.add_command(exchange) cli.add_command(plugin) load_plugins(cli) diff --git a/cli/test_exchange_e2e.py b/cli/test_exchange_e2e.py new file mode 100644 index 00000000..a06e5c25 --- /dev/null +++ b/cli/test_exchange_e2e.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +Exchange End-to-End Test +Tests complete Bitcoin exchange workflow: rates → payment creation → monitoring → confirmation. +""" + +import argparse +import sys +import time +from typing import Optional + +import httpx + +DEFAULT_COORDINATOR = "http://localhost:8000" +DEFAULT_API_KEY = "${CLIENT_API_KEY}" +DEFAULT_USER_ID = "e2e_test_user" +DEFAULT_AITBC_AMOUNT = 1000 +DEFAULT_TIMEOUT = 300 +POLL_INTERVAL = 10 + + +def get_exchange_rates(client: httpx.Client, base_url: str) -> Optional[dict]: + """Get current exchange rates""" + response = client.get( + f"{base_url}/v1/exchange/rates", + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to get exchange rates: {response.status_code} {response.text}") + return None + return response.json() + + +def create_payment(client: httpx.Client, base_url: str, user_id: str, + aitbc_amount: float, btc_amount: Optional[float] = None, + notes: Optional[str] = None) -> Optional[dict]: + """Create a Bitcoin payment request""" + if not btc_amount: + # Get rates to calculate BTC amount + rates = get_exchange_rates(client, base_url) + if not rates: + return None + btc_amount = aitbc_amount / rates['btc_to_aitbc'] + + payload = { + "user_id": user_id, + "aitbc_amount": aitbc_amount, + "btc_amount": btc_amount + } + if notes: + payload["notes"] = notes + + response = client.post( + f"{base_url}/v1/exchange/create-payment", + json=payload, + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to create payment: {response.status_code} {response.text}") + return None + return response.json() + + +def get_payment_status(client: httpx.Client, base_url: str, payment_id: str) -> Optional[dict]: + """Get payment status""" + response = client.get( + f"{base_url}/v1/exchange/payment-status/{payment_id}", + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to get payment status: {response.status_code} {response.text}") + return None + return response.json() + + +def confirm_payment(client: httpx.Client, base_url: str, payment_id: str, + tx_hash: str) -> Optional[dict]: + """Confirm payment (simulating blockchain confirmation)""" + response = client.post( + f"{base_url}/v1/exchange/confirm-payment/{payment_id}", + json={"tx_hash": tx_hash}, + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to confirm payment: {response.status_code} {response.text}") + return None + return response.json() + + +def get_market_stats(client: httpx.Client, base_url: str) -> Optional[dict]: + """Get market statistics""" + response = client.get( + f"{base_url}/v1/exchange/market-stats", + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to get market stats: {response.status_code} {response.text}") + return None + return response.json() + + +def get_wallet_balance(client: httpx.Client, base_url: str) -> Optional[dict]: + """Get Bitcoin wallet balance""" + response = client.get( + f"{base_url}/v1/exchange/wallet/balance", + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to get wallet balance: {response.status_code} {response.text}") + return None + return response.json() + + +def monitor_payment_confirmation(client: httpx.Client, base_url: str, + payment_id: str, timeout: int) -> Optional[str]: + """Monitor payment until confirmed or timeout""" + deadline = time.time() + timeout + + while time.time() < deadline: + status_data = get_payment_status(client, base_url, payment_id) + if not status_data: + return None + + status = status_data.get("status") + print(f"⏳ Payment status: {status}") + + if status == "confirmed": + return status + elif status == "expired": + print("❌ Payment expired") + return status + + time.sleep(POLL_INTERVAL) + + print("❌ Payment monitoring timed out") + return None + + +def test_basic_exchange_workflow(client: httpx.Client, base_url: str, user_id: str, + aitbc_amount: float) -> bool: + """Test basic exchange workflow""" + print("🧪 Testing basic exchange workflow...") + + # Step 1: Get exchange rates + print("💱 Getting exchange rates...") + rates = get_exchange_rates(client, base_url) + if not rates: + print("❌ Failed to get exchange rates") + return False + + print(f"✅ Exchange rates: 1 BTC = {rates['btc_to_aitbc']:,} AITBC") + print(f" Fee: {rates['fee_percent']}%") + + # Step 2: Create payment + print(f"💰 Creating payment for {aitbc_amount} AITBC...") + payment = create_payment(client, base_url, user_id, aitbc_amount, + notes="E2E test payment") + if not payment: + print("❌ Failed to create payment") + return False + + print(f"✅ Payment created: {payment['payment_id']}") + print(f" Send {payment['btc_amount']:.8f} BTC to: {payment['payment_address']}") + print(f" Expires at: {payment['expires_at']}") + + # Step 3: Check initial payment status + print("📋 Checking initial payment status...") + status = get_payment_status(client, base_url, payment['payment_id']) + if not status: + print("❌ Failed to get payment status") + return False + + print(f"✅ Initial status: {status['status']}") + + # Step 4: Simulate payment confirmation + print("🔗 Simulating blockchain confirmation...") + tx_hash = f"test_tx_{int(time.time())}" + confirmation = confirm_payment(client, base_url, payment['payment_id'], tx_hash) + if not confirmation: + print("❌ Failed to confirm payment") + return False + + print(f"✅ Payment confirmed with transaction: {tx_hash}") + + # Step 5: Verify final status + print("📄 Verifying final payment status...") + final_status = get_payment_status(client, base_url, payment['payment_id']) + if not final_status: + print("❌ Failed to get final payment status") + return False + + if final_status['status'] != 'confirmed': + print(f"❌ Expected confirmed status, got: {final_status['status']}") + return False + + print(f"✅ Payment confirmed! AITBC amount: {final_status['aitbc_amount']}") + + return True + + +def test_market_statistics(client: httpx.Client, base_url: str) -> bool: + """Test market statistics functionality""" + print("🧪 Testing market statistics...") + + stats = get_market_stats(client, base_url) + if not stats: + print("❌ Failed to get market stats") + return False + + print(f"📊 Market Statistics:") + print(f" Current price: ${stats['price']:.8f} per AITBC") + print(f" 24h change: {stats['price_change_24h']:+.2f}%") + print(f" Daily volume: {stats['daily_volume']:,} AITBC") + print(f" Daily volume (BTC): {stats['daily_volume_btc']:.8f} BTC") + print(f" Total payments: {stats['total_payments']}") + print(f" Pending payments: {stats['pending_payments']}") + + return True + + +def test_wallet_operations(client: httpx.Client, base_url: str) -> bool: + """Test wallet operations""" + print("🧪 Testing wallet operations...") + + balance = get_wallet_balance(client, base_url) + if not balance: + print("❌ Failed to get wallet balance (service may be unavailable)") + return True # Don't fail test if wallet service is unavailable + + print(f"💰 Wallet Balance:") + print(f" Address: {balance['address']}") + print(f" Balance: {balance['balance']:.8f} BTC") + print(f" Unconfirmed: {balance['unconfirmed_balance']:.8f} BTC") + print(f" Total received: {balance['total_received']:.8f} BTC") + print(f" Total sent: {balance['total_sent']:.8f} BTC") + + return True + + +def test_multiple_payments_scenario(client: httpx.Client, base_url: str, + user_id: str) -> bool: + """Test multiple payments scenario""" + print("🧪 Testing multiple payments scenario...") + + # Create multiple payments + payment_amounts = [500, 1000, 1500] + payment_ids = [] + + for i, amount in enumerate(payment_amounts): + print(f"💰 Creating payment {i+1}: {amount} AITBC...") + payment = create_payment(client, base_url, f"{user_id}_{i}", amount, + notes=f"Multi-payment test {i+1}") + if not payment: + print(f"❌ Failed to create payment {i+1}") + return False + + payment_ids.append(payment['payment_id']) + print(f"✅ Payment {i+1} created: {payment['payment_id']}") + time.sleep(1) # Small delay between payments + + # Confirm all payments + for i, payment_id in enumerate(payment_ids): + print(f"🔗 Confirming payment {i+1}...") + tx_hash = f"multi_tx_{i}_{int(time.time())}" + confirmation = confirm_payment(client, base_url, payment_id, tx_hash) + if not confirmation: + print(f"❌ Failed to confirm payment {i+1}") + return False + print(f"✅ Payment {i+1} confirmed") + time.sleep(0.5) + + # Check updated market stats + print("📊 Checking updated market statistics...") + final_stats = get_market_stats(client, base_url) + if final_stats: + print(f"✅ Final stats: {final_stats['total_payments']} total payments") + + return True + + +def test_error_scenarios(client: httpx.Client, base_url: str) -> bool: + """Test error handling scenarios""" + print("🧪 Testing error scenarios...") + + # Test invalid payment creation + print("❌ Testing invalid payment creation...") + invalid_payment = create_payment(client, base_url, "test_user", -100) + if invalid_payment: + print("❌ Expected error for negative amount, but got success") + return False + print("✅ Correctly rejected negative amount") + + # Test non-existent payment status + print("❌ Testing non-existent payment status...") + fake_status = get_payment_status(client, base_url, "fake_payment_id") + if fake_status: + print("❌ Expected error for fake payment ID, but got success") + return False + print("✅ Correctly rejected fake payment ID") + + # Test invalid payment confirmation + print("❌ Testing invalid payment confirmation...") + fake_confirmation = confirm_payment(client, base_url, "fake_payment_id", "fake_tx") + if fake_confirmation: + print("❌ Expected error for fake payment confirmation, but got success") + return False + print("✅ Correctly rejected fake payment confirmation") + + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description="Exchange end-to-end test") + parser.add_argument("--url", default=DEFAULT_COORDINATOR, help="Coordinator base URL") + parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="Client API key") + parser.add_argument("--user-id", default=DEFAULT_USER_ID, help="User ID for payments") + parser.add_argument("--aitbc-amount", type=float, default=DEFAULT_AITBC_AMOUNT, help="AITBC amount for test payment") + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Timeout in seconds") + parser.add_argument("--test", choices=["basic", "stats", "wallet", "multi", "errors", "all"], + default="all", help="Test scenario to run") + args = parser.parse_args() + + with httpx.Client() as client: + print("🚀 Starting Exchange end-to-end test...") + print(f"📍 Coordinator: {args.url}") + print(f"🆔 User ID: {args.user_id}") + print(f"💰 Test amount: {args.aitbc_amount} AITBC") + print() + + success = True + + if args.test in ["basic", "all"]: + success &= test_basic_exchange_workflow(client, args.url, args.user_id, args.aitbc_amount) + print() + + if args.test in ["stats", "all"]: + success &= test_market_statistics(client, args.url) + print() + + if args.test in ["wallet", "all"]: + success &= test_wallet_operations(client, args.url) + print() + + if args.test in ["multi", "all"]: + success &= test_multiple_payments_scenario(client, args.url, args.user_id) + print() + + if args.test in ["errors", "all"]: + success &= test_error_scenarios(client, args.url) + print() + + if success: + print("✅ All exchange tests completed successfully!") + return 0 + else: + print("❌ Some exchange tests failed!") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cli/test_gpu_marketplace_bids.py b/cli/test_gpu_marketplace_bids.py new file mode 100644 index 00000000..40677c37 --- /dev/null +++ b/cli/test_gpu_marketplace_bids.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +GPU Marketplace Bids Test +Tests complete marketplace bid workflow: offers listing → bid submission → bid tracking. +""" + +import argparse +import sys +import time +from typing import Optional + +import httpx + +DEFAULT_COORDINATOR = "http://localhost:8000" +DEFAULT_API_KEY = "${CLIENT_API_KEY}" +DEFAULT_PROVIDER = "test_miner_123" +DEFAULT_CAPACITY = 100 +DEFAULT_PRICE = 0.05 +DEFAULT_TIMEOUT = 300 +POLL_INTERVAL = 5 + + +def list_offers(client: httpx.Client, base_url: str, api_key: str, + status: Optional[str] = None, gpu_model: Optional[str] = None) -> Optional[dict]: + """List marketplace offers with optional filters""" + params = {"limit": 20} + if status: + params["status"] = status + if gpu_model: + params["gpu_model"] = gpu_model + + response = client.get( + f"{base_url}/v1/marketplace/offers", + headers={"X-Api-Key": api_key}, + params=params, + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to list offers: {response.status_code} {response.text}") + return None + return response.json() + + +def submit_bid(client: httpx.Client, base_url: str, api_key: str, + provider: str, capacity: int, price: float, + notes: Optional[str] = None) -> Optional[str]: + """Submit a marketplace bid""" + payload = { + "provider": provider, + "capacity": capacity, + "price": price + } + if notes: + payload["notes"] = notes + + response = client.post( + f"{base_url}/v1/marketplace/bids", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + json=payload, + timeout=10, + ) + if response.status_code != 202: + print(f"❌ Bid submission failed: {response.status_code} {response.text}") + return None + return response.json().get("id") + + +def list_bids(client: httpx.Client, base_url: str, api_key: str, + status: Optional[str] = None, provider: Optional[str] = None) -> Optional[dict]: + """List marketplace bids with optional filters""" + params = {"limit": 20} + if status: + params["status"] = status + if provider: + params["provider"] = provider + + response = client.get( + f"{base_url}/v1/marketplace/bids", + headers={"X-Api-Key": api_key}, + params=params, + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to list bids: {response.status_code} {response.text}") + return None + return response.json() + + +def get_bid_details(client: httpx.Client, base_url: str, api_key: str, bid_id: str) -> Optional[dict]: + """Get detailed information about a specific bid""" + response = client.get( + f"{base_url}/v1/marketplace/bids/{bid_id}", + headers={"X-Api-Key": api_key}, + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to get bid details: {response.status_code} {response.text}") + return None + return response.json() + + +def get_marketplace_stats(client: httpx.Client, base_url: str, api_key: str) -> Optional[dict]: + """Get marketplace statistics""" + response = client.get( + f"{base_url}/v1/marketplace/stats", + headers={"X-Api-Key": api_key}, + timeout=10, + ) + if response.status_code != 200: + print(f"❌ Failed to get marketplace stats: {response.status_code} {response.text}") + return None + return response.json() + + +def monitor_bid_status(client: httpx.Client, base_url: str, api_key: str, + bid_id: str, timeout: int) -> Optional[str]: + """Monitor bid status until it's accepted/rejected or timeout""" + deadline = time.time() + timeout + + while time.time() < deadline: + bid_details = get_bid_details(client, base_url, api_key, bid_id) + if not bid_details: + return None + + status = bid_details.get("status") + print(f"⏳ Bid status: {status}") + + if status in {"accepted", "rejected"}: + return status + + time.sleep(POLL_INTERVAL) + + print("❌ Bid status monitoring timed out") + return None + + +def test_basic_workflow(client: httpx.Client, base_url: str, api_key: str, + provider: str, capacity: int, price: float) -> bool: + """Test basic marketplace bid workflow""" + print("🧪 Testing basic marketplace bid workflow...") + + # Step 1: List available offers + print("📋 Listing marketplace offers...") + offers = list_offers(client, base_url, api_key, status="open") + if not offers: + print("❌ Failed to list offers") + return False + + offers_list = offers.get("offers", []) + print(f"✅ Found {len(offers_list)} open offers") + + if offers_list: + print("📊 Sample offers:") + for i, offer in enumerate(offers_list[:3]): # Show first 3 offers + print(f" {i+1}. {offer.get('gpu_model', 'Unknown')} - ${offer.get('price', 0):.4f}/hr - {offer.get('provider', 'Unknown')}") + + # Step 2: Submit bid + print(f"💰 Submitting bid: {capacity} units at ${price:.4f}/unit from {provider}") + bid_id = submit_bid(client, base_url, api_key, provider, capacity, price, + notes="Test bid for GPU marketplace") + if not bid_id: + print("❌ Failed to submit bid") + return False + + print(f"✅ Bid submitted: {bid_id}") + + # Step 3: Get bid details + print("📄 Getting bid details...") + bid_details = get_bid_details(client, base_url, api_key, bid_id) + if not bid_details: + print("❌ Failed to get bid details") + return False + + print(f"✅ Bid details: {bid_details['provider']} - {bid_details['capacity']} units - ${bid_details['price']:.4f}/unit - {bid_details['status']}") + + # Step 4: List bids to verify it appears + print("📋 Listing bids to verify...") + bids = list_bids(client, base_url, api_key, provider=provider) + if not bids: + print("❌ Failed to list bids") + return False + + bids_list = bids.get("bids", []) + our_bid = next((b for b in bids_list if b.get("id") == bid_id), None) + if not our_bid: + print("❌ Submitted bid not found in bid list") + return False + + print(f"✅ Bid found in list: {our_bid['status']}") + + return True + + +def test_competitive_bidding(client: httpx.Client, base_url: str, api_key: str) -> bool: + """Test competitive bidding scenario with multiple providers""" + print("🧪 Testing competitive bidding scenario...") + + # Submit multiple bids from different providers + providers = ["provider_alpha", "provider_beta", "provider_gamma"] + bid_ids = [] + + for i, provider in enumerate(providers): + price = 0.05 - (i * 0.01) # Decreasing prices + print(f"💰 {provider} submitting bid at ${price:.4f}/unit") + + bid_id = submit_bid(client, base_url, api_key, provider, 50, price, + notes=f"Competitive bid from {provider}") + if not bid_id: + print(f"❌ {provider} failed to submit bid") + return False + + bid_ids.append((provider, bid_id)) + time.sleep(1) # Small delay between submissions + + print(f"✅ All {len(bid_ids)} competitive bids submitted") + + # List all bids to see the competition + all_bids = list_bids(client, base_url, api_key) + if not all_bids: + print("❌ Failed to list all bids") + return False + + bids_list = all_bids.get("bids", []) + competitive_bids = [b for b in bids_list if b.get("provider") in providers] + + print(f"📊 Found {len(competitive_bids)} competitive bids:") + for bid in sorted(competitive_bids, key=lambda x: x.get("price", 0)): + print(f" {bid['provider']}: ${bid['price']:.4f}/unit - {bid['status']}") + + return True + + +def test_marketplace_stats(client: httpx.Client, base_url: str, api_key: str) -> bool: + """Test marketplace statistics functionality""" + print("🧪 Testing marketplace statistics...") + + stats = get_marketplace_stats(client, base_url, api_key) + if not stats: + print("❌ Failed to get marketplace stats") + return False + + print(f"📊 Marketplace Statistics:") + print(f" Total offers: {stats.get('totalOffers', 0)}") + print(f" Open capacity: {stats.get('openCapacity', 0)}") + print(f" Average price: ${stats.get('averagePrice', 0):.4f}") + print(f" Active bids: {stats.get('activeBids', 0)}") + + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description="GPU marketplace bids end-to-end test") + parser.add_argument("--url", default=DEFAULT_COORDINATOR, help="Coordinator base URL") + parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="Client API key") + parser.add_argument("--provider", default=DEFAULT_PROVIDER, help="Provider ID for bids") + parser.add_argument("--capacity", type=int, default=DEFAULT_CAPACITY, help="Bid capacity") + parser.add_argument("--price", type=float, default=DEFAULT_PRICE, help="Price per unit") + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Timeout in seconds") + parser.add_argument("--test", choices=["basic", "competitive", "stats", "all"], + default="all", help="Test scenario to run") + args = parser.parse_args() + + with httpx.Client() as client: + print("🚀 Starting GPU marketplace bids test...") + print(f"📍 Coordinator: {args.url}") + print(f"🆔 Provider: {args.provider}") + print(f"💰 Bid: {args.capacity} units at ${args.price:.4f}/unit") + print() + + success = True + + if args.test in ["basic", "all"]: + success &= test_basic_workflow(client, args.url, args.api_key, + args.provider, args.capacity, args.price) + print() + + if args.test in ["competitive", "all"]: + success &= test_competitive_bidding(client, args.url, args.api_key) + print() + + if args.test in ["stats", "all"]: + success &= test_marketplace_stats(client, args.url, args.api_key) + print() + + if success: + print("✅ All marketplace bid tests completed successfully!") + return 0 + else: + print("❌ Some marketplace bid tests failed!") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/infrastructure.md b/docs/1_project/3_infrastructure.md similarity index 74% rename from docs/infrastructure.md rename to docs/1_project/3_infrastructure.md index d73e0abb..a07861d1 100644 --- a/docs/infrastructure.md +++ b/docs/1_project/3_infrastructure.md @@ -40,6 +40,66 @@ Internet → aitbc.bubuit.net (HTTPS :443) ## Incus Host (localhost) +### Services (Host) + +| Service | Port | Process | Python Version | Purpose | Status | +|---------|------|---------|----------------|---------|--------| +| Mock Coordinator | 8090 | python3 | 3.11+ | Development/testing API endpoint | systemd: aitbc-mock-coordinator.service | +| Blockchain Node | N/A | python3 | 3.11+ | Local blockchain node | systemd: aitbc-blockchain-node.service | +| Blockchain RPC API | 9080 | python3 | 3.11+ | RPC API for blockchain | systemd: aitbc-blockchain-rpc.service | +| GPU Miner Client | N/A | python3 | 3.11+ | GPU mining client | systemd: aitbc-gpu-miner.service | +| Local Development Tools | Varies | python3 | 3.11+ | CLI tools, scripts, testing | Manual/venv | + +### Systemd Services (Host) + +All services are configured as systemd units but currently inactive: + +```bash +# Service files location: /etc/systemd/system/ +aitbc-blockchain-node.service # Blockchain node main process +aitbc-blockchain-rpc.service # RPC API on port 9080 +aitbc-gpu-miner.service # GPU mining client +aitbc-mock-coordinator.service # Mock coordinator on port 8090 +``` + +**Service Details:** +- **Working Directory**: `/home/oib/windsurf/aitbc/apps/blockchain-node` +- **Python Environment**: `/home/oib/windsurf/aitbc/apps/blockchain-node/.venv/bin/python` +- **User**: oib +- **Restart Policy**: always (with 5s delay) + +**Verification Commands:** +```bash +# Check service status +sc-status aitbc-blockchain-node.service aitbc-blockchain-rpc.service aitbc-gpu-miner.service aitbc-mock-coordinator.service + +# Start services +sudo systemctl start aitbc-mock-coordinator.service +sudo systemctl start aitbc-blockchain-node.service + +# Check logs +journalctl -u aitbc-mock-coordinator --no-pager -n 20 +``` + +### Python Environment (Host) + +Development and testing services on localhost use **Python 3.11+**: + +```bash +# Localhost development workspace +/home/oib/windsurf/aitbc/ # Local development +├── .venv/ # Primary Python environment +├── cli/ # CLI tools (12 command groups) +├── scripts/ # Development scripts +└── tests/ # Pytest suites +``` + +**Verification Commands:** +```bash +python3 --version # Should show Python 3.11+ +ls -la /home/oib/windsurf/aitbc/.venv/bin/python # Check venv +``` + ### Nginx Reverse Proxy The host runs a simple reverse proxy that forwards all traffic to the container. SSL is terminated here via Let's Encrypt. @@ -80,14 +140,32 @@ ssh aitbc-cascade # Direct SSH to container ### Services -| Service | Port | Process | Public URL | -|---------|------|---------|------------| -| Nginx (web) | 80 | nginx | https://aitbc.bubuit.net/ | -| Coordinator API | 8000 | python (uvicorn) | /api/ → /v1/ | -| Blockchain Node RPC | 8081 | python3 | /rpc/ | -| Wallet Daemon | 8002 | python | /wallet/ | -| Trade Exchange | 3002 | python (server.py) | /Exchange | -| Exchange API | 8085 | python | /api/trades/*, /api/orders/* | +| Service | Port | Process | Python Version | Public URL | +|---------|------|---------|----------------|------------| +| Nginx (web) | 80 | nginx | N/A | https://aitbc.bubuit.net/ | +| Coordinator API | 8000 | python (uvicorn) | 3.11+ | /api/ → /v1/ | +| Blockchain Node RPC | 8081 | python3 | 3.11+ | /rpc/ | +| Wallet Daemon | 8002 | python | 3.11+ | /wallet/ | +| Trade Exchange | 3002 | python (server.py) | 3.11+ | /Exchange | +| Exchange API | 8085 | python | 3.11+ | /api/trades/*, /api/orders/* | + +### Python Environment Details + +All Python services in the AITBC container run on **Python 3.11+** with isolated virtual environments: + +```bash +# Container: aitbc (10.1.223.93) +/opt/coordinator-api/.venv/ # Coordinator API (uvicorn, FastAPI) +/opt/blockchain-node/.venv/ # Blockchain Node 1 (aitbc_chain) +/opt/blockchain-node-2/.venv/ # Blockchain Node 2 (aitbc_chain) +/opt/exchange/.venv/ # Exchange API (Flask/specific framework) +``` + +**Verification Commands:** +```bash +ssh aitbc-cascade "python3 --version" # Should show Python 3.11+ +ssh aitbc-cascade "ls -la /opt/*/.venv/bin/python" # Check venv symlinks +``` ### Nginx Routes (container) diff --git a/docs/issues/mock-coordinator-services-removed-2026-02-16.md b/docs/issues/mock-coordinator-services-removed-2026-02-16.md new file mode 100644 index 00000000..91991530 --- /dev/null +++ b/docs/issues/mock-coordinator-services-removed-2026-02-16.md @@ -0,0 +1,104 @@ +# Mock Coordinator Services Removal - RESOLVED + +**Date:** February 16, 2026 +**Status:** Resolved +**Severity:** Low + +## Issue Description +Mock coordinator services were running on both localhost and AITBC server environments, creating potential confusion between development and production deployments. This could lead to testing against mock data instead of real production APIs. + +## Affected Components +- **Localhost**: `aitbc-mock-coordinator.service` +- **AITBC Server**: `aitbc-coordinator.service` (mock version) +- **Production**: `aitbc-coordinator-api.service` (desired service) + +## Root Cause Analysis +Historical development setup included mock coordinator services for testing purposes. These were never properly cleaned up when moving to production deployment, leading to: +- Multiple coordinator services running simultaneously +- Potential routing to mock endpoints instead of production +- Confusion about which service was handling requests + +## Solution Implemented + +### 1. Localhost Cleanup +```bash +# Stop and disable mock service +sudo systemctl stop aitbc-mock-coordinator.service +sudo systemctl disable aitbc-mock-coordinator.service + +# Remove service file +sudo rm /etc/systemd/system/aitbc-mock-coordinator.service +sudo systemctl daemon-reload +``` + +### 2. AITBC Server Cleanup +```bash +# Stop and disable mock service +ssh aitbc-cascade "systemctl stop aitbc-coordinator.service" +ssh aitbc-cascade "systemctl disable aitbc-coordinator.service" + +# Remove service file +ssh aitbc-cascade "rm /etc/systemd/system/aitbc-coordinator.service" +ssh aitbc-cascade "systemctl daemon-reload" +``` + +### 3. Production Service Verification +Confirmed production services running correctly: +- **Localhost**: `aitbc-coordinator-api.service` active on port 8000 +- **AITBC Server**: `aitbc-coordinator-api.service` active in container + +### 4. Database Configuration Fix +Fixed database configuration issue that was preventing localhost production service from starting: +- Added missing `effective_url` property to `DatabaseConfig` class +- Fixed module path in systemd service file +- Installed missing dependency (`python-json-logger`) + +## Verification +Tested both production services: + +```bash +# Localhost health check +curl -s http://localhost:8000/v1/health +# Response: {"status": "ok", "env": "dev"} ✅ + +# AITBC Server health check +curl -s https://aitbc.bubuit.net/api/health +# Response: {"status": "ok", "env": "dev"} ✅ +``` + +## Service Configuration Differences + +### Before Cleanup +- **Localhost**: Mock service + broken production service +- **AITBC Server**: Mock service + working production service + +### After Cleanup +- **Localhost**: Working production service only +- **AITBC Server**: Working production service only + +## Impact +- **Clarity**: Clear separation between development and production environments +- **Reliability**: Production requests no longer risk hitting mock endpoints +- **Maintenance**: Reduced service footprint and complexity +- **Performance**: Eliminated redundant services + +## Lessons Learned +1. **Service Hygiene**: Always clean up mock/test services before production deployment +2. **Documentation**: Keep accurate inventory of running services +3. **Configuration**: Ensure production services have correct paths and dependencies +4. **Verification**: Test both environments after configuration changes + +## Current Service Status + +### Localhost Services +- ✅ `aitbc-coordinator-api.service` - Production API (active) +- ❌ `aitbc-mock-coordinator.service` - Mock API (removed) + +### AITBC Server Services +- ✅ `aitbc-coordinator-api.service` - Production API (active) +- ❌ `aitbc-coordinator.service` - Mock API (removed) + +## Related Documentation +- [Infrastructure Documentation](/docs/infrastructure.md) +- [Service Management Guidelines](/docs/operations/service-management.md) +- [Development vs Production Environments](/docs/development/environments.md) diff --git a/docs/issues/web-vitals-422-error-2026-02-16.md b/docs/issues/web-vitals-422-error-2026-02-16.md new file mode 100644 index 00000000..8c8c9fbb --- /dev/null +++ b/docs/issues/web-vitals-422-error-2026-02-16.md @@ -0,0 +1,92 @@ +# Web Vitals 422 Error - RESOLVED + +**Date:** February 16, 2026 +**Status:** Resolved +**Severity:** Medium + +## Issue Description +The `/api/web-vitals` endpoint was returning 422 Unprocessable Content errors when receiving performance metrics from the frontend. This prevented the collection of important web performance data. + +## Affected Components +- **Backend**: `/apps/coordinator-api/src/app/routers/web_vitals.py` - API schema +- **Frontend**: `/website/assets/js/web-vitals.js` - Metrics collection script +- **Endpoint**: `/api/web-vitals` - POST endpoint for performance metrics + +## Root Cause Analysis +The `WebVitalsEntry` Pydantic model in the backend only included three fields: +- `name` (required) +- `startTime` (optional) +- `duration` (optional) + +However, the browser's Web Vitals library was sending additional fields for certain metrics: +- `value` - For CLS (Cumulative Layout Shift) metrics +- `hadRecentInput` - For CLS metrics to distinguish user-initiated shifts + +When these extra fields were included in the JSON payload, Pydantic validation failed with a 422 error. + +## Solution Implemented + +### 1. Schema Enhancement +Updated the `WebVitalsEntry` model to include the missing optional fields: + +```python +class WebVitalsEntry(BaseModel): + name: str + startTime: Optional[float] = None + duration: Optional[float] = None + value: Optional[float] = None # Added + hadRecentInput: Optional[bool] = None # Added +``` + +### 2. Defensive Processing +Added filtering logic to handle any unexpected fields that might be sent in the future: + +```python +# 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) +``` + +### 3. Deployment +- Deployed changes to both localhost and AITBC server +- Restarted coordinator-api service on both systems +- Verified functionality with test requests + +## Verification +Tested the fix with various Web Vitals payloads: + +```bash +# Test with CLS metric (includes extra fields) +curl -X POST https://aitbc.bubuit.net/api/web-vitals \ + -H "Content-Type: application/json" \ + -d '{"name":"CLS","value":0.1,"id":"cls","delta":0.05,"entries":[{"name":"layout-shift","startTime":100,"duration":0,"value":0.1,"hadRecentInput":false}],"url":"https://aitbc.bubuit.net/","timestamp":"2026-02-16T20:00:00Z"}' + +# Result: 200 OK ✅ +``` + +## Impact +- **Before**: Web Vitals metrics collection was failing completely +- **After**: All Web Vitals metrics are now successfully collected and logged +- **Performance**: No performance impact on the API endpoint +- **Compatibility**: Backward compatible with existing frontend code + +## Lessons Learned +1. **Schema Mismatch**: Always ensure backend schemas match frontend payloads exactly +2. **Optional Fields**: Web APIs often evolve with additional optional fields +3. **Defensive Programming**: Filter unknown fields to prevent future validation errors +4. **Testing**: Test with real frontend payloads, not just ideal ones + +## Related Documentation +- [Web Vitals Documentation](https://web.dev/vitals/) +- [Pydantic Validation](https://pydantic-docs.helpmanual.io/) +- [FastAPI Error Handling](https://fastapi.tiangolo.com/tutorial/handling-errors/) diff --git a/tests/cli/test_exchange.py b/tests/cli/test_exchange.py new file mode 100644 index 00000000..4f812a98 --- /dev/null +++ b/tests/cli/test_exchange.py @@ -0,0 +1,595 @@ +"""Tests for exchange CLI commands""" + +import pytest +import json +import time +from click.testing import CliRunner +from unittest.mock import Mock, patch +from aitbc_cli.commands.exchange import exchange + + +@pytest.fixture +def runner(): + """Create CLI runner""" + return CliRunner() + + +@pytest.fixture +def mock_config(): + """Mock configuration""" + config = Mock() + config.coordinator_url = "http://test:8000" + config.api_key = "test_api_key" + return config + + +class TestExchangeRatesCommand: + """Test exchange rates command""" + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_rates_success(self, mock_client_class, runner, mock_config): + """Test successful exchange rates retrieval""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "btc_to_aitbc": 100000, + "aitbc_to_btc": 0.00001, + "fee_percent": 0.5 + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, ['rates'], + obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + # Extract JSON from output + import re + clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output) + lines = clean_output.strip().split('\n') + + # Find JSON part + json_lines = [] + in_json = False + for line in lines: + stripped = line.strip() + if stripped.startswith('{'): + in_json = True + json_lines.append(stripped) + elif in_json: + json_lines.append(stripped) + if stripped.endswith('}'): + break + + json_str = '\n'.join(json_lines) + assert json_str, "No JSON found in output" + data = json.loads(json_str) + assert data['btc_to_aitbc'] == 100000 + assert data['aitbc_to_btc'] == 0.00001 + assert data['fee_percent'] == 0.5 + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/exchange/rates', + timeout=10 + ) + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_rates_api_error(self, mock_client_class, runner, mock_config): + """Test exchange rates with API error""" + # Setup mock for error response + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 500 + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, ['rates'], + obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Failed to get exchange rates: 500' in result.output + + +class TestExchangeCreatePaymentCommand: + """Test exchange create-payment command""" + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_create_payment_with_aitbc_amount(self, mock_client_class, runner, mock_config): + """Test creating payment with AITBC amount""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + + # Mock rates response + rates_response = Mock() + rates_response.status_code = 200 + rates_response.json.return_value = { + "btc_to_aitbc": 100000, + "aitbc_to_btc": 0.00001, + "fee_percent": 0.5 + } + + # Mock payment creation response + payment_response = Mock() + payment_response.status_code = 200 + payment_response.json.return_value = { + "payment_id": "pay_123456", + "user_id": "cli_user", + "aitbc_amount": 1000, + "btc_amount": 0.01, + "payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "status": "pending", + "created_at": int(time.time()), + "expires_at": int(time.time()) + 3600 + } + + mock_client.get.return_value = rates_response + mock_client.post.return_value = payment_response + + # Run command + result = runner.invoke(exchange, [ + 'create-payment', + '--aitbc-amount', '1000', + '--user-id', 'test_user', + '--notes', 'Test payment' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Payment created: pay_123456' in result.output + assert 'Send 0.01000000 BTC to:' in result.output + + # Verify API calls + assert mock_client.get.call_count == 1 # Get rates + assert mock_client.post.call_count == 1 # Create payment + + # Check payment creation call + payment_call = mock_client.post.call_args + assert payment_call[0][0] == 'http://test:8000/v1/exchange/create-payment' + payment_data = payment_call[1]['json'] + assert payment_data['user_id'] == 'test_user' + assert payment_data['aitbc_amount'] == 1000 + assert payment_data['btc_amount'] == 0.01 + assert payment_data['notes'] == 'Test payment' + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_create_payment_with_btc_amount(self, mock_client_class, runner, mock_config): + """Test creating payment with BTC amount""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + + # Mock rates response + rates_response = Mock() + rates_response.status_code = 200 + rates_response.json.return_value = { + "btc_to_aitbc": 100000, + "aitbc_to_btc": 0.00001, + "fee_percent": 0.5 + } + + # Mock payment creation response + payment_response = Mock() + payment_response.status_code = 200 + payment_response.json.return_value = { + "payment_id": "pay_789012", + "user_id": "cli_user", + "aitbc_amount": 500, + "btc_amount": 0.005, + "payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "status": "pending", + "created_at": int(time.time()), + "expires_at": int(time.time()) + 3600 + } + + mock_client.get.return_value = rates_response + mock_client.post.return_value = payment_response + + # Run command + result = runner.invoke(exchange, [ + 'create-payment', + '--btc-amount', '0.005' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Payment created: pay_789012' in result.output + + # Check payment data + payment_call = mock_client.post.call_args + payment_data = payment_call[1]['json'] + assert payment_data['aitbc_amount'] == 500 + assert payment_data['btc_amount'] == 0.005 + + def test_create_payment_no_amount(self, runner, mock_config): + """Test creating payment without specifying amount""" + # Run command without amount + result = runner.invoke(exchange, ['create-payment'], + obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Either --aitbc-amount or --btc-amount must be specified' in result.output + + def test_create_payment_invalid_aitbc_amount(self, runner, mock_config): + """Test creating payment with invalid AITBC amount""" + # Run command with invalid amount + result = runner.invoke(exchange, [ + 'create-payment', + '--aitbc-amount', '0' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'AITBC amount must be greater than 0' in result.output + + def test_create_payment_invalid_btc_amount(self, runner, mock_config): + """Test creating payment with invalid BTC amount""" + # Run command with invalid amount + result = runner.invoke(exchange, [ + 'create-payment', + '--btc-amount', '-0.01' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'BTC amount must be greater than 0' in result.output + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_create_payment_rates_error(self, mock_client_class, runner, mock_config): + """Test creating payment when rates API fails""" + # Setup mock for rates error + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + rates_response = Mock() + rates_response.status_code = 500 + mock_client.get.return_value = rates_response + + # Run command + result = runner.invoke(exchange, [ + 'create-payment', + '--aitbc-amount', '1000' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Failed to get exchange rates' in result.output + + +class TestExchangePaymentStatusCommand: + """Test exchange payment-status command""" + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_payment_status_pending(self, mock_client_class, runner, mock_config): + """Test checking pending payment status""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "payment_id": "pay_123456", + "user_id": "test_user", + "aitbc_amount": 1000, + "btc_amount": 0.01, + "payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "status": "pending", + "created_at": int(time.time()), + "expires_at": int(time.time()) + 3600, + "confirmations": 0 + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, [ + 'payment-status', + '--payment-id', 'pay_123456' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Payment pay_123456 is pending confirmation' in result.output + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/exchange/payment-status/pay_123456', + timeout=10 + ) + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_payment_status_confirmed(self, mock_client_class, runner, mock_config): + """Test checking confirmed payment status""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "payment_id": "pay_123456", + "user_id": "test_user", + "aitbc_amount": 1000, + "btc_amount": 0.01, + "payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "status": "confirmed", + "created_at": int(time.time()), + "expires_at": int(time.time()) + 3600, + "confirmations": 1, + "confirmed_at": int(time.time()) + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, [ + 'payment-status', + '--payment-id', 'pay_123456' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Payment pay_123456 is confirmed!' in result.output + assert 'AITBC amount: 1000' in result.output + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_payment_status_expired(self, mock_client_class, runner, mock_config): + """Test checking expired payment status""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "payment_id": "pay_123456", + "user_id": "test_user", + "aitbc_amount": 1000, + "btc_amount": 0.01, + "payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "status": "expired", + "created_at": int(time.time()), + "expires_at": int(time.time()) - 3600, # Expired + "confirmations": 0 + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, [ + 'payment-status', + '--payment-id', 'pay_123456' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Payment pay_123456 has expired' in result.output + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_payment_status_not_found(self, mock_client_class, runner, mock_config): + """Test checking status for non-existent payment""" + # Setup mock for 404 response + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 404 + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, [ + 'payment-status', + '--payment-id', 'nonexistent' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Failed to get payment status: 404' in result.output + + +class TestExchangeMarketStatsCommand: + """Test exchange market-stats command""" + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_market_stats_success(self, mock_client_class, runner, mock_config): + """Test successful market stats retrieval""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "price": 0.00001, + "price_change_24h": 5.2, + "daily_volume": 50000, + "daily_volume_btc": 0.5, + "total_payments": 10, + "pending_payments": 2 + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, ['market-stats'], + obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Exchange market statistics:' in result.output + + # Extract and verify JSON + import re + clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output) + lines = clean_output.strip().split('\n') + + json_lines = [] + in_json = False + for line in lines: + stripped = line.strip() + if stripped.startswith('{'): + in_json = True + json_lines.append(stripped) + elif in_json: + json_lines.append(stripped) + if stripped.endswith('}'): + break + + json_str = '\n'.join(json_lines) + assert json_str, "No JSON found in output" + data = json.loads(json_str) + assert data['price'] == 0.00001 + assert data['price_change_24h'] == 5.2 + assert data['daily_volume'] == 50000 + assert data['total_payments'] == 10 + assert data['pending_payments'] == 2 + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/exchange/market-stats', + timeout=10 + ) + + +class TestExchangeWalletCommands: + """Test exchange wallet commands""" + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_wallet_balance_success(self, mock_client_class, runner, mock_config): + """Test successful wallet balance retrieval""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "balance": 1.5, + "unconfirmed_balance": 0.1, + "total_received": 10.0, + "total_sent": 8.5 + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, ['wallet', 'balance'], + obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Bitcoin wallet balance:' in result.output + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/exchange/wallet/balance', + timeout=10 + ) + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_wallet_info_success(self, mock_client_class, runner, mock_config): + """Test successful wallet info retrieval""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "balance": 1.5, + "unconfirmed_balance": 0.1, + "total_received": 10.0, + "total_sent": 8.5, + "transactions": [], + "network": "testnet", + "block_height": 2500000 + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(exchange, ['wallet', 'info'], + obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Bitcoin wallet information:' in result.output + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/exchange/wallet/info', + timeout=10 + ) + + +class TestExchangeIntegration: + """Test exchange integration workflows""" + + @patch('aitbc_cli.commands.exchange.httpx.Client') + def test_complete_exchange_workflow(self, mock_client_class, runner, mock_config): + """Test complete exchange workflow: rates → create payment → check status""" + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + + # Step 1: Get rates + rates_response = Mock() + rates_response.status_code = 200 + rates_response.json.return_value = { + "btc_to_aitbc": 100000, + "aitbc_to_btc": 0.00001, + "fee_percent": 0.5 + } + + # Step 2: Create payment + payment_response = Mock() + payment_response.status_code = 200 + payment_response.json.return_value = { + "payment_id": "pay_workflow_123", + "user_id": "cli_user", + "aitbc_amount": 1000, + "btc_amount": 0.01, + "payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "status": "pending", + "created_at": int(time.time()), + "expires_at": int(time.time()) + 3600 + } + + # Step 3: Check payment status + status_response = Mock() + status_response.status_code = 200 + status_response.json.return_value = { + "payment_id": "pay_workflow_123", + "user_id": "cli_user", + "aitbc_amount": 1000, + "btc_amount": 0.01, + "payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "status": "pending", + "created_at": int(time.time()), + "expires_at": int(time.time()) + 3600, + "confirmations": 0 + } + + # Configure mock to return different responses for different calls + mock_client.get.side_effect = [rates_response, status_response] + mock_client.post.return_value = payment_response + + # Execute workflow + # Get rates + result1 = runner.invoke(exchange, ['rates'], + obj={'config': mock_config, 'output_format': 'json'}) + assert result1.exit_code == 0 + + # Create payment + result2 = runner.invoke(exchange, [ + 'create-payment', + '--aitbc-amount', '1000' + ], obj={'config': mock_config, 'output_format': 'json'}) + assert result2.exit_code == 0 + + # Check payment status + result3 = runner.invoke(exchange, [ + 'payment-status', + '--payment-id', 'pay_workflow_123' + ], obj={'config': mock_config, 'output_format': 'json'}) + assert result3.exit_code == 0 + + # Verify all API calls were made + assert mock_client.get.call_count == 3 # rates (standalone) + rates (create-payment) + payment status + assert mock_client.post.call_count == 1 # create payment diff --git a/tests/cli/test_marketplace_bids.py b/tests/cli/test_marketplace_bids.py new file mode 100644 index 00000000..646e126f --- /dev/null +++ b/tests/cli/test_marketplace_bids.py @@ -0,0 +1,497 @@ +"""Tests for marketplace bid CLI commands""" + +import pytest +import json +from click.testing import CliRunner +from unittest.mock import Mock, patch +from aitbc_cli.commands.marketplace import marketplace + + +@pytest.fixture +def runner(): + """Create CLI runner""" + return CliRunner() + + +@pytest.fixture +def mock_config(): + """Mock configuration""" + config = Mock() + config.coordinator_url = "http://test:8000" + config.api_key = "test_api_key" + return config + + +class TestMarketplaceBidCommands: + """Test marketplace bid command group""" + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_submit_success(self, mock_client_class, runner, mock_config): + """Test successful bid submission""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 202 + mock_response.json.return_value = { + "id": "bid123", + "status": "pending" + } + mock_client.post.return_value = mock_response + + # Run command + result = runner.invoke(marketplace, [ + 'bid', + 'submit', + '--provider', 'miner123', + '--capacity', '100', + '--price', '0.05', + '--notes', 'Need GPU capacity for AI training' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + # Extract JSON from output (success message + JSON) + # Remove ANSI escape codes and extract JSON part + import re + clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output) + lines = clean_output.strip().split('\n') + + # Find JSON part (multiline JSON with ANSI codes removed) + json_lines = [] + in_json = False + for line in lines: + stripped = line.strip() + if stripped.startswith('{'): + in_json = True + json_lines.append(stripped) + elif in_json: + json_lines.append(stripped) + if stripped.endswith('}'): + break + + json_str = '\n'.join(json_lines) + assert json_str, "No JSON found in output" + data = json.loads(json_str) + assert data['id'] == 'bid123' + + # Verify API call + mock_client.post.assert_called_once_with( + 'http://test:8000/v1/marketplace/bids', + json={ + "provider": "miner123", + "capacity": 100, + "price": 0.05, + "notes": "Need GPU capacity for AI training" + }, + headers={ + "Content-Type": "application/json", + "X-Api-Key": "test_api_key" + } + ) + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_submit_validation_error(self, mock_client_class, runner, mock_config): + """Test bid submission with invalid capacity""" + # Run command with invalid capacity + result = runner.invoke(marketplace, [ + 'bid', + 'submit', + '--provider', 'miner123', + '--capacity', '0', # Invalid: must be > 0 + '--price', '0.05' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Capacity must be greater than 0' in result.output + + # Verify no API call was made + mock_client_class.assert_not_called() + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_submit_price_validation_error(self, mock_client_class, runner, mock_config): + """Test bid submission with invalid price""" + # Run command with invalid price + result = runner.invoke(marketplace, [ + 'bid', + 'submit', + '--provider', 'miner123', + '--capacity', '100', + '--price', '-0.05' # Invalid: must be > 0 + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Price must be greater than 0' in result.output + + # Verify no API call was made + mock_client_class.assert_not_called() + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_submit_api_error(self, mock_client_class, runner, mock_config): + """Test bid submission with API error""" + # Setup mock for error response + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Invalid provider" + mock_client.post.return_value = mock_response + + # Run command + result = runner.invoke(marketplace, [ + 'bid', + 'submit', + '--provider', 'invalid_provider', + '--capacity', '100', + '--price', '0.05' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Failed to submit bid: 400' in result.output + assert 'Invalid provider' in result.output + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_list_all(self, mock_client_class, runner, mock_config): + """Test listing all bids""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "bids": [ + { + "id": "bid1", + "provider": "miner1", + "capacity": 100, + "price": 0.05, + "status": "pending", + "submitted_at": "2024-01-01T00:00:00" + }, + { + "id": "bid2", + "provider": "miner2", + "capacity": 50, + "price": 0.03, + "status": "accepted", + "submitted_at": "2024-01-01T01:00:00" + } + ] + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(marketplace, [ + 'bid', + 'list' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data['bids']) == 2 + assert data['bids'][0]['provider'] == 'miner1' + assert data['bids'][0]['status'] == 'pending' + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/marketplace/bids', + params={"limit": 20}, + headers={"X-Api-Key": "test_api_key"} + ) + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_list_with_filters(self, mock_client_class, runner, mock_config): + """Test listing bids with filters""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "bids": [ + { + "id": "bid1", + "provider": "miner123", + "capacity": 100, + "price": 0.05, + "status": "pending", + "submitted_at": "2024-01-01T00:00:00" + } + ] + } + mock_client.get.return_value = mock_response + + # Run command with filters + result = runner.invoke(marketplace, [ + 'bid', + 'list', + '--status', 'pending', + '--provider', 'miner123', + '--limit', '10' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + + # Verify API call with filters + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + assert call_args[1]['params']['status'] == 'pending' + assert call_args[1]['params']['provider'] == 'miner123' + assert call_args[1]['params']['limit'] == 10 + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_details(self, mock_client_class, runner, mock_config): + """Test getting bid details""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "bid123", + "provider": "miner123", + "capacity": 100, + "price": 0.05, + "notes": "Need GPU capacity for AI training", + "status": "pending", + "submitted_at": "2024-01-01T00:00:00" + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(marketplace, [ + 'bid', + 'details', + 'bid123' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + data = json.loads(result.output) + assert data['id'] == 'bid123' + assert data['provider'] == 'miner123' + assert data['capacity'] == 100 + assert data['price'] == 0.05 + assert data['notes'] == 'Need GPU capacity for AI training' + assert data['status'] == 'pending' + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/marketplace/bids/bid123', + headers={"X-Api-Key": "test_api_key"} + ) + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_bid_details_not_found(self, mock_client_class, runner, mock_config): + """Test getting details for non-existent bid""" + # Setup mock for 404 response + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 404 + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(marketplace, [ + 'bid', + 'details', + 'nonexistent' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + assert 'Bid not found: 404' in result.output + + +class TestMarketplaceOffersCommands: + """Test marketplace offers command group""" + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_offers_list_all(self, mock_client_class, runner, mock_config): + """Test listing all offers""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "offers": [ + { + "id": "offer1", + "provider": "miner1", + "capacity": 200, + "price": 0.10, + "status": "open", + "gpu_model": "RTX4090", + "gpu_memory_gb": 24, + "region": "us-west" + }, + { + "id": "offer2", + "provider": "miner2", + "capacity": 100, + "price": 0.08, + "status": "reserved", + "gpu_model": "RTX3080", + "gpu_memory_gb": 10, + "region": "us-east" + } + ] + } + mock_client.get.return_value = mock_response + + # Run command + result = runner.invoke(marketplace, [ + 'offers', + 'list' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data['offers']) == 2 + assert data['offers'][0]['gpu_model'] == 'RTX4090' + assert data['offers'][0]['status'] == 'open' + + # Verify API call + mock_client.get.assert_called_once_with( + 'http://test:8000/v1/marketplace/offers', + params={"limit": 20}, + headers={"X-Api-Key": "test_api_key"} + ) + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_offers_list_with_filters(self, mock_client_class, runner, mock_config): + """Test listing offers with filters""" + # Setup mock + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "offers": [ + { + "id": "offer1", + "provider": "miner1", + "capacity": 200, + "price": 0.10, + "status": "open", + "gpu_model": "RTX4090", + "gpu_memory_gb": 24, + "region": "us-west" + } + ] + } + mock_client.get.return_value = mock_response + + # Run command with filters + result = runner.invoke(marketplace, [ + 'offers', + 'list', + '--status', 'open', + '--gpu-model', 'RTX4090', + '--price-max', '0.15', + '--memory-min', '16', + '--region', 'us-west', + '--limit', '10' + ], obj={'config': mock_config, 'output_format': 'json'}) + + # Assertions + assert result.exit_code == 0 + + # Verify API call with filters + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + params = call_args[1]['params'] + assert params['status'] == 'open' + assert params['gpu_model'] == 'RTX4090' + assert params['price_max'] == 0.15 + assert params['memory_min'] == 16 + assert params['region'] == 'us-west' + assert params['limit'] == 10 + + +class TestMarketplaceBidIntegration: + """Test marketplace bid integration workflows""" + + @patch('aitbc_cli.commands.marketplace.httpx.Client') + def test_complete_bid_workflow(self, mock_client_class, runner, mock_config): + """Test complete workflow: list offers -> submit bid -> track status""" + mock_client = Mock() + mock_client_class.return_value.__enter__.return_value = mock_client + + # Step 1: List offers + offers_response = Mock() + offers_response.status_code = 200 + offers_response.json.return_value = { + "offers": [ + { + "id": "offer1", + "provider": "miner1", + "capacity": 200, + "price": 0.10, + "status": "open", + "gpu_model": "RTX4090" + } + ] + } + + # Step 2: Submit bid + bid_response = Mock() + bid_response.status_code = 202 + bid_response.json.return_value = { + "id": "bid123", + "status": "pending" + } + + # Step 3: Get bid details + bid_details_response = Mock() + bid_details_response.status_code = 200 + bid_details_response.json.return_value = { + "id": "bid123", + "provider": "miner123", + "capacity": 100, + "price": 0.05, + "status": "pending", + "submitted_at": "2024-01-01T00:00:00" + } + + # Configure mock to return different responses for different calls + mock_client.get.side_effect = [offers_response, bid_details_response] + mock_client.post.return_value = bid_response + + # Execute workflow + # List offers + result1 = runner.invoke(marketplace, [ + 'offers', + 'list', + '--status', 'open' + ], obj={'config': mock_config, 'output_format': 'json'}) + assert result1.exit_code == 0 + + # Submit bid + result2 = runner.invoke(marketplace, [ + 'bid', + 'submit', + '--provider', 'miner123', + '--capacity', '100', + '--price', '0.05' + ], obj={'config': mock_config, 'output_format': 'json'}) + assert result2.exit_code == 0 + + # Check bid details + result3 = runner.invoke(marketplace, [ + 'bid', + 'details', + 'bid123' + ], obj={'config': mock_config, 'output_format': 'json'}) + assert result3.exit_code == 0 + + # Verify all API calls were made + assert mock_client.get.call_count == 2 + assert mock_client.post.call_count == 1