refactor: consolidate blockchain explorer into single app and update backup ignore patterns
- Remove standalone explorer-web app (README, HTML, package files) - Add /web endpoint to blockchain-explorer for web interface access - Update .gitignore to exclude application backup archives (*.tar.gz, *.zip) - Add backup documentation files to .gitignore (BACKUP_INDEX.md, README.md) - Consolidate explorer functionality into main blockchain-explorer application
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -50,8 +50,9 @@ def submit(ctx, job_type: str, prompt: Optional[str], model: Optional[str],
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
# Use Exchange API endpoint format
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
f"{config.coordinator_url}/v1/miners/default/jobs/submit",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
@@ -62,7 +63,7 @@ def submit(ctx, job_type: str, prompt: Optional[str], model: Optional[str],
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
if response.status_code in [200, 201]:
|
||||
job = response.json()
|
||||
result = {
|
||||
"job_id": job.get('job_id'),
|
||||
@@ -118,24 +119,33 @@ def status(ctx, job_id: str):
|
||||
|
||||
@client.command()
|
||||
@click.option("--limit", default=10, help="Number of blocks to show")
|
||||
@click.option('--chain-id', help='Specific chain ID to query (default: ait-devnet)')
|
||||
@click.pass_context
|
||||
def blocks(ctx, limit: int):
|
||||
"""List recent blocks"""
|
||||
def blocks(ctx, limit: int, chain_id: str):
|
||||
"""List recent blocks from specific chain"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Query specific chain (default to ait-devnet if not specified)
|
||||
target_chain = chain_id or 'ait-devnet'
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/api/v1/blocks",
|
||||
params={"limit": limit},
|
||||
params={"limit": limit, "chain_id": target_chain},
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
blocks = response.json()
|
||||
output(blocks, ctx.obj['output_format'])
|
||||
output({
|
||||
"blocks": blocks,
|
||||
"chain_id": target_chain,
|
||||
"limit": limit,
|
||||
"query_type": "single_chain"
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get blocks: {response.status_code}")
|
||||
error(f"Failed to get blocks from chain {target_chain}: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
476
cli/aitbc_cli/commands/cross_chain.py
Normal file
476
cli/aitbc_cli/commands/cross_chain.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""Cross-chain trading commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional
|
||||
from tabulate import tabulate
|
||||
from ..config import get_config
|
||||
from ..utils import success, error, output
|
||||
|
||||
|
||||
@click.group()
|
||||
def cross_chain():
|
||||
"""Cross-chain trading operations"""
|
||||
pass
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.option("--from-chain", help="Source chain ID")
|
||||
@click.option("--to-chain", help="Target chain ID")
|
||||
@click.option("--from-token", help="Source token symbol")
|
||||
@click.option("--to-token", help="Target token symbol")
|
||||
@click.pass_context
|
||||
def rates(ctx, from_chain: Optional[str], to_chain: Optional[str],
|
||||
from_token: Optional[str], to_token: Optional[str]):
|
||||
"""Get cross-chain exchange rates"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
# Get rates from cross-chain exchange
|
||||
response = client.get(
|
||||
f"http://localhost:8001/api/v1/cross-chain/rates",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
rates_data = response.json()
|
||||
rates = rates_data.get('rates', {})
|
||||
|
||||
if from_chain and to_chain:
|
||||
# Get specific rate
|
||||
pair_key = f"{from_chain}-{to_chain}"
|
||||
if pair_key in rates:
|
||||
success(f"Exchange rate {from_chain} → {to_chain}: {rates[pair_key]}")
|
||||
else:
|
||||
error(f"No rate available for {from_chain} → {to_chain}")
|
||||
else:
|
||||
# Show all rates
|
||||
success("Cross-chain exchange rates:")
|
||||
rate_table = []
|
||||
for pair, rate in rates.items():
|
||||
chains = pair.split('-')
|
||||
rate_table.append([chains[0], chains[1], f"{rate:.6f}"])
|
||||
|
||||
if rate_table:
|
||||
headers = ["From Chain", "To Chain", "Rate"]
|
||||
print(tabulate(rate_table, headers=headers, tablefmt="grid"))
|
||||
else:
|
||||
output("No cross-chain rates available")
|
||||
else:
|
||||
error(f"Failed to get cross-chain rates: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.option("--from-chain", required=True, help="Source chain ID")
|
||||
@click.option("--to-chain", required=True, help="Target chain ID")
|
||||
@click.option("--from-token", required=True, help="Source token symbol")
|
||||
@click.option("--to-token", required=True, help="Target token symbol")
|
||||
@click.option("--amount", type=float, required=True, help="Amount to swap")
|
||||
@click.option("--min-amount", type=float, help="Minimum amount to receive")
|
||||
@click.option("--slippage", type=float, default=0.01, help="Slippage tolerance (0-0.1)")
|
||||
@click.option("--address", help="User wallet address")
|
||||
@click.pass_context
|
||||
def swap(ctx, from_chain: str, to_chain: str, from_token: str, to_token: str,
|
||||
amount: float, min_amount: Optional[float], slippage: float, address: Optional[str]):
|
||||
"""Create cross-chain swap"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Validate inputs
|
||||
if from_chain == to_chain:
|
||||
error("Source and target chains must be different")
|
||||
return
|
||||
|
||||
if amount <= 0:
|
||||
error("Amount must be greater than 0")
|
||||
return
|
||||
|
||||
# Use default address if not provided
|
||||
if not address:
|
||||
address = config.get('default_address', '0x1234567890123456789012345678901234567890')
|
||||
|
||||
# Calculate minimum amount if not provided
|
||||
if not min_amount:
|
||||
# Get rate first
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"http://localhost:8001/api/v1/cross-chain/rates",
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
rates_data = response.json()
|
||||
pair_key = f"{from_chain}-{to_chain}"
|
||||
rate = rates_data.get('rates', {}).get(pair_key, 1.0)
|
||||
min_amount = amount * rate * (1 - slippage) * 0.97 # Account for fees
|
||||
else:
|
||||
min_amount = amount * 0.95 # Conservative fallback
|
||||
except:
|
||||
min_amount = amount * 0.95
|
||||
|
||||
swap_data = {
|
||||
"from_chain": from_chain,
|
||||
"to_chain": to_chain,
|
||||
"from_token": from_token,
|
||||
"to_token": to_token,
|
||||
"amount": amount,
|
||||
"min_amount": min_amount,
|
||||
"user_address": address,
|
||||
"slippage_tolerance": slippage
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"http://localhost:8001/api/v1/cross-chain/swap",
|
||||
json=swap_data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
swap_result = response.json()
|
||||
success("Cross-chain swap created successfully!")
|
||||
output({
|
||||
"Swap ID": swap_result.get('swap_id'),
|
||||
"From Chain": swap_result.get('from_chain'),
|
||||
"To Chain": swap_result.get('to_chain'),
|
||||
"Amount": swap_result.get('amount'),
|
||||
"Expected Amount": swap_result.get('expected_amount'),
|
||||
"Rate": swap_result.get('rate'),
|
||||
"Total Fees": swap_result.get('total_fees'),
|
||||
"Status": swap_result.get('status')
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
# Show swap ID for tracking
|
||||
success(f"Track swap with: aitbc cross-chain status {swap_result.get('swap_id')}")
|
||||
else:
|
||||
error(f"Failed to create swap: {response.status_code}")
|
||||
if response.text:
|
||||
error(f"Details: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.argument("swap_id")
|
||||
@click.pass_context
|
||||
def status(ctx, swap_id: str):
|
||||
"""Check cross-chain swap status"""
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"http://localhost:8001/api/v1/cross-chain/swap/{swap_id}",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
swap_data = response.json()
|
||||
success(f"Swap Status: {swap_data.get('status', 'unknown')}")
|
||||
|
||||
# Display swap details
|
||||
details = {
|
||||
"Swap ID": swap_data.get('swap_id'),
|
||||
"From Chain": swap_data.get('from_chain'),
|
||||
"To Chain": swap_data.get('to_chain'),
|
||||
"From Token": swap_data.get('from_token'),
|
||||
"To Token": swap_data.get('to_token'),
|
||||
"Amount": swap_data.get('amount'),
|
||||
"Expected Amount": swap_data.get('expected_amount'),
|
||||
"Actual Amount": swap_data.get('actual_amount'),
|
||||
"Status": swap_data.get('status'),
|
||||
"Created At": swap_data.get('created_at'),
|
||||
"Completed At": swap_data.get('completed_at'),
|
||||
"Bridge Fee": swap_data.get('bridge_fee'),
|
||||
"From Tx Hash": swap_data.get('from_tx_hash'),
|
||||
"To Tx Hash": swap_data.get('to_tx_hash')
|
||||
}
|
||||
|
||||
output(details, ctx.obj['output_format'])
|
||||
|
||||
# Show additional status info
|
||||
if swap_data.get('status') == 'completed':
|
||||
success("✅ Swap completed successfully!")
|
||||
elif swap_data.get('status') == 'failed':
|
||||
error("❌ Swap failed")
|
||||
if swap_data.get('error_message'):
|
||||
error(f"Error: {swap_data['error_message']}")
|
||||
elif swap_data.get('status') == 'pending':
|
||||
success("⏳ Swap is pending...")
|
||||
elif swap_data.get('status') == 'executing':
|
||||
success("🔄 Swap is executing...")
|
||||
elif swap_data.get('status') == 'refunded':
|
||||
success("💰 Swap was refunded")
|
||||
else:
|
||||
error(f"Failed to get swap status: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.option("--user-address", help="Filter by user address")
|
||||
@click.option("--status", help="Filter by status")
|
||||
@click.option("--limit", type=int, default=10, help="Number of swaps to show")
|
||||
@click.pass_context
|
||||
def swaps(ctx, user_address: Optional[str], status: Optional[str], limit: int):
|
||||
"""List cross-chain swaps"""
|
||||
params = {}
|
||||
if user_address:
|
||||
params['user_address'] = user_address
|
||||
if status:
|
||||
params['status'] = status
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"http://localhost:8001/api/v1/cross-chain/swaps",
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
swaps_data = response.json()
|
||||
swaps = swaps_data.get('swaps', [])
|
||||
|
||||
if swaps:
|
||||
success(f"Found {len(swaps)} cross-chain swaps:")
|
||||
|
||||
# Create table
|
||||
swap_table = []
|
||||
for swap in swaps[:limit]:
|
||||
swap_table.append([
|
||||
swap.get('swap_id', '')[:8] + '...',
|
||||
swap.get('from_chain', ''),
|
||||
swap.get('to_chain', ''),
|
||||
swap.get('amount', 0),
|
||||
swap.get('status', ''),
|
||||
swap.get('created_at', '')[:19]
|
||||
])
|
||||
|
||||
table(["ID", "From", "To", "Amount", "Status", "Created"], swap_table)
|
||||
|
||||
if len(swaps) > limit:
|
||||
success(f"Showing {limit} of {len(swaps)} total swaps")
|
||||
else:
|
||||
success("No cross-chain swaps found")
|
||||
else:
|
||||
error(f"Failed to get swaps: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.option("--source-chain", required=True, help="Source chain ID")
|
||||
@click.option("--target-chain", required=True, help="Target chain ID")
|
||||
@click.option("--token", required=True, help="Token to bridge")
|
||||
@click.option("--amount", type=float, required=True, help="Amount to bridge")
|
||||
@click.option("--recipient", help="Recipient address")
|
||||
@click.pass_context
|
||||
def bridge(ctx, source_chain: str, target_chain: str, token: str,
|
||||
amount: float, recipient: Optional[str]):
|
||||
"""Create cross-chain bridge transaction"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Validate inputs
|
||||
if source_chain == target_chain:
|
||||
error("Source and target chains must be different")
|
||||
return
|
||||
|
||||
if amount <= 0:
|
||||
error("Amount must be greater than 0")
|
||||
return
|
||||
|
||||
# Use default recipient if not provided
|
||||
if not recipient:
|
||||
recipient = config.get('default_address', '0x1234567890123456789012345678901234567890')
|
||||
|
||||
bridge_data = {
|
||||
"source_chain": source_chain,
|
||||
"target_chain": target_chain,
|
||||
"token": token,
|
||||
"amount": amount,
|
||||
"recipient_address": recipient
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"http://localhost:8001/api/v1/cross-chain/bridge",
|
||||
json=bridge_data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
bridge_result = response.json()
|
||||
success("Cross-chain bridge created successfully!")
|
||||
output({
|
||||
"Bridge ID": bridge_result.get('bridge_id'),
|
||||
"Source Chain": bridge_result.get('source_chain'),
|
||||
"Target Chain": bridge_result.get('target_chain'),
|
||||
"Token": bridge_result.get('token'),
|
||||
"Amount": bridge_result.get('amount'),
|
||||
"Bridge Fee": bridge_result.get('bridge_fee'),
|
||||
"Status": bridge_result.get('status')
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
# Show bridge ID for tracking
|
||||
success(f"Track bridge with: aitbc cross-chain bridge-status {bridge_result.get('bridge_id')}")
|
||||
else:
|
||||
error(f"Failed to create bridge: {response.status_code}")
|
||||
if response.text:
|
||||
error(f"Details: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.argument("bridge_id")
|
||||
@click.pass_context
|
||||
def bridge_status(ctx, bridge_id: str):
|
||||
"""Check cross-chain bridge status"""
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"http://localhost:8001/api/v1/cross-chain/bridge/{bridge_id}",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
bridge_data = response.json()
|
||||
success(f"Bridge Status: {bridge_data.get('status', 'unknown')}")
|
||||
|
||||
# Display bridge details
|
||||
details = {
|
||||
"Bridge ID": bridge_data.get('bridge_id'),
|
||||
"Source Chain": bridge_data.get('source_chain'),
|
||||
"Target Chain": bridge_data.get('target_chain'),
|
||||
"Token": bridge_data.get('token'),
|
||||
"Amount": bridge_data.get('amount'),
|
||||
"Recipient Address": bridge_data.get('recipient_address'),
|
||||
"Status": bridge_data.get('status'),
|
||||
"Created At": bridge_data.get('created_at'),
|
||||
"Completed At": bridge_data.get('completed_at'),
|
||||
"Bridge Fee": bridge_data.get('bridge_fee'),
|
||||
"Source Tx Hash": bridge_data.get('source_tx_hash'),
|
||||
"Target Tx Hash": bridge_data.get('target_tx_hash')
|
||||
}
|
||||
|
||||
output(details, ctx.obj['output_format'])
|
||||
|
||||
# Show additional status info
|
||||
if bridge_data.get('status') == 'completed':
|
||||
success("✅ Bridge completed successfully!")
|
||||
elif bridge_data.get('status') == 'failed':
|
||||
error("❌ Bridge failed")
|
||||
if bridge_data.get('error_message'):
|
||||
error(f"Error: {bridge_data['error_message']}")
|
||||
elif bridge_data.get('status') == 'pending':
|
||||
success("⏳ Bridge is pending...")
|
||||
elif bridge_data.get('status') == 'locked':
|
||||
success("🔒 Bridge is locked...")
|
||||
elif bridge_data.get('status') == 'transferred':
|
||||
success("🔄 Bridge is transferring...")
|
||||
else:
|
||||
error(f"Failed to get bridge status: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.pass_context
|
||||
def pools(ctx):
|
||||
"""Show cross-chain liquidity pools"""
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"http://localhost:8001/api/v1/cross-chain/pools",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
pools_data = response.json()
|
||||
pools = pools_data.get('pools', [])
|
||||
|
||||
if pools:
|
||||
success(f"Found {len(pools)} cross-chain liquidity pools:")
|
||||
|
||||
# Create table
|
||||
pool_table = []
|
||||
for pool in pools:
|
||||
pool_table.append([
|
||||
pool.get('pool_id', ''),
|
||||
pool.get('token_a', ''),
|
||||
pool.get('token_b', ''),
|
||||
pool.get('chain_a', ''),
|
||||
pool.get('chain_b', ''),
|
||||
f"{pool.get('reserve_a', 0):.2f}",
|
||||
f"{pool.get('reserve_b', 0):.2f}",
|
||||
f"{pool.get('total_liquidity', 0):.2f}",
|
||||
f"{pool.get('apr', 0):.2%}"
|
||||
])
|
||||
|
||||
table(["Pool ID", "Token A", "Token B", "Chain A", "Chain B",
|
||||
"Reserve A", "Reserve B", "Liquidity", "APR"], pool_table)
|
||||
else:
|
||||
success("No cross-chain liquidity pools found")
|
||||
else:
|
||||
error(f"Failed to get pools: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@cross_chain.command()
|
||||
@click.pass_context
|
||||
def stats(ctx):
|
||||
"""Show cross-chain trading statistics"""
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"http://localhost:8001/api/v1/cross-chain/stats",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
stats_data = response.json()
|
||||
|
||||
success("Cross-Chain Trading Statistics:")
|
||||
|
||||
# Show swap stats
|
||||
swap_stats = stats_data.get('swap_stats', [])
|
||||
if swap_stats:
|
||||
success("Swap Statistics:")
|
||||
swap_table = []
|
||||
for stat in swap_stats:
|
||||
swap_table.append([
|
||||
stat.get('status', ''),
|
||||
stat.get('count', 0),
|
||||
f"{stat.get('volume', 0):.2f}"
|
||||
])
|
||||
table(["Status", "Count", "Volume"], swap_table)
|
||||
|
||||
# Show bridge stats
|
||||
bridge_stats = stats_data.get('bridge_stats', [])
|
||||
if bridge_stats:
|
||||
success("Bridge Statistics:")
|
||||
bridge_table = []
|
||||
for stat in bridge_stats:
|
||||
bridge_table.append([
|
||||
stat.get('status', ''),
|
||||
stat.get('count', 0),
|
||||
f"{stat.get('volume', 0):.2f}"
|
||||
])
|
||||
table(["Status", "Count", "Volume"], bridge_table)
|
||||
|
||||
# Show overall stats
|
||||
success("Overall Statistics:")
|
||||
output({
|
||||
"Total Volume": f"{stats_data.get('total_volume', 0):.2f}",
|
||||
"Supported Chains": ", ".join(stats_data.get('supported_chains', [])),
|
||||
"Last Updated": stats_data.get('timestamp', '')
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get stats: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
@@ -84,12 +84,26 @@ def _load_wallet(wallet_path: Path, wallet_name: str) -> Dict[str, Any]:
|
||||
@click.option(
|
||||
"--wallet-path", help="Direct path to wallet file (overrides --wallet-name)"
|
||||
)
|
||||
@click.option(
|
||||
"--use-daemon", is_flag=True, help="Use wallet daemon for operations"
|
||||
)
|
||||
@click.pass_context
|
||||
def wallet(ctx, wallet_name: Optional[str], wallet_path: Optional[str]):
|
||||
def wallet(ctx, wallet_name: Optional[str], wallet_path: Optional[str], use_daemon: bool):
|
||||
"""Manage your AITBC wallets and transactions"""
|
||||
# Ensure wallet object exists
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
# Store daemon mode preference
|
||||
ctx.obj["use_daemon"] = use_daemon
|
||||
|
||||
# Initialize dual-mode adapter
|
||||
from ..config import get_config
|
||||
from ..dual_mode_wallet_adapter import DualModeWalletAdapter
|
||||
|
||||
config = get_config()
|
||||
adapter = DualModeWalletAdapter(config, use_daemon=use_daemon)
|
||||
ctx.obj["wallet_adapter"] = adapter
|
||||
|
||||
# If direct wallet path is provided, use it
|
||||
if wallet_path:
|
||||
wp = Path(wallet_path)
|
||||
@@ -140,118 +154,117 @@ def wallet(ctx, wallet_name: Optional[str], wallet_path: Optional[str]):
|
||||
@click.pass_context
|
||||
def create(ctx, name: str, wallet_type: str, no_encrypt: bool):
|
||||
"""Create a new wallet"""
|
||||
wallet_dir = ctx.obj["wallet_dir"]
|
||||
wallet_path = wallet_dir / f"{name}.json"
|
||||
|
||||
if wallet_path.exists():
|
||||
error(f"Wallet '{name}' already exists")
|
||||
return
|
||||
|
||||
# Generate new wallet
|
||||
if wallet_type == "hd":
|
||||
# Hierarchical Deterministic wallet
|
||||
import secrets
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
PublicFormat,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
)
|
||||
import base64
|
||||
|
||||
# Generate private key
|
||||
private_key_bytes = secrets.token_bytes(32)
|
||||
private_key = f"0x{private_key_bytes.hex()}"
|
||||
|
||||
# Derive public key from private key using ECDSA
|
||||
priv_key = ec.derive_private_key(
|
||||
int.from_bytes(private_key_bytes, "big"), ec.SECP256K1()
|
||||
)
|
||||
pub_key = priv_key.public_key()
|
||||
pub_key_bytes = pub_key.public_bytes(
|
||||
encoding=Encoding.X962, format=PublicFormat.UncompressedPoint
|
||||
)
|
||||
public_key = f"0x{pub_key_bytes.hex()}"
|
||||
|
||||
# Generate address from public key (simplified)
|
||||
digest = hashes.Hash(hashes.SHA256())
|
||||
digest.update(pub_key_bytes)
|
||||
address_hash = digest.finalize()
|
||||
address = f"aitbc1{address_hash[:20].hex()}"
|
||||
else:
|
||||
# Simple wallet
|
||||
import secrets
|
||||
|
||||
private_key = f"0x{secrets.token_hex(32)}"
|
||||
public_key = f"0x{secrets.token_hex(32)}"
|
||||
address = f"aitbc1{secrets.token_hex(20)}"
|
||||
|
||||
wallet_data = {
|
||||
"wallet_id": name,
|
||||
"type": wallet_type,
|
||||
"address": address,
|
||||
"public_key": public_key,
|
||||
"private_key": private_key,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"balance": 0,
|
||||
"transactions": [],
|
||||
}
|
||||
|
||||
# Get password for encryption unless skipped
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
# Check if using daemon mode and daemon is available
|
||||
if use_daemon and not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available. Falling back to file-based wallet.")
|
||||
# Switch to file mode
|
||||
from ..config import get_config
|
||||
from ..dual_mode_wallet_adapter import DualModeWalletAdapter
|
||||
config = get_config()
|
||||
adapter = DualModeWalletAdapter(config, use_daemon=False)
|
||||
ctx.obj["wallet_adapter"] = adapter
|
||||
|
||||
# Get password for encryption
|
||||
password = None
|
||||
if not no_encrypt:
|
||||
success(
|
||||
"Wallet encryption is enabled. Your private key will be encrypted at rest."
|
||||
)
|
||||
password = _get_wallet_password(name)
|
||||
|
||||
# Save wallet
|
||||
_save_wallet(wallet_path, wallet_data, password)
|
||||
|
||||
success(f"Wallet '{name}' created successfully")
|
||||
output(
|
||||
{
|
||||
"name": name,
|
||||
"type": wallet_type,
|
||||
"address": address,
|
||||
"path": str(wallet_path),
|
||||
},
|
||||
ctx.obj.get("output_format", "table"),
|
||||
)
|
||||
if use_daemon:
|
||||
# For daemon mode, use a default password or prompt
|
||||
password = getpass.getpass(f"Enter password for wallet '{name}' (press Enter for default): ")
|
||||
if not password:
|
||||
password = "default_wallet_password"
|
||||
else:
|
||||
# For file mode, use existing password prompt logic
|
||||
password = getpass.getpass(f"Enter password for wallet '{name}': ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if password != confirm:
|
||||
error("Passwords do not match")
|
||||
return
|
||||
|
||||
# Create wallet using the adapter
|
||||
try:
|
||||
metadata = {
|
||||
"wallet_type": wallet_type,
|
||||
"created_by": "aitbc_cli",
|
||||
"encryption_enabled": not no_encrypt
|
||||
}
|
||||
|
||||
wallet_info = adapter.create_wallet(name, password, wallet_type, metadata)
|
||||
|
||||
# Display results
|
||||
output(wallet_info, ctx.obj.get("output_format", "table"))
|
||||
|
||||
# Set as active wallet if successful
|
||||
if wallet_info:
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
config_data = {}
|
||||
if config_file.exists():
|
||||
with open(config_file, "r") as f:
|
||||
config_data = yaml.safe_load(f) or {}
|
||||
|
||||
config_data["active_wallet"] = name
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
success(f"Wallet '{name}' is now active")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to create wallet: {str(e)}")
|
||||
return
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def list(ctx):
|
||||
"""List all wallets"""
|
||||
wallet_dir = ctx.obj["wallet_dir"]
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
|
||||
# Get active wallet
|
||||
active_wallet = "default"
|
||||
if config_file.exists():
|
||||
with open(config_file, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
active_wallet = config.get("active_wallet", "default")
|
||||
|
||||
wallets = []
|
||||
for wallet_file in wallet_dir.glob("*.json"):
|
||||
with open(wallet_file, "r") as f:
|
||||
wallet_data = json.load(f)
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
# Check if using daemon mode and daemon is available
|
||||
if use_daemon and not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available. Falling back to file-based wallet listing.")
|
||||
# Switch to file mode
|
||||
from ..config import get_config
|
||||
from ..dual_mode_wallet_adapter import DualModeWalletAdapter
|
||||
config = get_config()
|
||||
adapter = DualModeWalletAdapter(config, use_daemon=False)
|
||||
|
||||
try:
|
||||
wallets = adapter.list_wallets()
|
||||
|
||||
if not wallets:
|
||||
output({"wallets": [], "count": 0, "mode": "daemon" if use_daemon else "file"},
|
||||
ctx.obj.get("output_format", "table"))
|
||||
return
|
||||
|
||||
# Format output
|
||||
wallet_list = []
|
||||
for wallet in wallets:
|
||||
wallet_info = {
|
||||
"name": wallet_data["wallet_id"],
|
||||
"type": wallet_data.get("type", "simple"),
|
||||
"address": wallet_data["address"],
|
||||
"created_at": wallet_data["created_at"],
|
||||
"active": wallet_data["wallet_id"] == active_wallet,
|
||||
"name": wallet.get("wallet_name"),
|
||||
"address": wallet.get("address"),
|
||||
"balance": wallet.get("balance", 0.0),
|
||||
"type": wallet.get("wallet_type", "hd"),
|
||||
"created_at": wallet.get("created_at"),
|
||||
"mode": wallet.get("mode", "file")
|
||||
}
|
||||
if wallet_data.get("encrypted"):
|
||||
wallet_info["encrypted"] = True
|
||||
wallets.append(wallet_info)
|
||||
wallet_list.append(wallet_info)
|
||||
|
||||
output_data = {
|
||||
"wallets": wallet_list,
|
||||
"count": len(wallet_list),
|
||||
"mode": "daemon" if use_daemon else "file"
|
||||
}
|
||||
|
||||
output(output_data, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to list wallets: {str(e)}")
|
||||
|
||||
|
||||
output(wallets, ctx.obj.get("output_format", "table"))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@@ -259,37 +272,43 @@ def list(ctx):
|
||||
@click.pass_context
|
||||
def switch(ctx, name: str):
|
||||
"""Switch to a different wallet"""
|
||||
wallet_dir = ctx.obj["wallet_dir"]
|
||||
wallet_path = wallet_dir / f"{name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
# Check if using daemon mode and daemon is available
|
||||
if use_daemon and not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available. Falling back to file-based wallet switching.")
|
||||
# Switch to file mode
|
||||
from ..config import get_config
|
||||
from ..dual_mode_wallet_adapter import DualModeWalletAdapter
|
||||
config = get_config()
|
||||
adapter = DualModeWalletAdapter(config, use_daemon=False)
|
||||
|
||||
# Check if wallet exists
|
||||
wallet_info = adapter.get_wallet_info(name)
|
||||
if not wallet_info:
|
||||
error(f"Wallet '{name}' does not exist")
|
||||
return
|
||||
|
||||
|
||||
# Update config
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
config = {}
|
||||
|
||||
if config_file.exists():
|
||||
import yaml
|
||||
|
||||
with open(config_file, "r") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
config["active_wallet"] = name
|
||||
|
||||
# Save config
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(config, f, default_flow_style=False)
|
||||
|
||||
success(f"Switched to wallet '{name}'")
|
||||
# Load wallet to get address (will handle encryption)
|
||||
wallet_data = _load_wallet(wallet_path, name)
|
||||
output(
|
||||
{"active_wallet": name, "address": wallet_data["address"]},
|
||||
ctx.obj.get("output_format", "table"),
|
||||
)
|
||||
yaml.dump(config, f)
|
||||
|
||||
success(f"Switched to wallet: {name}")
|
||||
output({
|
||||
"active_wallet": name,
|
||||
"mode": "daemon" if use_daemon else "file",
|
||||
"wallet_info": wallet_info
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@@ -424,12 +443,12 @@ def info(ctx):
|
||||
active_wallet = config.get("active_wallet", "default")
|
||||
|
||||
wallet_info = {
|
||||
"name": wallet_data["wallet_id"],
|
||||
"type": wallet_data.get("type", "simple"),
|
||||
"name": wallet_data.get("name", wallet_name),
|
||||
"type": wallet_data.get("type", wallet_data.get("wallet_type", "simple")),
|
||||
"address": wallet_data["address"],
|
||||
"public_key": wallet_data["public_key"],
|
||||
"public_key": wallet_data.get("public_key", "N/A"),
|
||||
"created_at": wallet_data["created_at"],
|
||||
"active": wallet_data["wallet_id"] == active_wallet,
|
||||
"active": wallet_data.get("name", wallet_name) == active_wallet,
|
||||
"path": str(wallet_path),
|
||||
}
|
||||
|
||||
@@ -734,148 +753,191 @@ def address(ctx):
|
||||
@click.pass_context
|
||||
def send(ctx, to_address: str, amount: float, description: Optional[str]):
|
||||
"""Send AITBC to another address"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
wallet_name = ctx.obj["wallet_name"]
|
||||
wallet_path = ctx.obj["wallet_path"]
|
||||
config = ctx.obj.get("config")
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
|
||||
# Check if using daemon mode and daemon is available
|
||||
if use_daemon and not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available. Falling back to file-based wallet send.")
|
||||
# Switch to file mode
|
||||
from ..config import get_config
|
||||
from ..dual_mode_wallet_adapter import DualModeWalletAdapter
|
||||
config = get_config()
|
||||
adapter = DualModeWalletAdapter(config, use_daemon=False)
|
||||
ctx.obj["wallet_adapter"] = adapter
|
||||
|
||||
# Get password for transaction
|
||||
password = getpass.getpass(f"Enter password for wallet '{wallet_name}': ")
|
||||
|
||||
try:
|
||||
result = adapter.send_transaction(wallet_name, password, to_address, amount, description)
|
||||
|
||||
# Display results
|
||||
output(result, ctx.obj.get("output_format", "table"))
|
||||
|
||||
# Update active wallet if successful
|
||||
if result:
|
||||
success(f"Transaction sent successfully")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to send transaction: {str(e)}")
|
||||
return
|
||||
|
||||
wallet_data = _load_wallet(wallet_path, wallet_name)
|
||||
|
||||
balance = wallet_data.get("balance", 0)
|
||||
if balance < amount:
|
||||
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Try to send via blockchain
|
||||
if config:
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url.rstrip('/')}/rpc/sendTx?chain_id=ait-devnet",
|
||||
json={
|
||||
"type": "TRANSFER",
|
||||
"sender": wallet_data["address"],
|
||||
"nonce": 0, # Will need to get actual nonce
|
||||
"fee": 1,
|
||||
"payload": {
|
||||
"to": to_address,
|
||||
"amount": int(amount * 1000000000), # Convert to smallest unit
|
||||
"description": description or "",
|
||||
},
|
||||
"sig": None, # Will need to sign transaction
|
||||
},
|
||||
headers={"X-Api-Key": getattr(config, "api_key", "") or ""},
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
tx = response.json()
|
||||
# Update local wallet
|
||||
transaction = {
|
||||
"type": "send",
|
||||
"amount": -amount,
|
||||
"to_address": to_address,
|
||||
"tx_hash": tx.get("hash"),
|
||||
"description": description or "",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
wallet_data["transactions"].append(transaction)
|
||||
wallet_data["balance"] = balance - amount
|
||||
|
||||
# Use _save_wallet to preserve encryption
|
||||
if wallet_data.get("encrypted"):
|
||||
password = _get_wallet_password(wallet_name)
|
||||
_save_wallet(wallet_path, wallet_data, password)
|
||||
else:
|
||||
_save_wallet(wallet_path, wallet_data)
|
||||
|
||||
success(f"Sent {amount} AITBC to {to_address}")
|
||||
output(
|
||||
{
|
||||
"wallet": wallet_name,
|
||||
"tx_hash": tx.get("hash"),
|
||||
"amount": amount,
|
||||
"to": to_address,
|
||||
"new_balance": wallet_data["balance"],
|
||||
},
|
||||
ctx.obj.get("output_format", "table"),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
# Fallback: just record locally
|
||||
transaction = {
|
||||
"type": "send",
|
||||
"amount": -amount,
|
||||
"to_address": to_address,
|
||||
"description": description or "",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"pending": True,
|
||||
}
|
||||
|
||||
wallet_data["transactions"].append(transaction)
|
||||
wallet_data["balance"] = balance - amount
|
||||
|
||||
# Save wallet with encryption
|
||||
password = None
|
||||
if wallet_data.get("encrypted"):
|
||||
password = _get_wallet_password(wallet_name)
|
||||
_save_wallet(wallet_path, wallet_data, password)
|
||||
|
||||
output(
|
||||
{
|
||||
"wallet": wallet_name,
|
||||
"amount": amount,
|
||||
"to": to_address,
|
||||
"new_balance": wallet_data["balance"],
|
||||
"note": "Transaction recorded locally (blockchain RPC not available)",
|
||||
},
|
||||
ctx.obj.get("output_format", "table"),
|
||||
)
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("to_address")
|
||||
@click.argument("amount", type=float)
|
||||
@click.option("--description", help="Transaction description")
|
||||
@click.pass_context
|
||||
def request_payment(ctx, to_address: str, amount: float, description: Optional[str]):
|
||||
"""Request payment from another address"""
|
||||
wallet_name = ctx.obj["wallet_name"]
|
||||
wallet_path = ctx.obj["wallet_path"]
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
wallet_data = _load_wallet(wallet_path, wallet_name)
|
||||
|
||||
# Create payment request
|
||||
request = {
|
||||
"from_address": to_address,
|
||||
"to_address": wallet_data["address"],
|
||||
"amount": amount,
|
||||
"description": description or "",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
output(
|
||||
{
|
||||
"wallet": wallet_name,
|
||||
"payment_request": request,
|
||||
"note": "Share this with the payer to request payment",
|
||||
},
|
||||
ctx.obj.get("output_format", "table"),
|
||||
)
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def balance(ctx):
|
||||
"""Check wallet balance"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
wallet_name = ctx.obj["wallet_name"]
|
||||
|
||||
# Check if using daemon mode and daemon is available
|
||||
if use_daemon and not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available. Falling back to file-based wallet balance.")
|
||||
# Switch to file mode
|
||||
from ..config import get_config
|
||||
from ..dual_mode_wallet_adapter import DualModeWalletAdapter
|
||||
config = get_config()
|
||||
adapter = DualModeWalletAdapter(config, use_daemon=False)
|
||||
ctx.obj["wallet_adapter"] = adapter
|
||||
|
||||
try:
|
||||
balance = adapter.get_wallet_balance(wallet_name)
|
||||
wallet_info = adapter.get_wallet_info(wallet_name)
|
||||
|
||||
if balance is None:
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
output_data = {
|
||||
"wallet_name": wallet_name,
|
||||
"balance": balance,
|
||||
"address": wallet_info.get("address") if wallet_info else None,
|
||||
"mode": "daemon" if use_daemon else "file"
|
||||
}
|
||||
|
||||
output(output_data, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to get wallet balance: {str(e)}")
|
||||
|
||||
|
||||
@wallet.group()
|
||||
def daemon():
|
||||
"""Wallet daemon management commands"""
|
||||
pass
|
||||
|
||||
|
||||
@daemon.command()
|
||||
@click.pass_context
|
||||
def status(ctx):
|
||||
"""Check wallet daemon status"""
|
||||
from ..config import get_config
|
||||
from ..wallet_daemon_client import WalletDaemonClient
|
||||
|
||||
config = get_config()
|
||||
client = WalletDaemonClient(config)
|
||||
|
||||
if client.is_available():
|
||||
status_info = client.get_status()
|
||||
success("Wallet daemon is available")
|
||||
output(status_info, ctx.obj.get("output_format", "table"))
|
||||
else:
|
||||
error("Wallet daemon is not available")
|
||||
output({
|
||||
"status": "unavailable",
|
||||
"wallet_url": config.wallet_url,
|
||||
"suggestion": "Start the wallet daemon or check the configuration"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
|
||||
|
||||
@daemon.command()
|
||||
@click.pass_context
|
||||
def configure(ctx):
|
||||
"""Configure wallet daemon settings"""
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
output({
|
||||
"wallet_url": config.wallet_url,
|
||||
"timeout": getattr(config, 'timeout', 30),
|
||||
"suggestion": "Use AITBC_WALLET_URL environment variable or config file to change settings"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("wallet_name")
|
||||
@click.option("--password", help="Wallet password")
|
||||
@click.option("--new-password", help="New password for daemon wallet")
|
||||
@click.option("--force", is_flag=True, help="Force migration even if wallet exists")
|
||||
@click.pass_context
|
||||
def migrate_to_daemon(ctx, wallet_name: str, password: Optional[str], new_password: Optional[str], force: bool):
|
||||
"""Migrate a file-based wallet to daemon storage"""
|
||||
from ..wallet_migration_service import WalletMigrationService
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
migration_service = WalletMigrationService(config)
|
||||
|
||||
if not migration_service.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
result = migration_service.migrate_to_daemon(wallet_name, password, new_password, force)
|
||||
success(f"Migrated wallet '{wallet_name}' to daemon")
|
||||
output(result, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to migrate wallet: {str(e)}")
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("wallet_name")
|
||||
@click.option("--password", help="Wallet password")
|
||||
@click.option("--new-password", help="New password for file wallet")
|
||||
@click.option("--force", is_flag=True, help="Force migration even if wallet exists")
|
||||
@click.pass_context
|
||||
def migrate_to_file(ctx, wallet_name: str, password: Optional[str], new_password: Optional[str], force: bool):
|
||||
"""Migrate a daemon-based wallet to file storage"""
|
||||
from ..wallet_migration_service import WalletMigrationService
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
migration_service = WalletMigrationService(config)
|
||||
|
||||
if not migration_service.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
result = migration_service.migrate_to_file(wallet_name, password, new_password, force)
|
||||
success(f"Migrated wallet '{wallet_name}' to file storage")
|
||||
output(result, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to migrate wallet: {str(e)}")
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def migration_status(ctx):
|
||||
"""Show wallet migration status"""
|
||||
from ..wallet_migration_service import WalletMigrationService
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
migration_service = WalletMigrationService(config)
|
||||
|
||||
try:
|
||||
status = migration_service.get_migration_status()
|
||||
output(status, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to get migration status: {str(e)}")
|
||||
def stats(ctx):
|
||||
"""Show wallet statistics"""
|
||||
wallet_name = ctx.obj["wallet_name"]
|
||||
@@ -1603,3 +1665,265 @@ def rewards(ctx):
|
||||
},
|
||||
ctx.obj.get("output_format", "table"),
|
||||
)
|
||||
|
||||
|
||||
# Multi-Chain Commands
|
||||
|
||||
@wallet.group()
|
||||
def chain():
|
||||
"""Multi-chain wallet operations"""
|
||||
pass
|
||||
|
||||
|
||||
@chain.command()
|
||||
@click.pass_context
|
||||
def list(ctx):
|
||||
"""List all blockchain chains"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
chains = adapter.list_chains()
|
||||
output({
|
||||
"chains": chains,
|
||||
"count": len(chains),
|
||||
"mode": "daemon"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to list chains: {str(e)}")
|
||||
|
||||
|
||||
@chain.command()
|
||||
@click.argument("chain_id")
|
||||
@click.argument("name")
|
||||
@click.argument("coordinator_url")
|
||||
@click.argument("coordinator_api_key")
|
||||
@click.pass_context
|
||||
def create(ctx, chain_id: str, name: str, coordinator_url: str, coordinator_api_key: str):
|
||||
"""Create a new blockchain chain"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
chain = adapter.create_chain(chain_id, name, coordinator_url, coordinator_api_key)
|
||||
if chain:
|
||||
success(f"Created chain: {chain_id}")
|
||||
output(chain, ctx.obj.get("output_format", "table"))
|
||||
else:
|
||||
error(f"Failed to create chain: {chain_id}")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to create chain: {str(e)}")
|
||||
|
||||
|
||||
@chain.command()
|
||||
@click.pass_context
|
||||
def status(ctx):
|
||||
"""Get chain status and statistics"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
status = adapter.get_chain_status()
|
||||
output(status, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to get chain status: {str(e)}")
|
||||
|
||||
|
||||
@chain.command()
|
||||
@click.argument("chain_id")
|
||||
@click.pass_context
|
||||
def wallets(ctx, chain_id: str):
|
||||
"""List wallets in a specific chain"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
wallets = adapter.list_wallets_in_chain(chain_id)
|
||||
output({
|
||||
"chain_id": chain_id,
|
||||
"wallets": wallets,
|
||||
"count": len(wallets),
|
||||
"mode": "daemon"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to list wallets in chain {chain_id}: {str(e)}")
|
||||
|
||||
|
||||
@chain.command()
|
||||
@click.argument("chain_id")
|
||||
@click.argument("wallet_name")
|
||||
@click.pass_context
|
||||
def info(ctx, chain_id: str, wallet_name: str):
|
||||
"""Get wallet information from a specific chain"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
wallet_info = adapter.get_wallet_info_in_chain(chain_id, wallet_name)
|
||||
if wallet_info:
|
||||
output(wallet_info, ctx.obj.get("output_format", "table"))
|
||||
else:
|
||||
error(f"Wallet '{wallet_name}' not found in chain '{chain_id}'")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to get wallet info: {str(e)}")
|
||||
|
||||
|
||||
@chain.command()
|
||||
@click.argument("chain_id")
|
||||
@click.argument("wallet_name")
|
||||
@click.pass_context
|
||||
def balance(ctx, chain_id: str, wallet_name: str):
|
||||
"""Get wallet balance in a specific chain"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
balance = adapter.get_wallet_balance_in_chain(chain_id, wallet_name)
|
||||
if balance is not None:
|
||||
output({
|
||||
"chain_id": chain_id,
|
||||
"wallet_name": wallet_name,
|
||||
"balance": balance,
|
||||
"mode": "daemon"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
else:
|
||||
error(f"Could not get balance for wallet '{wallet_name}' in chain '{chain_id}'")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to get wallet balance: {str(e)}")
|
||||
|
||||
|
||||
@chain.command()
|
||||
@click.argument("source_chain_id")
|
||||
@click.argument("target_chain_id")
|
||||
@click.argument("wallet_name")
|
||||
@click.option("--new-password", help="New password for target chain wallet")
|
||||
@click.pass_context
|
||||
def migrate(ctx, source_chain_id: str, target_chain_id: str, wallet_name: str, new_password: Optional[str]):
|
||||
"""Migrate a wallet from one chain to another"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Get password
|
||||
import getpass
|
||||
password = getpass.getpass(f"Enter password for wallet '{wallet_name}': ")
|
||||
|
||||
result = adapter.migrate_wallet(source_chain_id, target_chain_id, wallet_name, password, new_password)
|
||||
if result:
|
||||
success(f"Migrated wallet '{wallet_name}' from '{source_chain_id}' to '{target_chain_id}'")
|
||||
output(result, ctx.obj.get("output_format", "table"))
|
||||
else:
|
||||
error(f"Failed to migrate wallet '{wallet_name}'")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to migrate wallet: {str(e)}")
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("chain_id")
|
||||
@click.argument("wallet_name")
|
||||
@click.option("--type", "wallet_type", default="hd", help="Wallet type (hd, simple)")
|
||||
@click.option("--no-encrypt", is_flag=True, help="Skip wallet encryption (not recommended)")
|
||||
@click.pass_context
|
||||
def create_in_chain(ctx, chain_id: str, wallet_name: str, wallet_type: str, no_encrypt: bool):
|
||||
"""Create a wallet in a specific chain"""
|
||||
adapter = ctx.obj["wallet_adapter"]
|
||||
use_daemon = ctx.obj["use_daemon"]
|
||||
|
||||
if not use_daemon:
|
||||
error("Chain operations require daemon mode. Use --use-daemon flag.")
|
||||
return
|
||||
|
||||
if not adapter.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Get password
|
||||
import getpass
|
||||
if not no_encrypt:
|
||||
password = getpass.getpass(f"Enter password for wallet '{wallet_name}': ")
|
||||
confirm_password = getpass.getpass(f"Confirm password for wallet '{wallet_name}': ")
|
||||
if password != confirm_password:
|
||||
error("Passwords do not match")
|
||||
return
|
||||
else:
|
||||
password = "insecure" # Default password for unencrypted wallets
|
||||
|
||||
metadata = {
|
||||
"wallet_type": wallet_type,
|
||||
"encrypted": not no_encrypt,
|
||||
"created_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
result = adapter.create_wallet_in_chain(chain_id, wallet_name, password, wallet_type, metadata)
|
||||
if result:
|
||||
success(f"Created wallet '{wallet_name}' in chain '{chain_id}'")
|
||||
output(result, ctx.obj.get("output_format", "table"))
|
||||
else:
|
||||
error(f"Failed to create wallet '{wallet_name}' in chain '{chain_id}'")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to create wallet in chain: {str(e)}")
|
||||
|
||||
@@ -19,6 +19,26 @@ class Config:
|
||||
blockchain_rpc_url: str = "http://127.0.0.1:8006"
|
||||
wallet_url: str = "http://127.0.0.1:8002"
|
||||
|
||||
def _validate_localhost_urls(self):
|
||||
"""Validate that all service URLs point to localhost"""
|
||||
localhost_prefixes = ["http://localhost:", "http://127.0.0.1:", "https://localhost:", "https://127.0.0.1:"]
|
||||
|
||||
urls_to_check = [
|
||||
("coordinator_url", self.coordinator_url),
|
||||
("blockchain_rpc_url", self.blockchain_rpc_url),
|
||||
("wallet_url", self.wallet_url)
|
||||
]
|
||||
|
||||
for url_name, url in urls_to_check:
|
||||
if not any(url.startswith(prefix) for prefix in localhost_prefixes):
|
||||
# Force to localhost if not already
|
||||
if url_name == "coordinator_url":
|
||||
self.coordinator_url = "http://localhost:8000"
|
||||
elif url_name == "blockchain_rpc_url":
|
||||
self.blockchain_rpc_url = "http://localhost:8006"
|
||||
elif url_name == "wallet_url":
|
||||
self.wallet_url = "http://localhost:8002"
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize configuration"""
|
||||
# Load environment variables
|
||||
@@ -45,6 +65,9 @@ class Config:
|
||||
self.blockchain_rpc_url = os.getenv("AITBC_BLOCKCHAIN_RPC_URL")
|
||||
if os.getenv("AITBC_WALLET_URL"):
|
||||
self.wallet_url = os.getenv("AITBC_WALLET_URL")
|
||||
|
||||
# Validate and enforce localhost URLs
|
||||
self._validate_localhost_urls()
|
||||
|
||||
def load_from_file(self):
|
||||
"""Load configuration from YAML file"""
|
||||
@@ -60,6 +83,9 @@ class Config:
|
||||
self.wallet_url = data.get('wallet_url', self.wallet_url)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config file: {e}")
|
||||
|
||||
# Validate and enforce localhost URLs after file loading
|
||||
self._validate_localhost_urls()
|
||||
|
||||
def save_to_file(self):
|
||||
"""Save configuration to YAML file"""
|
||||
|
||||
567
cli/aitbc_cli/dual_mode_wallet_adapter.py
Normal file
567
cli/aitbc_cli/dual_mode_wallet_adapter.py
Normal file
@@ -0,0 +1,567 @@
|
||||
"""Dual-Mode Wallet Adapter for AITBC CLI
|
||||
|
||||
This module provides an abstraction layer that supports both file-based
|
||||
and daemon-based wallet operations, allowing seamless switching between modes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
from datetime import datetime
|
||||
|
||||
from .wallet_daemon_client import WalletDaemonClient, WalletInfo, WalletBalance, ChainInfo, WalletMigrationResult
|
||||
from .config import Config
|
||||
from .utils import error, success, output
|
||||
|
||||
|
||||
class DualModeWalletAdapter:
|
||||
"""Adapter supporting both file-based and daemon-based wallet operations"""
|
||||
|
||||
def __init__(self, config: Config, use_daemon: bool = False):
|
||||
self.config = config
|
||||
self.use_daemon = use_daemon
|
||||
self.wallet_dir = Path.home() / ".aitbc" / "wallets"
|
||||
self.wallet_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if use_daemon:
|
||||
self.daemon_client = WalletDaemonClient(config)
|
||||
else:
|
||||
self.daemon_client = None
|
||||
|
||||
def is_daemon_available(self) -> bool:
|
||||
"""Check if daemon is available"""
|
||||
if not self.daemon_client:
|
||||
return False
|
||||
return self.daemon_client.is_available()
|
||||
|
||||
def get_daemon_status(self) -> Dict[str, Any]:
|
||||
"""Get daemon status"""
|
||||
if not self.daemon_client:
|
||||
return {"status": "disabled", "message": "Daemon mode not enabled"}
|
||||
return self.daemon_client.get_status()
|
||||
|
||||
def create_wallet(self, wallet_name: str, password: str, wallet_type: str = "hd",
|
||||
metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Create a wallet using the appropriate mode"""
|
||||
if self.use_daemon:
|
||||
return self._create_wallet_daemon(wallet_name, password, metadata)
|
||||
else:
|
||||
return self._create_wallet_file(wallet_name, password, wallet_type)
|
||||
|
||||
def _create_wallet_daemon(self, wallet_name: str, password: str,
|
||||
metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Create wallet using daemon"""
|
||||
try:
|
||||
if not self.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
raise Exception("Daemon unavailable")
|
||||
|
||||
wallet_info = self.daemon_client.create_wallet(wallet_name, password, metadata)
|
||||
|
||||
success(f"Created daemon wallet: {wallet_name}")
|
||||
return {
|
||||
"mode": "daemon",
|
||||
"wallet_name": wallet_name,
|
||||
"wallet_id": wallet_info.wallet_id,
|
||||
"public_key": wallet_info.public_key,
|
||||
"address": wallet_info.address,
|
||||
"created_at": wallet_info.created_at,
|
||||
"metadata": wallet_info.metadata
|
||||
}
|
||||
except Exception as e:
|
||||
error(f"Failed to create daemon wallet: {str(e)}")
|
||||
raise
|
||||
|
||||
def _create_wallet_file(self, wallet_name: str, password: str, wallet_type: str) -> Dict[str, Any]:
|
||||
"""Create wallet using file-based storage"""
|
||||
from .commands.wallet import _save_wallet
|
||||
|
||||
wallet_path = self.wallet_dir / f"{wallet_name}.json"
|
||||
|
||||
if wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' already exists")
|
||||
raise Exception("Wallet exists")
|
||||
|
||||
# Generate wallet data
|
||||
if wallet_type == "simple":
|
||||
# Simple wallet with deterministic key for testing
|
||||
private_key = f"simple_key_{wallet_name}_{datetime.now().isoformat()}"
|
||||
address = f"aitbc1{wallet_name}_simple"
|
||||
else:
|
||||
# HD wallet (placeholder for real implementation)
|
||||
private_key = f"hd_key_{wallet_name}_{datetime.now().isoformat()}"
|
||||
address = f"aitbc1{wallet_name}_hd"
|
||||
|
||||
wallet_data = {
|
||||
"name": wallet_name,
|
||||
"address": address,
|
||||
"balance": 0.0,
|
||||
"encrypted": bool(password),
|
||||
"private_key": private_key,
|
||||
"transactions": [],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"wallet_type": wallet_type
|
||||
}
|
||||
|
||||
# Save wallet
|
||||
save_password = password if password else None
|
||||
_save_wallet(wallet_path, wallet_data, save_password)
|
||||
|
||||
success(f"Created file wallet: {wallet_name}")
|
||||
return {
|
||||
"mode": "file",
|
||||
"wallet_name": wallet_name,
|
||||
"address": address,
|
||||
"balance": 0.0,
|
||||
"wallet_type": wallet_type,
|
||||
"created_at": wallet_data["created_at"]
|
||||
}
|
||||
|
||||
def list_wallets(self) -> List[Dict[str, Any]]:
|
||||
"""List wallets using the appropriate mode"""
|
||||
if self.use_daemon:
|
||||
return self._list_wallets_daemon()
|
||||
else:
|
||||
return self._list_wallets_file()
|
||||
|
||||
def _list_wallets_daemon(self) -> List[Dict[str, Any]]:
|
||||
"""List wallets using daemon"""
|
||||
try:
|
||||
if not self.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
return []
|
||||
|
||||
wallets = self.daemon_client.list_wallets()
|
||||
return [
|
||||
{
|
||||
"mode": "daemon",
|
||||
"wallet_name": w.wallet_id,
|
||||
"wallet_id": w.wallet_id,
|
||||
"public_key": w.public_key,
|
||||
"address": w.address,
|
||||
"created_at": w.created_at,
|
||||
"metadata": w.metadata
|
||||
}
|
||||
for w in wallets
|
||||
]
|
||||
except Exception as e:
|
||||
error(f"Failed to list daemon wallets: {str(e)}")
|
||||
return []
|
||||
|
||||
def _list_wallets_file(self) -> List[Dict[str, Any]]:
|
||||
"""List wallets using file-based storage"""
|
||||
wallets = []
|
||||
|
||||
for wallet_file in self.wallet_dir.glob("*.json"):
|
||||
try:
|
||||
with open(wallet_file, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
wallets.append({
|
||||
"mode": "file",
|
||||
"wallet_name": wallet_data.get("name") or wallet_data.get("wallet_id") or wallet_file.stem,
|
||||
"address": wallet_data.get("address"),
|
||||
"balance": wallet_data.get("balance", 0.0),
|
||||
"wallet_type": wallet_data.get("wallet_type", "hd"),
|
||||
"created_at": wallet_data.get("created_at"),
|
||||
"encrypted": wallet_data.get("encrypted", False)
|
||||
})
|
||||
except Exception as e:
|
||||
error(f"Error reading wallet file {wallet_file}: {str(e)}")
|
||||
|
||||
return wallets
|
||||
|
||||
def get_wallet_info(self, wallet_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get wallet information using the appropriate mode"""
|
||||
if self.use_daemon:
|
||||
return self._get_wallet_info_daemon(wallet_name)
|
||||
else:
|
||||
return self._get_wallet_info_file(wallet_name)
|
||||
|
||||
def _get_wallet_info_daemon(self, wallet_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get wallet info using daemon"""
|
||||
try:
|
||||
if not self.is_daemon_available():
|
||||
return None
|
||||
|
||||
wallet_info = self.daemon_client.get_wallet_info(wallet_name)
|
||||
if wallet_info:
|
||||
return {
|
||||
"mode": "daemon",
|
||||
"wallet_name": wallet_name,
|
||||
"wallet_id": wallet_info.wallet_id,
|
||||
"public_key": wallet_info.public_key,
|
||||
"address": wallet_info.address,
|
||||
"created_at": wallet_info.created_at,
|
||||
"metadata": wallet_info.metadata
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
error(f"Failed to get daemon wallet info: {str(e)}")
|
||||
return None
|
||||
|
||||
def _get_wallet_info_file(self, wallet_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get wallet info using file-based storage"""
|
||||
from .commands.wallet import _load_wallet
|
||||
|
||||
wallet_path = self.wallet_dir / f"{wallet_name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
return {
|
||||
"mode": "file",
|
||||
"wallet_name": wallet_data.get("name") or wallet_data.get("wallet_id") or wallet_name,
|
||||
"address": wallet_data.get("address"),
|
||||
"balance": wallet_data.get("balance", 0.0),
|
||||
"wallet_type": wallet_data.get("wallet_type", "hd"),
|
||||
"created_at": wallet_data.get("created_at"),
|
||||
"encrypted": wallet_data.get("encrypted", False),
|
||||
"transactions": wallet_data.get("transactions", [])
|
||||
}
|
||||
except Exception as e:
|
||||
error(f"Failed to get file wallet info: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_wallet_balance(self, wallet_name: str) -> Optional[float]:
|
||||
"""Get wallet balance using the appropriate mode"""
|
||||
if self.use_daemon:
|
||||
return self._get_wallet_balance_daemon(wallet_name)
|
||||
else:
|
||||
return self._get_wallet_balance_file(wallet_name)
|
||||
|
||||
def _get_wallet_balance_daemon(self, wallet_name: str) -> Optional[float]:
|
||||
"""Get wallet balance using daemon"""
|
||||
try:
|
||||
if not self.is_daemon_available():
|
||||
return None
|
||||
|
||||
balance_info = self.daemon_client.get_wallet_balance(wallet_name)
|
||||
if balance_info:
|
||||
return balance_info.balance
|
||||
return None
|
||||
except Exception as e:
|
||||
error(f"Failed to get daemon wallet balance: {str(e)}")
|
||||
return None
|
||||
|
||||
def _get_wallet_balance_file(self, wallet_name: str) -> Optional[float]:
|
||||
"""Get wallet balance using file-based storage"""
|
||||
wallet_info = self._get_wallet_info_file(wallet_name)
|
||||
if wallet_info:
|
||||
return wallet_info.get("balance", 0.0)
|
||||
return None
|
||||
|
||||
def send_transaction(self, wallet_name: str, password: str, to_address: str,
|
||||
amount: float, description: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Send transaction using the appropriate mode"""
|
||||
if self.use_daemon:
|
||||
return self._send_transaction_daemon(wallet_name, password, to_address, amount, description)
|
||||
else:
|
||||
return self._send_transaction_file(wallet_name, password, to_address, amount, description)
|
||||
|
||||
def _send_transaction_daemon(self, wallet_name: str, password: str, to_address: str,
|
||||
amount: float, description: Optional[str]) -> Dict[str, Any]:
|
||||
"""Send transaction using daemon"""
|
||||
try:
|
||||
if not self.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
raise Exception("Daemon unavailable")
|
||||
|
||||
result = self.daemon_client.send_transaction(wallet_name, password, to_address, amount, description)
|
||||
|
||||
success(f"Sent {amount} AITBC to {to_address} via daemon")
|
||||
return {
|
||||
"mode": "daemon",
|
||||
"wallet_name": wallet_name,
|
||||
"to_address": to_address,
|
||||
"amount": amount,
|
||||
"description": description,
|
||||
"tx_hash": result.get("tx_hash"),
|
||||
"timestamp": result.get("timestamp")
|
||||
}
|
||||
except Exception as e:
|
||||
error(f"Failed to send daemon transaction: {str(e)}")
|
||||
raise
|
||||
|
||||
def _send_transaction_file(self, wallet_name: str, password: str, to_address: str,
|
||||
amount: float, description: Optional[str]) -> Dict[str, Any]:
|
||||
"""Send transaction using file-based storage"""
|
||||
from .commands.wallet import _load_wallet, _save_wallet
|
||||
|
||||
wallet_path = self.wallet_dir / f"{wallet_name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
wallet_data = _load_wallet(wallet_path, wallet_name)
|
||||
balance = wallet_data.get("balance", 0)
|
||||
|
||||
if balance < amount:
|
||||
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
|
||||
raise Exception("Insufficient balance")
|
||||
|
||||
# Add transaction
|
||||
transaction = {
|
||||
"type": "send",
|
||||
"amount": -amount,
|
||||
"to_address": to_address,
|
||||
"description": description or "",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
wallet_data["transactions"].append(transaction)
|
||||
wallet_data["balance"] = balance - amount
|
||||
|
||||
# Save wallet
|
||||
save_password = password if wallet_data.get("encrypted") else None
|
||||
_save_wallet(wallet_path, wallet_data, save_password)
|
||||
|
||||
success(f"Sent {amount} AITBC to {to_address}")
|
||||
return {
|
||||
"mode": "file",
|
||||
"wallet_name": wallet_name,
|
||||
"to_address": to_address,
|
||||
"amount": amount,
|
||||
"description": description,
|
||||
"new_balance": wallet_data["balance"],
|
||||
"timestamp": transaction["timestamp"]
|
||||
}
|
||||
|
||||
def delete_wallet(self, wallet_name: str, password: str) -> bool:
|
||||
"""Delete wallet using the appropriate mode"""
|
||||
if self.use_daemon:
|
||||
return self._delete_wallet_daemon(wallet_name, password)
|
||||
else:
|
||||
return self._delete_wallet_file(wallet_name, password)
|
||||
|
||||
def _delete_wallet_daemon(self, wallet_name: str, password: str) -> bool:
|
||||
"""Delete wallet using daemon"""
|
||||
try:
|
||||
if not self.is_daemon_available():
|
||||
return False
|
||||
|
||||
return self.daemon_client.delete_wallet(wallet_name, password)
|
||||
except Exception as e:
|
||||
error(f"Failed to delete daemon wallet: {str(e)}")
|
||||
return False
|
||||
|
||||
def _delete_wallet_file(self, wallet_name: str, password: str) -> bool:
|
||||
"""Delete wallet using file-based storage"""
|
||||
wallet_path = self.wallet_dir / f"{wallet_name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return False
|
||||
|
||||
try:
|
||||
wallet_path.unlink()
|
||||
success(f"Deleted wallet: {wallet_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
error(f"Failed to delete wallet: {str(e)}")
|
||||
return False
|
||||
|
||||
# Multi-Chain Methods
|
||||
|
||||
def list_chains(self) -> List[Dict[str, Any]]:
|
||||
"""List all blockchain chains"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain listing requires daemon mode")
|
||||
return []
|
||||
|
||||
try:
|
||||
chains = self.daemon_client.list_chains()
|
||||
return [
|
||||
{
|
||||
"chain_id": chain.chain_id,
|
||||
"name": chain.name,
|
||||
"status": chain.status,
|
||||
"coordinator_url": chain.coordinator_url,
|
||||
"created_at": chain.created_at,
|
||||
"updated_at": chain.updated_at,
|
||||
"wallet_count": chain.wallet_count,
|
||||
"recent_activity": chain.recent_activity
|
||||
}
|
||||
for chain in chains
|
||||
]
|
||||
except Exception as e:
|
||||
error(f"Failed to list chains: {str(e)}")
|
||||
return []
|
||||
|
||||
def create_chain(self, chain_id: str, name: str, coordinator_url: str,
|
||||
coordinator_api_key: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Create a new blockchain chain"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain creation requires daemon mode")
|
||||
return None
|
||||
|
||||
try:
|
||||
chain = self.daemon_client.create_chain(chain_id, name, coordinator_url, coordinator_api_key, metadata)
|
||||
return {
|
||||
"chain_id": chain.chain_id,
|
||||
"name": chain.name,
|
||||
"status": chain.status,
|
||||
"coordinator_url": chain.coordinator_url,
|
||||
"created_at": chain.created_at,
|
||||
"updated_at": chain.updated_at,
|
||||
"wallet_count": chain.wallet_count,
|
||||
"recent_activity": chain.recent_activity
|
||||
}
|
||||
except Exception as e:
|
||||
error(f"Failed to create chain: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_wallet_in_chain(self, chain_id: str, wallet_name: str, password: str,
|
||||
wallet_type: str = "hd", metadata: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Create a wallet in a specific chain"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain-specific wallet creation requires daemon mode")
|
||||
return None
|
||||
|
||||
try:
|
||||
wallet = self.daemon_client.create_wallet_in_chain(chain_id, wallet_name, password, metadata)
|
||||
return {
|
||||
"mode": "daemon",
|
||||
"chain_id": chain_id,
|
||||
"wallet_name": wallet.wallet_id,
|
||||
"public_key": wallet.public_key,
|
||||
"address": wallet.address,
|
||||
"created_at": wallet.created_at,
|
||||
"wallet_type": wallet_type,
|
||||
"metadata": wallet.metadata or {}
|
||||
}
|
||||
except Exception as e:
|
||||
error(f"Failed to create wallet in chain {chain_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def list_wallets_in_chain(self, chain_id: str) -> List[Dict[str, Any]]:
|
||||
"""List wallets in a specific chain"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain-specific wallet listing requires daemon mode")
|
||||
return []
|
||||
|
||||
try:
|
||||
wallets = self.daemon_client.list_wallets_in_chain(chain_id)
|
||||
return [
|
||||
{
|
||||
"mode": "daemon",
|
||||
"chain_id": chain_id,
|
||||
"wallet_name": wallet.wallet_id,
|
||||
"public_key": wallet.public_key,
|
||||
"address": wallet.address,
|
||||
"created_at": wallet.created_at,
|
||||
"metadata": wallet.metadata or {}
|
||||
}
|
||||
for wallet in wallets
|
||||
]
|
||||
except Exception as e:
|
||||
error(f"Failed to list wallets in chain {chain_id}: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_wallet_info_in_chain(self, chain_id: str, wallet_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get wallet information from a specific chain"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain-specific wallet info requires daemon mode")
|
||||
return None
|
||||
|
||||
try:
|
||||
wallet = self.daemon_client.get_wallet_info_in_chain(chain_id, wallet_name)
|
||||
if wallet:
|
||||
return {
|
||||
"mode": "daemon",
|
||||
"chain_id": chain_id,
|
||||
"wallet_name": wallet.wallet_id,
|
||||
"public_key": wallet.public_key,
|
||||
"address": wallet.address,
|
||||
"created_at": wallet.created_at,
|
||||
"metadata": wallet.metadata or {}
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
error(f"Failed to get wallet info from chain {chain_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_wallet_balance_in_chain(self, chain_id: str, wallet_name: str) -> Optional[float]:
|
||||
"""Get wallet balance in a specific chain"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain-specific balance check requires daemon mode")
|
||||
return None
|
||||
|
||||
try:
|
||||
balance = self.daemon_client.get_wallet_balance_in_chain(chain_id, wallet_name)
|
||||
return balance.balance if balance else None
|
||||
except Exception as e:
|
||||
error(f"Failed to get wallet balance in chain {chain_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def unlock_wallet_in_chain(self, chain_id: str, wallet_name: str, password: str) -> bool:
|
||||
"""Unlock a wallet in a specific chain"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain-specific wallet unlock requires daemon mode")
|
||||
return False
|
||||
|
||||
try:
|
||||
return self.daemon_client.unlock_wallet_in_chain(chain_id, wallet_name, password)
|
||||
except Exception as e:
|
||||
error(f"Failed to unlock wallet in chain {chain_id}: {str(e)}")
|
||||
return False
|
||||
|
||||
def sign_message_in_chain(self, chain_id: str, wallet_name: str, password: str, message: bytes) -> Optional[str]:
|
||||
"""Sign a message with a wallet in a specific chain"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Chain-specific message signing requires daemon mode")
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.daemon_client.sign_message_in_chain(chain_id, wallet_name, password, message)
|
||||
except Exception as e:
|
||||
error(f"Failed to sign message in chain {chain_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def migrate_wallet(self, source_chain_id: str, target_chain_id: str, wallet_name: str,
|
||||
password: str, new_password: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Migrate a wallet from one chain to another"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
error("Wallet migration requires daemon mode")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self.daemon_client.migrate_wallet(source_chain_id, target_chain_id, wallet_name, password, new_password)
|
||||
if result:
|
||||
return {
|
||||
"success": result.success,
|
||||
"source_wallet": {
|
||||
"chain_id": result.source_wallet.chain_id,
|
||||
"wallet_name": result.source_wallet.wallet_id,
|
||||
"public_key": result.source_wallet.public_key,
|
||||
"address": result.source_wallet.address
|
||||
},
|
||||
"target_wallet": {
|
||||
"chain_id": result.target_wallet.chain_id,
|
||||
"wallet_name": result.target_wallet.wallet_id,
|
||||
"public_key": result.target_wallet.public_key,
|
||||
"address": result.target_wallet.address
|
||||
},
|
||||
"migration_timestamp": result.migration_timestamp
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
error(f"Failed to migrate wallet: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_chain_status(self) -> Dict[str, Any]:
|
||||
"""Get overall chain status and statistics"""
|
||||
if not self.use_daemon or not self.is_daemon_available():
|
||||
return {"status": "disabled", "message": "Chain status requires daemon mode"}
|
||||
|
||||
try:
|
||||
return self.daemon_client.get_chain_status()
|
||||
except Exception as e:
|
||||
error(f"Failed to get chain status: {str(e)}")
|
||||
return {"error": str(e)}
|
||||
@@ -46,6 +46,7 @@ from .commands.node import node
|
||||
from .commands.analytics import analytics
|
||||
from .commands.agent_comm import agent_comm
|
||||
from .commands.deployment import deploy
|
||||
from .commands.cross_chain import cross_chain
|
||||
from .plugins import plugin, load_plugins
|
||||
|
||||
|
||||
@@ -188,6 +189,7 @@ cli.add_command(node)
|
||||
cli.add_command(analytics)
|
||||
cli.add_command(agent_comm)
|
||||
cli.add_command(deploy)
|
||||
cli.add_command(cross_chain)
|
||||
cli.add_command(plugin)
|
||||
load_plugins(cli)
|
||||
|
||||
|
||||
536
cli/aitbc_cli/wallet_daemon_client.py
Normal file
536
cli/aitbc_cli/wallet_daemon_client.py
Normal file
@@ -0,0 +1,536 @@
|
||||
"""Wallet Daemon Client for AITBC CLI
|
||||
|
||||
This module provides a client for communicating with the AITBC wallet daemon,
|
||||
supporting both REST and JSON-RPC APIs for wallet operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .utils import error, success
|
||||
from .config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChainInfo:
|
||||
"""Chain information from daemon"""
|
||||
chain_id: str
|
||||
name: str
|
||||
status: str
|
||||
coordinator_url: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
wallet_count: int
|
||||
recent_activity: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletInfo:
|
||||
"""Wallet information from daemon"""
|
||||
wallet_id: str
|
||||
chain_id: str
|
||||
public_key: str
|
||||
address: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletBalance:
|
||||
"""Wallet balance information"""
|
||||
wallet_id: str
|
||||
chain_id: str
|
||||
balance: float
|
||||
address: Optional[str] = None
|
||||
last_updated: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletMigrationResult:
|
||||
"""Result of wallet migration between chains"""
|
||||
success: bool
|
||||
source_wallet: WalletInfo
|
||||
target_wallet: WalletInfo
|
||||
migration_timestamp: str
|
||||
|
||||
|
||||
class WalletDaemonClient:
|
||||
"""Client for interacting with AITBC wallet daemon"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.base_url = config.wallet_url.rstrip('/')
|
||||
self.timeout = getattr(config, 'timeout', 30)
|
||||
|
||||
def _get_http_client(self) -> httpx.Client:
|
||||
"""Create HTTP client with appropriate settings"""
|
||||
return httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if wallet daemon is available and responsive"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.get("/health")
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get wallet daemon status information"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.get("/health")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return {"status": "unavailable", "error": f"HTTP {response.status_code}"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def create_wallet(self, wallet_id: str, password: str, metadata: Optional[Dict[str, Any]] = None) -> WalletInfo:
|
||||
"""Create a new wallet in the daemon"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {
|
||||
"wallet_id": wallet_id,
|
||||
"password": password,
|
||||
"metadata": metadata or {}
|
||||
}
|
||||
|
||||
response = client.post("/v1/wallets", json=payload)
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
return WalletInfo(
|
||||
wallet_id=data["wallet_id"],
|
||||
public_key=data["public_key"],
|
||||
address=data.get("address"),
|
||||
created_at=data.get("created_at"),
|
||||
metadata=data.get("metadata")
|
||||
)
|
||||
else:
|
||||
error(f"Failed to create wallet: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error creating wallet: {str(e)}")
|
||||
raise
|
||||
|
||||
def list_wallets(self) -> List[WalletInfo]:
|
||||
"""List all wallets in the daemon"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.get("/v1/wallets")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
wallets = []
|
||||
for wallet_data in data.get("wallets", []):
|
||||
wallets.append(WalletInfo(
|
||||
wallet_id=wallet_data["wallet_id"],
|
||||
public_key=wallet_data["public_key"],
|
||||
address=wallet_data.get("address"),
|
||||
created_at=wallet_data.get("created_at"),
|
||||
metadata=wallet_data.get("metadata")
|
||||
))
|
||||
return wallets
|
||||
else:
|
||||
error(f"Failed to list wallets: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error listing wallets: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_wallet_info(self, wallet_id: str) -> Optional[WalletInfo]:
|
||||
"""Get information about a specific wallet"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.get(f"/v1/wallets/{wallet_id}")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return WalletInfo(
|
||||
wallet_id=data["wallet_id"],
|
||||
public_key=data["public_key"],
|
||||
address=data.get("address"),
|
||||
created_at=data.get("created_at"),
|
||||
metadata=data.get("metadata")
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
return None
|
||||
else:
|
||||
error(f"Failed to get wallet info: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error getting wallet info: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_wallet_balance(self, wallet_id: str) -> Optional[WalletBalance]:
|
||||
"""Get wallet balance from daemon"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.get(f"/v1/wallets/{wallet_id}/balance")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return WalletBalance(
|
||||
wallet_id=wallet_id,
|
||||
balance=data["balance"],
|
||||
address=data.get("address"),
|
||||
last_updated=data.get("last_updated")
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
return None
|
||||
else:
|
||||
error(f"Failed to get wallet balance: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error getting wallet balance: {str(e)}")
|
||||
raise
|
||||
|
||||
def sign_message(self, wallet_id: str, password: str, message: bytes) -> str:
|
||||
"""Sign a message with wallet private key"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
# Encode message as base64 for transmission
|
||||
message_b64 = base64.b64encode(message).decode()
|
||||
|
||||
payload = {
|
||||
"password": password,
|
||||
"message": message_b64
|
||||
}
|
||||
|
||||
response = client.post(f"/v1/wallets/{wallet_id}/sign", json=payload)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data["signature_base64"]
|
||||
else:
|
||||
error(f"Failed to sign message: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error signing message: {str(e)}")
|
||||
raise
|
||||
|
||||
def send_transaction(self, wallet_id: str, password: str, to_address: str, amount: float,
|
||||
description: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Send a transaction via the daemon"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {
|
||||
"password": password,
|
||||
"to_address": to_address,
|
||||
"amount": amount,
|
||||
"description": description or ""
|
||||
}
|
||||
|
||||
response = client.post(f"/v1/wallets/{wallet_id}/send", json=payload)
|
||||
if response.status_code == 201:
|
||||
return response.json()
|
||||
else:
|
||||
error(f"Failed to send transaction: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error sending transaction: {str(e)}")
|
||||
raise
|
||||
|
||||
def unlock_wallet(self, wallet_id: str, password: str) -> bool:
|
||||
"""Unlock a wallet for operations"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {"password": password}
|
||||
response = client.post(f"/v1/wallets/{wallet_id}/unlock", json=payload)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def lock_wallet(self, wallet_id: str) -> bool:
|
||||
"""Lock a wallet"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.post(f"/v1/wallets/{wallet_id}/lock")
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_wallet(self, wallet_id: str, password: str) -> bool:
|
||||
"""Delete a wallet from daemon"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {"password": password}
|
||||
response = client.delete(f"/v1/wallets/{wallet_id}", json=payload)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Make a JSON-RPC call to the daemon"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
"id": 1
|
||||
}
|
||||
|
||||
response = client.post("/rpc", json=payload)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
error(f"JSON-RPC call failed: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error making JSON-RPC call: {str(e)}")
|
||||
raise
|
||||
|
||||
# Multi-Chain Methods
|
||||
|
||||
def list_chains(self) -> List[ChainInfo]:
|
||||
"""List all blockchain chains"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.get("/v1/chains")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
chains = []
|
||||
for chain_data in data.get("chains", []):
|
||||
chains.append(ChainInfo(
|
||||
chain_id=chain_data["chain_id"],
|
||||
name=chain_data["name"],
|
||||
status=chain_data["status"],
|
||||
coordinator_url=chain_data["coordinator_url"],
|
||||
created_at=chain_data["created_at"],
|
||||
updated_at=chain_data["updated_at"],
|
||||
wallet_count=chain_data["wallet_count"],
|
||||
recent_activity=chain_data["recent_activity"]
|
||||
))
|
||||
return chains
|
||||
else:
|
||||
error(f"Failed to list chains: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error listing chains: {str(e)}")
|
||||
raise
|
||||
|
||||
def create_chain(self, chain_id: str, name: str, coordinator_url: str,
|
||||
coordinator_api_key: str, metadata: Optional[Dict[str, Any]] = None) -> ChainInfo:
|
||||
"""Create a new blockchain chain"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {
|
||||
"chain_id": chain_id,
|
||||
"name": name,
|
||||
"coordinator_url": coordinator_url,
|
||||
"coordinator_api_key": coordinator_api_key,
|
||||
"metadata": metadata or {}
|
||||
}
|
||||
|
||||
response = client.post("/v1/chains", json=payload)
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
chain_data = data["chain"]
|
||||
return ChainInfo(
|
||||
chain_id=chain_data["chain_id"],
|
||||
name=chain_data["name"],
|
||||
status=chain_data["status"],
|
||||
coordinator_url=chain_data["coordinator_url"],
|
||||
created_at=chain_data["created_at"],
|
||||
updated_at=chain_data["updated_at"],
|
||||
wallet_count=chain_data["wallet_count"],
|
||||
recent_activity=chain_data["recent_activity"]
|
||||
)
|
||||
else:
|
||||
error(f"Failed to create chain: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error creating chain: {str(e)}")
|
||||
raise
|
||||
|
||||
def create_wallet_in_chain(self, chain_id: str, wallet_id: str, password: str,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> WalletInfo:
|
||||
"""Create a wallet in a specific chain"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {
|
||||
"chain_id": chain_id,
|
||||
"wallet_id": wallet_id,
|
||||
"password": password,
|
||||
"metadata": metadata or {}
|
||||
}
|
||||
|
||||
response = client.post(f"/v1/chains/{chain_id}/wallets", json=payload)
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
wallet_data = data["wallet"]
|
||||
return WalletInfo(
|
||||
wallet_id=wallet_data["wallet_id"],
|
||||
chain_id=wallet_data["chain_id"],
|
||||
public_key=wallet_data["public_key"],
|
||||
address=wallet_data.get("address"),
|
||||
created_at=wallet_data.get("created_at"),
|
||||
metadata=wallet_data.get("metadata")
|
||||
)
|
||||
else:
|
||||
error(f"Failed to create wallet in chain {chain_id}: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error creating wallet in chain {chain_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
def list_wallets_in_chain(self, chain_id: str) -> List[WalletInfo]:
|
||||
"""List wallets in a specific chain"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
response = client.get(f"/v1/chains/{chain_id}/wallets")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
wallets = []
|
||||
for wallet_data in data.get("items", []):
|
||||
wallets.append(WalletInfo(
|
||||
wallet_id=wallet_data["wallet_id"],
|
||||
chain_id=wallet_data["chain_id"],
|
||||
public_key=wallet_data["public_key"],
|
||||
address=wallet_data.get("address"),
|
||||
created_at=wallet_data.get("created_at"),
|
||||
metadata=wallet_data.get("metadata")
|
||||
))
|
||||
return wallets
|
||||
else:
|
||||
error(f"Failed to list wallets in chain {chain_id}: {response.text}")
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Error listing wallets in chain {chain_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_wallet_info_in_chain(self, chain_id: str, wallet_id: str) -> Optional[WalletInfo]:
|
||||
"""Get wallet information from a specific chain"""
|
||||
try:
|
||||
wallets = self.list_wallets_in_chain(chain_id)
|
||||
for wallet in wallets:
|
||||
if wallet.wallet_id == wallet_id:
|
||||
return wallet
|
||||
return None
|
||||
except Exception as e:
|
||||
error(f"Error getting wallet info from chain {chain_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def unlock_wallet_in_chain(self, chain_id: str, wallet_id: str, password: str) -> bool:
|
||||
"""Unlock a wallet in a specific chain"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {"password": password}
|
||||
response = client.post(f"/v1/chains/{chain_id}/wallets/{wallet_id}/unlock", json=payload)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def sign_message_in_chain(self, chain_id: str, wallet_id: str, password: str, message: bytes) -> Optional[str]:
|
||||
"""Sign a message with a wallet in a specific chain"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {
|
||||
"password": password,
|
||||
"message_base64": base64.b64encode(message).decode()
|
||||
}
|
||||
|
||||
response = client.post(f"/v1/chains/{chain_id}/wallets/{wallet_id}/sign", json=payload)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("signature_base64")
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_wallet_balance_in_chain(self, chain_id: str, wallet_id: str) -> Optional[WalletBalance]:
|
||||
"""Get wallet balance in a specific chain"""
|
||||
try:
|
||||
# For now, return a placeholder balance
|
||||
# In a real implementation, this would call the chain-specific balance endpoint
|
||||
wallet_info = self.get_wallet_info_in_chain(chain_id, wallet_id)
|
||||
if wallet_info:
|
||||
return WalletBalance(
|
||||
wallet_id=wallet_id,
|
||||
chain_id=chain_id,
|
||||
balance=0.0, # Placeholder
|
||||
address=wallet_info.address
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
error(f"Error getting wallet balance in chain {chain_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def migrate_wallet(self, source_chain_id: str, target_chain_id: str, wallet_id: str,
|
||||
password: str, new_password: Optional[str] = None) -> Optional[WalletMigrationResult]:
|
||||
"""Migrate a wallet from one chain to another"""
|
||||
try:
|
||||
with self._get_http_client() as client:
|
||||
payload = {
|
||||
"source_chain_id": source_chain_id,
|
||||
"target_chain_id": target_chain_id,
|
||||
"wallet_id": wallet_id,
|
||||
"password": password
|
||||
}
|
||||
if new_password:
|
||||
payload["new_password"] = new_password
|
||||
|
||||
response = client.post("/v1/wallets/migrate", json=payload)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
source_wallet = WalletInfo(
|
||||
wallet_id=data["source_wallet"]["wallet_id"],
|
||||
chain_id=data["source_wallet"]["chain_id"],
|
||||
public_key=data["source_wallet"]["public_key"],
|
||||
address=data["source_wallet"].get("address"),
|
||||
metadata=data["source_wallet"].get("metadata")
|
||||
)
|
||||
|
||||
target_wallet = WalletInfo(
|
||||
wallet_id=data["target_wallet"]["wallet_id"],
|
||||
chain_id=data["target_wallet"]["chain_id"],
|
||||
public_key=data["target_wallet"]["public_key"],
|
||||
address=data["target_wallet"].get("address"),
|
||||
metadata=data["target_wallet"].get("metadata")
|
||||
)
|
||||
|
||||
return WalletMigrationResult(
|
||||
success=data["success"],
|
||||
source_wallet=source_wallet,
|
||||
target_wallet=target_wallet,
|
||||
migration_timestamp=data["migration_timestamp"]
|
||||
)
|
||||
else:
|
||||
error(f"Failed to migrate wallet: {response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
error(f"Error migrating wallet: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_chain_status(self) -> Dict[str, Any]:
|
||||
"""Get overall chain status and statistics"""
|
||||
try:
|
||||
chains = self.list_chains()
|
||||
active_chains = [c for c in chains if c.status == "active"]
|
||||
|
||||
return {
|
||||
"total_chains": len(chains),
|
||||
"active_chains": len(active_chains),
|
||||
"total_wallets": sum(c.wallet_count for c in chains),
|
||||
"chains": [
|
||||
{
|
||||
"chain_id": chain.chain_id,
|
||||
"name": chain.name,
|
||||
"status": chain.status,
|
||||
"wallet_count": chain.wallet_count,
|
||||
"recent_activity": chain.recent_activity
|
||||
}
|
||||
for chain in chains
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
error(f"Error getting chain status: {str(e)}")
|
||||
return {"error": str(e)}
|
||||
317
cli/aitbc_cli/wallet_migration_service.py
Normal file
317
cli/aitbc_cli/wallet_migration_service.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Wallet Migration Service for AITBC CLI
|
||||
|
||||
This module provides utilities for migrating wallets between
|
||||
file-based storage and daemon-based storage.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from .wallet_daemon_client import WalletDaemonClient, WalletInfo
|
||||
from .dual_mode_wallet_adapter import DualModeWalletAdapter
|
||||
from .config import Config
|
||||
from .utils import error, success, output
|
||||
|
||||
|
||||
class WalletMigrationService:
|
||||
"""Service for migrating wallets between file-based and daemon storage"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.wallet_dir = Path.home() / ".aitbc" / "wallets"
|
||||
self.wallet_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create adapters for both modes
|
||||
self.file_adapter = DualModeWalletAdapter(config, use_daemon=False)
|
||||
self.daemon_adapter = DualModeWalletAdapter(config, use_daemon=True)
|
||||
|
||||
def is_daemon_available(self) -> bool:
|
||||
"""Check if wallet daemon is available"""
|
||||
return self.daemon_adapter.is_daemon_available()
|
||||
|
||||
def list_file_wallets(self) -> List[Dict[str, Any]]:
|
||||
"""List all file-based wallets"""
|
||||
return self.file_adapter.list_wallets()
|
||||
|
||||
def list_daemon_wallets(self) -> List[Dict[str, Any]]:
|
||||
"""List all daemon-based wallets"""
|
||||
if not self.is_daemon_available():
|
||||
return []
|
||||
return self.daemon_adapter.list_wallets()
|
||||
|
||||
def migrate_to_daemon(self, wallet_name: str, password: Optional[str] = None,
|
||||
new_password: Optional[str] = None, force: bool = False) -> Dict[str, Any]:
|
||||
"""Migrate a file-based wallet to daemon storage"""
|
||||
try:
|
||||
# Check if wallet exists in file storage
|
||||
file_wallet = self.file_adapter.get_wallet_info(wallet_name)
|
||||
if not file_wallet:
|
||||
error(f"File wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
# Check if wallet already exists in daemon
|
||||
if self.is_daemon_available():
|
||||
daemon_wallet = self.daemon_adapter.get_wallet_info(wallet_name)
|
||||
if daemon_wallet and not force:
|
||||
error(f"Wallet '{wallet_name}' already exists in daemon. Use --force to overwrite.")
|
||||
raise Exception("Wallet exists in daemon")
|
||||
|
||||
# Get wallet data from file
|
||||
wallet_path = self.wallet_dir / f"{wallet_name}.json"
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
# Prepare metadata for daemon
|
||||
metadata = {
|
||||
"migrated_from": "file",
|
||||
"migration_date": datetime.now().isoformat(),
|
||||
"original_wallet_type": wallet_data.get("wallet_type", "hd"),
|
||||
"original_balance": wallet_data.get("balance", 0.0),
|
||||
"transaction_count": len(wallet_data.get("transactions", [])),
|
||||
"original_created_at": wallet_data.get("created_at")
|
||||
}
|
||||
|
||||
# Use provided password or default
|
||||
migration_password = new_password or password or "migrate_123"
|
||||
|
||||
# Create wallet in daemon
|
||||
if self.is_daemon_available():
|
||||
daemon_wallet_info = self.daemon_adapter.create_wallet(
|
||||
wallet_name, migration_password, metadata=metadata
|
||||
)
|
||||
|
||||
success(f"Migrated wallet '{wallet_name}' to daemon")
|
||||
|
||||
return {
|
||||
"wallet_name": wallet_name,
|
||||
"source_mode": "file",
|
||||
"target_mode": "daemon",
|
||||
"migrated_at": datetime.now().isoformat(),
|
||||
"original_balance": wallet_data.get("balance", 0.0),
|
||||
"transaction_count": len(wallet_data.get("transactions", [])),
|
||||
"daemon_wallet_id": daemon_wallet_info.get("wallet_id"),
|
||||
"backup_file": str(wallet_path)
|
||||
}
|
||||
else:
|
||||
error("Wallet daemon is not available for migration")
|
||||
raise Exception("Daemon unavailable")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to migrate wallet to daemon: {str(e)}")
|
||||
raise
|
||||
|
||||
def migrate_to_file(self, wallet_name: str, password: Optional[str] = None,
|
||||
new_password: Optional[str] = None, force: bool = False) -> Dict[str, Any]:
|
||||
"""Migrate a daemon-based wallet to file storage"""
|
||||
try:
|
||||
if not self.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
raise Exception("Daemon unavailable")
|
||||
|
||||
# Check if wallet exists in daemon
|
||||
daemon_wallet = self.daemon_adapter.get_wallet_info(wallet_name)
|
||||
if not daemon_wallet:
|
||||
error(f"Daemon wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
# Check if wallet already exists in file storage
|
||||
file_wallet = self.file_adapter.get_wallet_info(wallet_name)
|
||||
if file_wallet and not force:
|
||||
error(f"Wallet '{wallet_name}' already exists in file storage. Use --force to overwrite.")
|
||||
raise Exception("Wallet exists in file storage")
|
||||
|
||||
# Get additional info from daemon
|
||||
balance_info = self.daemon_adapter.get_wallet_balance(wallet_name)
|
||||
|
||||
# Create file wallet data
|
||||
wallet_data = {
|
||||
"name": wallet_name,
|
||||
"address": daemon_wallet.get("address") or f"aitbc1{wallet_name}_migrated",
|
||||
"balance": balance_info.balance if balance_info else 0.0,
|
||||
"encrypted": bool(new_password or password),
|
||||
"private_key": f"migrated_from_daemon_{wallet_name}_{datetime.now().isoformat()}",
|
||||
"transactions": [],
|
||||
"created_at": daemon_wallet.get("created_at") or datetime.now().isoformat(),
|
||||
"wallet_type": "hd",
|
||||
"migration_metadata": {
|
||||
"migrated_from": "daemon",
|
||||
"migration_date": datetime.now().isoformat(),
|
||||
"original_wallet_id": daemon_wallet.get("wallet_id"),
|
||||
"original_public_key": daemon_wallet.get("public_key"),
|
||||
"daemon_metadata": daemon_wallet.get("metadata", {})
|
||||
}
|
||||
}
|
||||
|
||||
# Save to file
|
||||
wallet_path = self.wallet_dir / f"{wallet_name}.json"
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Migrated wallet '{wallet_name}' to file storage")
|
||||
|
||||
return {
|
||||
"wallet_name": wallet_name,
|
||||
"source_mode": "daemon",
|
||||
"target_mode": "file",
|
||||
"migrated_at": datetime.now().isoformat(),
|
||||
"balance": wallet_data["balance"],
|
||||
"wallet_file": str(wallet_path),
|
||||
"original_wallet_id": daemon_wallet.get("wallet_id")
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to migrate wallet to file: {str(e)}")
|
||||
raise
|
||||
|
||||
def sync_wallets(self, wallet_name: str, direction: str = "to_daemon") -> Dict[str, Any]:
|
||||
"""Synchronize wallet data between file and daemon modes"""
|
||||
try:
|
||||
if direction == "to_daemon":
|
||||
return self._sync_to_daemon(wallet_name)
|
||||
elif direction == "to_file":
|
||||
return self._sync_to_file(wallet_name)
|
||||
else:
|
||||
error("Invalid sync direction. Use 'to_daemon' or 'to_file'")
|
||||
raise Exception("Invalid direction")
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to sync wallet: {str(e)}")
|
||||
raise
|
||||
|
||||
def _sync_to_daemon(self, wallet_name: str) -> Dict[str, Any]:
|
||||
"""Sync wallet data from file to daemon"""
|
||||
file_wallet = self.file_adapter.get_wallet_info(wallet_name)
|
||||
if not file_wallet:
|
||||
error(f"File wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
if not self.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
raise Exception("Daemon unavailable")
|
||||
|
||||
daemon_wallet = self.daemon_adapter.get_wallet_info(wallet_name)
|
||||
if not daemon_wallet:
|
||||
error(f"Daemon wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
# Compare and sync data
|
||||
file_balance = file_wallet.get("balance", 0.0)
|
||||
daemon_balance = self.daemon_adapter.get_wallet_balance(wallet_name) or 0.0
|
||||
|
||||
sync_info = {
|
||||
"wallet_name": wallet_name,
|
||||
"sync_direction": "file_to_daemon",
|
||||
"sync_time": datetime.now().isoformat(),
|
||||
"file_balance": file_balance,
|
||||
"daemon_balance": daemon_balance,
|
||||
"balance_difference": abs(file_balance - daemon_balance),
|
||||
"sync_required": file_balance != daemon_balance
|
||||
}
|
||||
|
||||
if sync_info["sync_required"]:
|
||||
success(f"Wallet '{wallet_name}' sync required: balance difference {sync_info['balance_difference']}")
|
||||
else:
|
||||
success(f"Wallet '{wallet_name}' already in sync")
|
||||
|
||||
return sync_info
|
||||
|
||||
def _sync_to_file(self, wallet_name: str) -> Dict[str, Any]:
|
||||
"""Sync wallet data from daemon to file"""
|
||||
if not self.is_daemon_available():
|
||||
error("Wallet daemon is not available")
|
||||
raise Exception("Daemon unavailable")
|
||||
|
||||
daemon_wallet = self.daemon_adapter.get_wallet_info(wallet_name)
|
||||
if not daemon_wallet:
|
||||
error(f"Daemon wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
file_wallet = self.file_adapter.get_wallet_info(wallet_name)
|
||||
if not file_wallet:
|
||||
error(f"File wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
# Compare and sync data
|
||||
file_balance = file_wallet.get("balance", 0.0)
|
||||
daemon_balance = self.daemon_adapter.get_wallet_balance(wallet_name) or 0.0
|
||||
|
||||
sync_info = {
|
||||
"wallet_name": wallet_name,
|
||||
"sync_direction": "daemon_to_file",
|
||||
"sync_time": datetime.now().isoformat(),
|
||||
"file_balance": file_balance,
|
||||
"daemon_balance": daemon_balance,
|
||||
"balance_difference": abs(file_balance - daemon_balance),
|
||||
"sync_required": file_balance != daemon_balance
|
||||
}
|
||||
|
||||
if sync_info["sync_required"]:
|
||||
success(f"Wallet '{wallet_name}' sync required: balance difference {sync_info['balance_difference']}")
|
||||
else:
|
||||
success(f"Wallet '{wallet_name}' already in sync")
|
||||
|
||||
return sync_info
|
||||
|
||||
def get_migration_status(self) -> Dict[str, Any]:
|
||||
"""Get overall migration status"""
|
||||
try:
|
||||
file_wallets = self.list_file_wallets()
|
||||
daemon_wallets = self.list_daemon_wallets() if self.is_daemon_available() else []
|
||||
|
||||
file_wallet_names = {w["wallet_name"] for w in file_wallets}
|
||||
daemon_wallet_names = {w["wallet_name"] for w in daemon_wallets}
|
||||
|
||||
# Categorize wallets
|
||||
file_only = file_wallet_names - daemon_wallet_names
|
||||
daemon_only = daemon_wallet_names - file_wallet_names
|
||||
both_modes = file_wallet_names & daemon_wallet_names
|
||||
|
||||
status = {
|
||||
"daemon_available": self.is_daemon_available(),
|
||||
"total_file_wallets": len(file_wallets),
|
||||
"total_daemon_wallets": len(daemon_wallets),
|
||||
"file_only_wallets": list(file_only),
|
||||
"daemon_only_wallets": list(daemon_only),
|
||||
"both_modes_wallets": list(both_modes),
|
||||
"migration_candidates": list(file_only),
|
||||
"sync_candidates": list(both_modes)
|
||||
}
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to get migration status: {str(e)}")
|
||||
return {
|
||||
"daemon_available": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def backup_wallet(self, wallet_name: str, backup_path: Optional[str] = None) -> str:
|
||||
"""Create a backup of a wallet file"""
|
||||
try:
|
||||
wallet_path = self.wallet_dir / f"{wallet_name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
raise Exception("Wallet not found")
|
||||
|
||||
if not backup_path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_filename = f"{wallet_name}_backup_{timestamp}.json"
|
||||
backup_path = self.wallet_dir / "backups" / backup_filename
|
||||
|
||||
# Create backup directory
|
||||
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy wallet file
|
||||
shutil.copy2(wallet_path, backup_path)
|
||||
|
||||
success(f"Wallet backup created: {backup_path}")
|
||||
return str(backup_path)
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to backup wallet: {str(e)}")
|
||||
raise
|
||||
Reference in New Issue
Block a user