Files
aitbc/cli/aitbc_cli/commands/exchange_island.py
aitbc e60aa70da9
Some checks failed
CLI Tests / test-cli (push) Failing after 2s
Security Scanning / security-scan (push) Failing after 14m33s
Multi-Node Blockchain Health Monitoring / health-check (push) Has been cancelled
feat: migrate exchange_island CLI buy command to use centralized aitbc package HTTP client
- Replace httpx.Client with aitbc.AITBCHTTPClient in buy command
- Remove async context manager in favor of direct AITBCHTTPClient usage
- Replace status code checks with NetworkError exception handling
- Remove httpx import (no longer needed)
- Simplify error handling with separate NetworkError and generic Exception catches
2026-04-24 23:59:03 +02:00

554 lines
22 KiB
Python

"""
Exchange Island CLI Commands
Commands for trading AIT coin against BTC and ETH on the island exchange
"""
import click
import json
import hashlib
import socket
import os
from datetime import datetime
from decimal import Decimal
from typing import Optional
from ..utils import output, error, success, info, warning
from ..utils.island_credentials import (
load_island_credentials, get_rpc_endpoint, get_chain_id,
get_island_id, get_island_name
)
# Import shared modules
from aitbc.aitbc_logging import get_logger
from aitbc.http_client import AITBCHTTPClient
from aitbc.exceptions import NetworkError
# Initialize logger
logger = get_logger(__name__)
# Supported trading pairs
SUPPORTED_PAIRS = ['AIT/BTC', 'AIT/ETH']
@click.group()
def exchange_island():
"""Exchange commands for trading AIT against BTC and ETH on the island"""
pass
@exchange_island.command()
@click.argument('ait_amount', type=float)
@click.argument('quote_currency', type=click.Choice(['BTC', 'ETH']))
@click.option('--max-price', type=float, help='Maximum price to pay per AIT')
@click.pass_context
def buy(ctx, ait_amount: float, quote_currency: str, max_price: Optional[float]):
"""Buy AIT with BTC or ETH"""
try:
if ait_amount <= 0:
error("AIT amount must be greater than 0")
raise click.Abort()
# Load island credentials
credentials = load_island_credentials()
rpc_endpoint = get_rpc_endpoint()
chain_id = get_chain_id()
island_id = get_island_id()
# Get user node ID
hostname = socket.gethostname()
local_address = socket.gethostbyname(hostname)
p2p_port = credentials.get('credentials', {}).get('p2p_port', 8001)
# Get public key for node ID generation
keystore_path = '/var/lib/aitbc/keystore/validator_keys.json'
if os.path.exists(keystore_path):
with open(keystore_path, 'r') as f:
keys = json.load(f)
public_key_pem = None
for key_id, key_data in keys.items():
public_key_pem = key_data.get('public_key_pem')
break
if public_key_pem:
content = f"{hostname}:{local_address}:{p2p_port}:{public_key_pem}"
user_id = hashlib.sha256(content.encode()).hexdigest()
else:
error("No public key found in keystore")
raise click.Abort()
else:
error(f"Keystore not found at {keystore_path}")
raise click.Abort()
pair = f"AIT/{quote_currency}"
# Generate order ID
order_id = f"exchange_buy_{datetime.now().strftime('%Y%m%d%H%M%S')}_{hashlib.sha256(f'{user_id}{ait_amount}{quote_currency}'.encode()).hexdigest()[:8]}"
# Create buy order transaction
buy_order_data = {
'type': 'exchange',
'action': 'buy',
'order_id': order_id,
'user_id': user_id,
'pair': pair,
'side': 'buy',
'amount': float(ait_amount),
'max_price': float(max_price) if max_price else None,
'status': 'open',
'island_id': island_id,
'chain_id': chain_id,
'created_at': datetime.now().isoformat()
}
# Submit transaction to blockchain
try:
http_client = AITBCHTTPClient(base_url=rpc_endpoint, timeout=10)
result = http_client.post("/transaction", json=buy_order_data)
success(f"Buy order created successfully!")
success(f"Order ID: {order_id}")
success(f"Buying {ait_amount} AIT with {quote_currency}")
if max_price:
success(f"Max price: {max_price:.8f} {quote_currency}/AIT")
order_info = {
"Order ID": order_id,
"Pair": pair,
"Side": "BUY",
"Amount": f"{ait_amount} AIT",
"Max Price": f"{max_price:.8f} {quote_currency}/AIT" if max_price else "Market",
"Status": "open",
"User": user_id[:16] + "...",
"Island": island_id[:16] + "..."
}
output(order_info, ctx.obj.get('output_format', 'table'))
except NetworkError as e:
error(f"Network error submitting transaction: {e}")
raise click.Abort()
except Exception as e:
error(f"Error submitting transaction: {e}")
raise click.Abort()
except Exception as e:
error(f"Error creating buy order: {str(e)}")
raise click.Abort()
@exchange_island.command()
@click.argument('ait_amount', type=float)
@click.argument('quote_currency', type=click.Choice(['BTC', 'ETH']))
@click.option('--min-price', type=float, help='Minimum price to accept per AIT')
@click.pass_context
def sell(ctx, ait_amount: float, quote_currency: str, min_price: Optional[float]):
"""Sell AIT for BTC or ETH"""
try:
if ait_amount <= 0:
error("AIT amount must be greater than 0")
raise click.Abort()
# Load island credentials
credentials = load_island_credentials()
rpc_endpoint = get_rpc_endpoint()
chain_id = get_chain_id()
island_id = get_island_id()
# Get user node ID
hostname = socket.gethostname()
local_address = socket.gethostbyname(hostname)
p2p_port = credentials.get('credentials', {}).get('p2p_port', 8001)
# Get public key for node ID generation
keystore_path = '/var/lib/aitbc/keystore/validator_keys.json'
if os.path.exists(keystore_path):
with open(keystore_path, 'r') as f:
keys = json.load(f)
public_key_pem = None
for key_id, key_data in keys.items():
public_key_pem = key_data.get('public_key_pem')
break
if public_key_pem:
content = f"{hostname}:{local_address}:{p2p_port}:{public_key_pem}"
user_id = hashlib.sha256(content.encode()).hexdigest()
else:
error("No public key found in keystore")
raise click.Abort()
else:
error(f"Keystore not found at {keystore_path}")
raise click.Abort()
pair = f"AIT/{quote_currency}"
# Generate order ID
order_id = f"exchange_sell_{datetime.now().strftime('%Y%m%d%H%M%S')}_{hashlib.sha256(f'{user_id}{ait_amount}{quote_currency}'.encode()).hexdigest()[:8]}"
# Create sell order transaction
sell_order_data = {
'type': 'exchange',
'action': 'sell',
'order_id': order_id,
'user_id': user_id,
'pair': pair,
'side': 'sell',
'amount': float(ait_amount),
'min_price': float(min_price) if min_price else None,
'status': 'open',
'island_id': island_id,
'chain_id': chain_id,
'created_at': datetime.now().isoformat()
}
# Submit transaction to blockchain
try:
import httpx
with httpx.Client() as client:
response = client.post(
f"{rpc_endpoint}/transaction",
json=sell_order_data,
timeout=10
)
if response.status_code == 200:
result = response.json()
success(f"Sell order created successfully!")
success(f"Order ID: {order_id}")
success(f"Selling {ait_amount} AIT for {quote_currency}")
if min_price:
success(f"Min price: {min_price:.8f} {quote_currency}/AIT")
order_info = {
"Order ID": order_id,
"Pair": pair,
"Side": "SELL",
"Amount": f"{ait_amount} AIT",
"Min Price": f"{min_price:.8f} {quote_currency}/AIT" if min_price else "Market",
"Status": "open",
"User": user_id[:16] + "...",
"Island": island_id[:16] + "..."
}
output(order_info, ctx.obj.get('output_format', 'table'))
else:
error(f"Failed to submit transaction: {response.status_code}")
if response.text:
error(f"Error details: {response.text}")
raise click.Abort()
except Exception as e:
error(f"Network error submitting transaction: {e}")
raise click.Abort()
except Exception as e:
error(f"Error creating sell order: {str(e)}")
raise click.Abort()
@exchange_island.command()
@click.argument('pair', type=click.Choice(SUPPORTED_PAIRS))
@click.option('--limit', type=int, default=20, help='Order book depth')
@click.pass_context
def orderbook(ctx, pair: str, limit: int):
"""View the order book for a trading pair"""
try:
# Load island credentials
credentials = load_island_credentials()
rpc_endpoint = get_rpc_endpoint()
island_id = get_island_id()
# Query blockchain for exchange orders
try:
import httpx
params = {
'transaction_type': 'exchange',
'island_id': island_id,
'pair': pair,
'status': 'open',
'limit': limit * 2 # Get both buys and sells
}
with httpx.Client() as client:
response = client.get(
f"{rpc_endpoint}/transactions",
params=params,
timeout=10
)
if response.status_code == 200:
orders = response.json()
# Separate buy and sell orders
buy_orders = []
sell_orders = []
for order in orders:
if order.get('side') == 'buy':
buy_orders.append(order)
elif order.get('side') == 'sell':
sell_orders.append(order)
# Sort buy orders by price descending (highest first)
buy_orders.sort(key=lambda x: x.get('max_price', 0), reverse=True)
# Sort sell orders by price ascending (lowest first)
sell_orders.sort(key=lambda x: x.get('min_price', float('inf')))
if not buy_orders and not sell_orders:
info(f"No open orders for {pair}")
return
# Display sell orders (asks)
if sell_orders:
asks_data = []
for order in sell_orders[:limit]:
asks_data.append({
"Price": f"{order.get('min_price', 0):.8f}",
"Amount": f"{order.get('amount', 0):.4f} AIT",
"Total": f"{order.get('min_price', 0) * order.get('amount', 0):.8f} {pair.split('/')[1]}",
"User": order.get('user_id', '')[:16] + "...",
"Order": order.get('order_id', '')[:16] + "..."
})
output(asks_data, ctx.obj.get('output_format', 'table'), title=f"Sell Orders (Asks) - {pair}")
# Display buy orders (bids)
if buy_orders:
bids_data = []
for order in buy_orders[:limit]:
bids_data.append({
"Price": f"{order.get('max_price', 0):.8f}",
"Amount": f"{order.get('amount', 0):.4f} AIT",
"Total": f"{order.get('max_price', 0) * order.get('amount', 0):.8f} {pair.split('/')[1]}",
"User": order.get('user_id', '')[:16] + "...",
"Order": order.get('order_id', '')[:16] + "..."
})
output(bids_data, ctx.obj.get('output_format', 'table'), title=f"Buy Orders (Bids) - {pair}")
# Calculate spread if both exist
if sell_orders and buy_orders:
best_ask = sell_orders[0].get('min_price', 0)
best_bid = buy_orders[0].get('max_price', 0)
spread = best_ask - best_bid
if best_bid > 0:
spread_pct = (spread / best_bid) * 100
info(f"Spread: {spread:.8f} ({spread_pct:.4f}%)")
info(f"Best Bid: {best_bid:.8f} {pair.split('/')[1]}/AIT")
info(f"Best Ask: {best_ask:.8f} {pair.split('/')[1]}/AIT")
else:
error(f"Failed to query blockchain: {response.status_code}")
raise click.Abort()
except Exception as e:
error(f"Network error querying blockchain: {e}")
raise click.Abort()
except Exception as e:
error(f"Error viewing order book: {str(e)}")
raise click.Abort()
@exchange_island.command()
@click.pass_context
def rates(ctx):
"""View current exchange rates for AIT/BTC and AIT/ETH"""
try:
# Load island credentials
credentials = load_island_credentials()
rpc_endpoint = get_rpc_endpoint()
island_id = get_island_id()
# Query blockchain for exchange orders to calculate rates
try:
import httpx
rates_data = []
for pair in SUPPORTED_PAIRS:
params = {
'transaction_type': 'exchange',
'island_id': island_id,
'pair': pair,
'status': 'open',
'limit': 100
}
with httpx.Client() as client:
response = client.get(
f"{rpc_endpoint}/transactions",
params=params,
timeout=10
)
if response.status_code == 200:
orders = response.json()
# Calculate rates from order book
buy_orders = [o for o in orders if o.get('side') == 'buy']
sell_orders = [o for o in orders if o.get('side') == 'sell']
# Get best bid and ask
best_bid = max([o.get('max_price', 0) for o in buy_orders]) if buy_orders else 0
best_ask = min([o.get('min_price', float('inf')) for o in sell_orders]) if sell_orders else 0
# Calculate mid price
mid_price = (best_bid + best_ask) / 2 if best_bid > 0 and best_ask < float('inf') else 0
rates_data.append({
"Pair": pair,
"Best Bid": f"{best_bid:.8f}" if best_bid > 0 else "N/A",
"Best Ask": f"{best_ask:.8f}" if best_ask < float('inf') else "N/A",
"Mid Price": f"{mid_price:.8f}" if mid_price > 0 else "N/A",
"Buy Orders": len(buy_orders),
"Sell Orders": len(sell_orders)
})
else:
rates_data.append({
"Pair": pair,
"Best Bid": "Error",
"Best Ask": "Error",
"Mid Price": "Error",
"Buy Orders": 0,
"Sell Orders": 0
})
output(rates_data, ctx.obj.get('output_format', 'table'), title="Exchange Rates")
except Exception as e:
error(f"Network error querying blockchain: {e}")
raise click.Abort()
except Exception as e:
error(f"Error viewing exchange rates: {str(e)}")
raise click.Abort()
@exchange_island.command()
@click.option('--user', help='Filter by user ID')
@click.option('--status', help='Filter by status (open, filled, partially_filled, cancelled)')
@click.option('--pair', type=click.Choice(SUPPORTED_PAIRS), help='Filter by trading pair')
@click.pass_context
def orders(ctx, user: Optional[str], status: Optional[str], pair: Optional[str]):
"""List exchange orders"""
try:
# Load island credentials
credentials = load_island_credentials()
rpc_endpoint = get_rpc_endpoint()
island_id = get_island_id()
# Query blockchain for exchange orders
try:
import httpx
params = {
'transaction_type': 'exchange',
'island_id': island_id
}
if user:
params['user_id'] = user
if status:
params['status'] = status
if pair:
params['pair'] = pair
with httpx.Client() as client:
response = client.get(
f"{rpc_endpoint}/transactions",
params=params,
timeout=10
)
if response.status_code == 200:
orders = response.json()
if not orders:
info("No exchange orders found")
return
# Format output
orders_data = []
for order in orders:
orders_data.append({
"Order ID": order.get('order_id', '')[:20] + "...",
"Pair": order.get('pair'),
"Side": order.get('side', '').upper(),
"Amount": f"{order.get('amount', 0):.4f} AIT",
"Price": f"{order.get('max_price', order.get('min_price', 0)):.8f}" if order.get('max_price') or order.get('min_price') else "Market",
"Status": order.get('status'),
"User": order.get('user_id', '')[:16] + "...",
"Created": order.get('created_at', '')[:19]
})
output(orders_data, ctx.obj.get('output_format', 'table'), title=f"Exchange Orders ({island_id[:16]}...)")
else:
error(f"Failed to query blockchain: {response.status_code}")
raise click.Abort()
except Exception as e:
error(f"Network error querying blockchain: {e}")
raise click.Abort()
except Exception as e:
error(f"Error listing orders: {str(e)}")
raise click.Abort()
@exchange_island.command()
@click.argument('order_id')
@click.pass_context
def cancel(ctx, order_id: str):
"""Cancel an exchange order"""
try:
# Load island credentials
credentials = load_island_credentials()
rpc_endpoint = get_rpc_endpoint()
chain_id = get_chain_id()
island_id = get_island_id()
# Get local node ID
hostname = socket.gethostname()
local_address = socket.gethostbyname(hostname)
p2p_port = credentials.get('credentials', {}).get('p2p_port', 8001)
keystore_path = '/var/lib/aitbc/keystore/validator_keys.json'
if os.path.exists(keystore_path):
with open(keystore_path, 'r') as f:
keys = json.load(f)
public_key_pem = None
for key_id, key_data in keys.items():
public_key_pem = key_data.get('public_key_pem')
break
if public_key_pem:
content = f"{hostname}:{local_address}:{p2p_port}:{public_key_pem}"
local_node_id = hashlib.sha256(content.encode()).hexdigest()
# Create cancel transaction
cancel_data = {
'type': 'exchange',
'action': 'cancel',
'order_id': order_id,
'user_id': local_node_id,
'status': 'cancelled',
'cancelled_at': datetime.now().isoformat(),
'island_id': island_id,
'chain_id': chain_id
}
# Submit transaction to blockchain
try:
import httpx
with httpx.Client() as client:
response = client.post(
f"{rpc_endpoint}/transaction",
json=cancel_data,
timeout=10
)
if response.status_code == 200:
success(f"Order {order_id} cancelled successfully!")
else:
error(f"Failed to cancel order: {response.status_code}")
if response.text:
error(f"Error details: {response.text}")
raise click.Abort()
except Exception as e:
error(f"Network error submitting transaction: {e}")
raise click.Abort()
except Exception as e:
error(f"Error cancelling order: {str(e)}")
raise click.Abort()