Files
aitbc/cli/src/aitbc_cli/commands/transactions.py
aitbc a7b6e39cdf fix: replace all print() with click.echo() or logger in CLI production code
- 55 CLI files: handlers/, aitbc_cli/commands/, cli/core/, cli/utils/, top-level scripts
- Click-based files: print() -> click.echo()
- Library modules: print() -> logger.info/error/warning
- Fixed pre-existing indentation bugs in monitor.py dashboard function
- Fixed bare print() -> logger.info('') in chain_manager.py
- 0 remaining print() in production CLI code
- All files compile cleanly
2026-05-25 13:53:49 +02:00

274 lines
9.3 KiB
Python

"""
Transaction commands for AITBC CLI
"""
import json
from pathlib import Path
from typing import Optional, Dict, Any, List
import click
from ..utils import error, success
from ..utils.wallet import decrypt_private_key
from aitbc import AITBCHTTPClient, NetworkError, KEYSTORE_DIR, get_logger
from aitbc.exceptions import ValidationError
from aitbc.utils.validation import validate_address
from cryptography.hazmat.primitives.asymmetric import ed25519
logger = get_logger(__name__)
DEFAULT_RPC_URL = "http://localhost:8006"
DEFAULT_KEYSTORE_DIR = KEYSTORE_DIR
@click.group()
def transactions():
"""Transaction management commands"""
pass
def _send_transaction_impl(from_wallet: str, to_address: str, amount: float, fee: float,
password: str, keystore_dir: Path = DEFAULT_KEYSTORE_DIR,
rpc_url: str = DEFAULT_RPC_URL) -> Optional[str]:
"""Send transaction from one wallet to another"""
# Validate recipient address
try:
validate_address(to_address)
except ValidationError as e:
logger.error(f"Invalid recipient address: {e}")
error(f"Invalid recipient address: {e}")
return None
# Validate amount
if amount <= 0:
logger.error(f"Invalid amount: {amount} must be positive")
error("Amount must be positive")
return None
# Ensure keystore_dir is a Path object
if keystore_dir is None:
keystore_dir = DEFAULT_KEYSTORE_DIR
if isinstance(keystore_dir, str):
keystore_dir = Path(keystore_dir)
# Get sender wallet info
sender_keystore = keystore_dir / f"{from_wallet}.json"
if not sender_keystore.exists():
error(f"Wallet '{from_wallet}' not found")
return None
with open(sender_keystore) as f:
sender_data = json.load(f)
sender_address = sender_data['address']
# Decrypt private key
try:
private_key_hex = decrypt_private_key(sender_keystore, password)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(bytes.fromhex(private_key_hex))
except Exception as e:
error(f"Error decrypting wallet: {e}")
return None
# Get chain_id from RPC health endpoint or use override
from ..utils.chain_id import get_chain_id
chain_id = get_chain_id(rpc_url, override=None, timeout=5)
# Get actual nonce from blockchain
actual_nonce = 0
try:
http_client = AITBCHTTPClient(base_url=rpc_url, timeout=5)
account_data = http_client.get(f"/rpc/account/{sender_address}")
actual_nonce = account_data.get("nonce", 0)
except NetworkError:
actual_nonce = 0
except Exception:
actual_nonce = 0
# Create transaction
transaction = {
"type": "TRANSFER",
"chain_id": chain_id,
"from": sender_address,
"nonce": actual_nonce,
"fee": int(fee),
"payload": {
"recipient": to_address,
"amount": int(amount)
}
}
# Sign transaction
message = json.dumps(transaction, sort_keys=True).encode()
signature = private_key.sign(message)
transaction["signature"] = signature.hex()
# Submit to blockchain
try:
http_client = AITBCHTTPClient(base_url=rpc_url, timeout=30)
result = http_client.post("/rpc/transaction", json=transaction)
tx_hash = result.get("transaction_hash")
success(f"Transaction submitted: {tx_hash}")
logger.info(f"Transaction submitted: {tx_hash} from {from_wallet} to {to_address}")
return tx_hash
except NetworkError as e:
logger.error(f"Network error submitting transaction: {e}")
error(f"Error submitting transaction: {e}")
return None
except Exception as e:
logger.error(f"Error submitting transaction: {e}")
error(f"Error: {e}")
return None
@transactions.command()
@click.option('--from', 'from_wallet', required=True, help='From wallet name')
@click.option('--to', 'to_address', required=True, help='To address')
@click.option('--amount', type=float, required=True, help='Amount to send')
@click.option('--fee', type=float, default=0.001, help='Transaction fee')
@click.option('--password', help='Wallet password')
@click.option('--password-file', help='File containing wallet password')
@click.option('--rpc-url', help='Blockchain RPC URL')
def send(from_wallet: str, to_address: str, amount: float, fee: float, password: Optional[str], password_file: Optional[str], rpc_url: Optional[str]):
"""Send transaction from one wallet to another"""
if password_file:
with open(password_file) as f:
password = f.read().strip()
elif not password:
import getpass
password = getpass.getpass("Enter wallet password: ")
if not rpc_url:
rpc_url = DEFAULT_RPC_URL
tx_hash = _send_transaction_impl(from_wallet, to_address, amount, fee, password, rpc_url=rpc_url)
if tx_hash:
success(f"Transaction sent: {tx_hash}")
@transactions.command()
@click.option('--transactions-file', required=True, help='JSON file with batch transactions')
@click.option('--password', help='Wallet password')
@click.option('--password-file', help='File containing wallet password')
@click.option('--rpc-url', help='Blockchain RPC URL')
def batch(transactions_file: str, password: Optional[str], password_file: Optional[str], rpc_url: Optional[str]):
"""Send batch transactions"""
if password_file:
with open(password_file) as f:
password = f.read().strip()
elif not password:
import getpass
password = getpass.getpass("Enter wallet password: ")
if not rpc_url:
rpc_url = DEFAULT_RPC_URL
with open(transactions_file) as f:
transactions_data = json.load(f)
results = []
for tx in transactions_data:
try:
tx_hash = _send_transaction_impl(
tx['from_wallet'],
tx['to_address'],
tx['amount'],
tx.get('fee', 10.0),
password,
rpc_url=rpc_url
)
results.append({
'transaction': tx,
'hash': tx_hash,
'success': tx_hash is not None
})
if tx_hash:
success(f"Transaction sent: {tx['from_wallet']}{tx['to_address']} ({tx['amount']} AIT)")
else:
error(f"Transaction failed: {tx['from_wallet']}{tx['to_address']}")
except Exception as e:
results.append({
'transaction': tx,
'hash': None,
'success': False,
'error': str(e)
})
error(f"Transaction error: {e}")
success(f"Batch completed: {len([r for r in results if r['success']])}/{len(results)} successful")
@transactions.command()
@click.argument('tx_hash')
@click.option('--rpc-url', help='Blockchain RPC URL')
def status(tx_hash: str, rpc_url: Optional[str]):
"""Get transaction status"""
if not rpc_url:
rpc_url = DEFAULT_RPC_URL
try:
http_client = AITBCHTTPClient(base_url=rpc_url, timeout=30)
result = http_client.get(f"/rpc/transaction/{tx_hash}")
success(f"Transaction status for {tx_hash}")
click.echo(json.dumps(result, indent=2))
except NetworkError as e:
error(f"Error getting transaction status: {e}")
except Exception as e:
error(f"Error: {e}")
@transactions.command()
@click.option('--rpc-url', help='Blockchain RPC URL')
def pending(rpc_url: Optional[str]):
"""Get pending transactions"""
if not rpc_url:
rpc_url = DEFAULT_RPC_URL
try:
http_client = AITBCHTTPClient(base_url=rpc_url, timeout=30)
data = http_client.get("/rpc/pending")
transactions = data.get("transactions", [])
success(f"Pending transactions: {len(transactions)}")
for tx in transactions:
click.echo(f" - {tx.get('hash', 'unknown')}: {tx.get('amount', 0)} AIT")
except NetworkError as e:
error(f"Error getting pending transactions: {e}")
except Exception as e:
error(f"Error: {e}")
@transactions.command()
@click.option('--from', 'from_wallet', required=True, help='From wallet name')
@click.option('--to', 'to_address', required=True, help='To address')
@click.option('--amount', type=float, required=True, help='Amount to send')
@click.option('--rpc-url', help='Blockchain RPC URL')
def estimate_fee(from_wallet: str, to_address: str, amount: float, rpc_url: Optional[str]):
"""Estimate transaction fee"""
if not rpc_url:
rpc_url = DEFAULT_RPC_URL
try:
test_tx = {
"sender": "",
"recipient": to_address,
"value": int(amount),
"fee": 10,
"nonce": 0,
"type": "transfer",
"payload": {}
}
try:
http_client = AITBCHTTPClient(base_url=rpc_url, timeout=10)
fee_data = http_client.post("/rpc/estimateFee", json=test_tx)
estimated_fee = fee_data.get("estimated_fee", 10.0)
success(f"Estimated fee: {estimated_fee} AIT")
except NetworkError:
success(f"Estimated fee: 10.0 AIT (default)")
except Exception as e:
error(f"Error estimating fee: {e}")
success(f"Estimated fee: 10.0 AIT (default)")