Files
aitbc/cli/aitbc_cli/commands/wallet.py
oib 65b63de56f docs: update README with comprehensive test results, CLI documentation, and enhanced feature descriptions
- Update key capabilities to include GPU marketplace, payments, billing, and governance
- Expand CLI section from basic examples to 12 command groups with 90+ subcommands
- Add detailed test results table showing 208 passing tests across 6 test suites
- Update documentation links to reference new CLI reference and coordinator API docs
- Revise test commands to reflect actual test structure (
2026-02-12 20:58:21 +01:00

1187 lines
38 KiB
Python

"""Wallet commands for AITBC CLI"""
import click
import httpx
import json
import os
import shutil
import yaml
from pathlib import Path
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from ..utils import output, error, success
@click.group()
@click.option("--wallet-name", help="Name of the wallet to use")
@click.option("--wallet-path", help="Direct path to wallet file (overrides --wallet-name)")
@click.pass_context
def wallet(ctx, wallet_name: Optional[str], wallet_path: Optional[str]):
"""Manage your AITBC wallets and transactions"""
# Ensure wallet object exists
ctx.ensure_object(dict)
# If direct wallet path is provided, use it
if wallet_path:
wp = Path(wallet_path)
wp.parent.mkdir(parents=True, exist_ok=True)
ctx.obj['wallet_name'] = wp.stem
ctx.obj['wallet_dir'] = wp.parent
ctx.obj['wallet_path'] = wp
return
# Set wallet directory
wallet_dir = Path.home() / ".aitbc" / "wallets"
wallet_dir.mkdir(parents=True, exist_ok=True)
# Set active wallet
if not wallet_name:
# Try to get from config or use 'default'
config_file = Path.home() / ".aitbc" / "config.yaml"
if config_file.exists():
with open(config_file, 'r') as f:
config = yaml.safe_load(f)
if config:
wallet_name = config.get('active_wallet', 'default')
else:
wallet_name = 'default'
else:
wallet_name = 'default'
ctx.obj['wallet_name'] = wallet_name
ctx.obj['wallet_dir'] = wallet_dir
ctx.obj['wallet_path'] = wallet_dir / f"{wallet_name}.json"
@wallet.command()
@click.argument('name')
@click.option('--type', 'wallet_type', default='hd', help='Wallet type (hd, simple)')
@click.pass_context
def create(ctx, name: str, wallet_type: str):
"""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
seed = secrets.token_hex(32)
address = f"aitbc1{seed[:40]}"
private_key = f"0x{seed}"
public_key = f"0x{secrets.token_hex(32)}"
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": []
}
# Save wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Wallet '{name}' created successfully")
output({
"name": name,
"type": wallet_type,
"address": address,
"path": str(wallet_path)
}, ctx.obj.get('output_format', 'table'))
@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)
wallets.append({
"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
})
output(wallets, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument('name')
@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():
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}'")
output({
"active_wallet": name,
"address": json.load(open(wallet_path))['address']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument('name')
@click.option('--confirm', is_flag=True, help='Skip confirmation prompt')
@click.pass_context
def delete(ctx, name: str, confirm: bool):
"""Delete a wallet"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if not wallet_path.exists():
error(f"Wallet '{name}' does not exist")
return
if not confirm:
if not click.confirm(f"Are you sure you want to delete wallet '{name}'? This cannot be undone."):
return
wallet_path.unlink()
success(f"Wallet '{name}' deleted")
# If deleted wallet was active, reset to default
config_file = Path.home() / ".aitbc" / "config.yaml"
if config_file.exists():
import yaml
with open(config_file, 'r') as f:
config = yaml.safe_load(f) or {}
if config.get('active_wallet') == name:
config['active_wallet'] = 'default'
with open(config_file, 'w') as f:
yaml.dump(config, f, default_flow_style=False)
@wallet.command()
@click.argument('name')
@click.option('--destination', help='Destination path for backup file')
@click.pass_context
def backup(ctx, name: str, destination: Optional[str]):
"""Backup a wallet"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if not wallet_path.exists():
error(f"Wallet '{name}' does not exist")
return
if not destination:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
destination = f"{name}_backup_{timestamp}.json"
# Copy wallet file
shutil.copy2(wallet_path, destination)
success(f"Wallet '{name}' backed up to '{destination}'")
output({
"wallet": name,
"backup_path": destination,
"timestamp": datetime.utcnow().isoformat() + "Z"
})
@wallet.command()
@click.argument('backup_path')
@click.argument('name')
@click.option('--force', is_flag=True, help='Override existing wallet')
@click.pass_context
def restore(ctx, backup_path: str, name: str, force: bool):
"""Restore a wallet from backup"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if wallet_path.exists() and not force:
error(f"Wallet '{name}' already exists. Use --force to override.")
return
if not Path(backup_path).exists():
error(f"Backup file '{backup_path}' not found")
return
# Load and verify backup
with open(backup_path, 'r') as f:
wallet_data = json.load(f)
# Update wallet name if needed
wallet_data['wallet_id'] = name
wallet_data['restored_at'] = datetime.utcnow().isoformat() + "Z"
# Save restored wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Wallet '{name}' restored from backup")
output({
"wallet": name,
"restored_from": backup_path,
"address": wallet_data['address']
})
@wallet.command()
@click.pass_context
def info(ctx):
"""Show current wallet information"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
config_file = Path.home() / ".aitbc" / "config.yaml"
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found. Use 'aitbc wallet create' to create one.")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# Get active wallet from config
active_wallet = 'default'
if config_file.exists():
import yaml
with open(config_file, 'r') as f:
config = yaml.safe_load(f)
active_wallet = config.get('active_wallet', 'default')
wallet_info = {
"name": wallet_data['wallet_id'],
"type": wallet_data.get('type', 'simple'),
"address": wallet_data['address'],
"public_key": wallet_data['public_key'],
"created_at": wallet_data['created_at'],
"active": wallet_data['wallet_id'] == active_wallet,
"path": str(wallet_path)
}
if 'balance' in wallet_data:
wallet_info['balance'] = wallet_data['balance']
output(wallet_info, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.pass_context
def balance(ctx):
"""Check wallet balance"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
config = ctx.obj.get('config')
# Auto-create wallet if it doesn't exist
if not wallet_path.exists():
import secrets
wallet_data = {
"wallet_id": wallet_name,
"type": "simple",
"address": f"aitbc1{secrets.token_hex(20)}",
"public_key": f"0x{secrets.token_hex(32)}",
"private_key": f"0x{secrets.token_hex(32)}",
"created_at": datetime.utcnow().isoformat() + "Z",
"balance": 0.0,
"transactions": []
}
wallet_path.parent.mkdir(parents=True, exist_ok=True)
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
else:
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# Try to get balance from blockchain if available
if config:
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url.replace('/api', '')}/rpc/balance/{wallet_data['address']}",
timeout=5
)
if response.status_code == 200:
blockchain_balance = response.json().get('balance', 0)
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"local_balance": wallet_data.get('balance', 0),
"blockchain_balance": blockchain_balance,
"synced": wallet_data.get('balance', 0) == blockchain_balance
}, ctx.obj.get('output_format', 'table'))
return
except:
pass
# Fallback to local balance only
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"balance": wallet_data.get('balance', 0),
"note": "Local balance only (blockchain not accessible)"
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.option("--limit", type=int, default=10, help="Number of transactions to show")
@click.pass_context
def history(ctx, limit: int):
"""Show transaction history"""
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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
transactions = wallet_data.get('transactions', [])[-limit:]
# Format transactions
formatted_txs = []
for tx in transactions:
formatted_txs.append({
"type": tx['type'],
"amount": tx['amount'],
"description": tx.get('description', ''),
"timestamp": tx['timestamp']
})
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"transactions": formatted_txs
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("amount", type=float)
@click.argument("job_id")
@click.option("--desc", help="Description of the work")
@click.pass_context
def earn(ctx, amount: float, job_id: str, desc: Optional[str]):
"""Add earnings from completed job"""
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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# Add transaction
transaction = {
"type": "earn",
"amount": amount,
"job_id": job_id,
"description": desc or f"Job {job_id}",
"timestamp": datetime.now().isoformat()
}
wallet_data['transactions'].append(transaction)
wallet_data['balance'] = wallet_data.get('balance', 0) + amount
# Save wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Earnings added: {amount} AITBC")
output({
"wallet": wallet_name,
"amount": amount,
"job_id": job_id,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("amount", type=float)
@click.argument("description")
@click.pass_context
def spend(ctx, amount: float, description: str):
"""Spend AITBC"""
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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
balance = wallet_data.get('balance', 0)
if balance < amount:
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
ctx.exit(1)
return
# Add transaction
transaction = {
"type": "spend",
"amount": -amount,
"description": description,
"timestamp": datetime.now().isoformat()
}
wallet_data['transactions'].append(transaction)
wallet_data['balance'] = balance - amount
# Save wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Spent: {amount} AITBC")
output({
"wallet": wallet_name,
"amount": amount,
"description": description,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.pass_context
def address(ctx):
"""Show wallet 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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
output({
"wallet": wallet_name,
"address": wallet_data['address']
}, 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 send(ctx, to_address: str, amount: float, description: Optional[str]):
"""Send AITBC to another address"""
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")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
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.replace('/api', '')}/rpc/transactions",
json={
"from": wallet_data['address'],
"to": to_address,
"amount": amount,
"description": description or ""
},
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
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
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
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
output({
"wallet": wallet_name,
"amount": amount,
"to": to_address,
"new_balance": wallet_data['balance'],
"note": "Transaction recorded locally (pending blockchain confirmation)"
}, 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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# 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 stats(ctx):
"""Show wallet statistics"""
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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
transactions = wallet_data.get('transactions', [])
# Calculate stats
total_earned = sum(tx['amount'] for tx in transactions if tx['type'] == 'earn' and tx['amount'] > 0)
total_spent = sum(abs(tx['amount']) for tx in transactions if tx['type'] in ['spend', 'send'] and tx['amount'] < 0)
jobs_completed = len([tx for tx in transactions if tx['type'] == 'earn'])
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"current_balance": wallet_data.get('balance', 0),
"total_earned": total_earned,
"total_spent": total_spent,
"jobs_completed": jobs_completed,
"transaction_count": len(transactions),
"wallet_created": wallet_data.get('created_at')
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("amount", type=float)
@click.option("--duration", type=int, default=30, help="Staking duration in days")
@click.pass_context
def stake(ctx, amount: float, duration: int):
"""Stake AITBC tokens"""
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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
balance = wallet_data.get('balance', 0)
if balance < amount:
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
ctx.exit(1)
return
# Record stake
stake_id = f"stake_{int(datetime.now().timestamp())}"
stake_record = {
"stake_id": stake_id,
"amount": amount,
"duration_days": duration,
"start_date": datetime.now().isoformat(),
"end_date": (datetime.now() + timedelta(days=duration)).isoformat(),
"status": "active",
"apy": 5.0 + (duration / 30) * 1.5 # Higher APY for longer stakes
}
staking = wallet_data.setdefault('staking', [])
staking.append(stake_record)
wallet_data['balance'] = balance - amount
# Add transaction
wallet_data['transactions'].append({
"type": "stake",
"amount": -amount,
"stake_id": stake_id,
"description": f"Staked {amount} AITBC for {duration} days",
"timestamp": datetime.now().isoformat()
})
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Staked {amount} AITBC for {duration} days")
output({
"wallet": wallet_name,
"stake_id": stake_id,
"amount": amount,
"duration_days": duration,
"apy": stake_record['apy'],
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("stake_id")
@click.pass_context
def unstake(ctx, stake_id: str):
"""Unstake AITBC tokens"""
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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
staking = wallet_data.get('staking', [])
stake_record = next((s for s in staking if s['stake_id'] == stake_id and s['status'] == 'active'), None)
if not stake_record:
error(f"Active stake '{stake_id}' not found")
ctx.exit(1)
return
# Calculate rewards
start = datetime.fromisoformat(stake_record['start_date'])
days_staked = max(1, (datetime.now() - start).days)
daily_rate = stake_record['apy'] / 100 / 365
rewards = stake_record['amount'] * daily_rate * days_staked
# Return principal + rewards
returned = stake_record['amount'] + rewards
wallet_data['balance'] = wallet_data.get('balance', 0) + returned
stake_record['status'] = 'completed'
stake_record['rewards'] = rewards
stake_record['completed_date'] = datetime.now().isoformat()
# Add transaction
wallet_data['transactions'].append({
"type": "unstake",
"amount": returned,
"stake_id": stake_id,
"rewards": rewards,
"description": f"Unstaked {stake_record['amount']} AITBC + {rewards:.4f} rewards",
"timestamp": datetime.now().isoformat()
})
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Unstaked {stake_record['amount']} AITBC + {rewards:.4f} rewards")
output({
"wallet": wallet_name,
"stake_id": stake_id,
"principal": stake_record['amount'],
"rewards": rewards,
"total_returned": returned,
"days_staked": days_staked,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="staking-info")
@click.pass_context
def staking_info(ctx):
"""Show staking information"""
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
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
staking = wallet_data.get('staking', [])
active_stakes = [s for s in staking if s['status'] == 'active']
completed_stakes = [s for s in staking if s['status'] == 'completed']
total_staked = sum(s['amount'] for s in active_stakes)
total_rewards = sum(s.get('rewards', 0) for s in completed_stakes)
output({
"wallet": wallet_name,
"total_staked": total_staked,
"total_rewards_earned": total_rewards,
"active_stakes": len(active_stakes),
"completed_stakes": len(completed_stakes),
"stakes": [
{
"stake_id": s['stake_id'],
"amount": s['amount'],
"apy": s['apy'],
"duration_days": s['duration_days'],
"status": s['status'],
"start_date": s['start_date']
}
for s in staking
]
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="multisig-create")
@click.argument("signers", nargs=-1, required=True)
@click.option("--threshold", type=int, required=True, help="Required signatures to approve")
@click.option("--name", required=True, help="Multisig wallet name")
@click.pass_context
def multisig_create(ctx, signers: tuple, threshold: int, name: str):
"""Create a multi-signature wallet"""
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
wallet_dir.mkdir(parents=True, exist_ok=True)
multisig_path = wallet_dir / f"{name}_multisig.json"
if multisig_path.exists():
error(f"Multisig wallet '{name}' already exists")
return
if threshold > len(signers):
error(f"Threshold ({threshold}) cannot exceed number of signers ({len(signers)})")
return
import secrets
multisig_data = {
"wallet_id": name,
"type": "multisig",
"address": f"aitbc1ms{secrets.token_hex(18)}",
"signers": list(signers),
"threshold": threshold,
"created_at": datetime.now().isoformat(),
"balance": 0.0,
"transactions": [],
"pending_transactions": []
}
with open(multisig_path, "w") as f:
json.dump(multisig_data, f, indent=2)
success(f"Multisig wallet '{name}' created ({threshold}-of-{len(signers)})")
output({
"name": name,
"address": multisig_data["address"],
"signers": list(signers),
"threshold": threshold
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="multisig-propose")
@click.option("--wallet", "wallet_name", required=True, help="Multisig wallet name")
@click.argument("to_address")
@click.argument("amount", type=float)
@click.option("--description", help="Transaction description")
@click.pass_context
def multisig_propose(ctx, wallet_name: str, to_address: str, amount: float, description: Optional[str]):
"""Propose a multisig transaction"""
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
multisig_path = wallet_dir / f"{wallet_name}_multisig.json"
if not multisig_path.exists():
error(f"Multisig wallet '{wallet_name}' not found")
return
with open(multisig_path) as f:
ms_data = json.load(f)
if ms_data.get("balance", 0) < amount:
error(f"Insufficient balance. Available: {ms_data['balance']}, Required: {amount}")
ctx.exit(1)
return
import secrets
tx_id = f"mstx_{secrets.token_hex(8)}"
pending_tx = {
"tx_id": tx_id,
"to": to_address,
"amount": amount,
"description": description or "",
"proposed_at": datetime.now().isoformat(),
"proposed_by": os.environ.get("USER", "unknown"),
"signatures": [],
"status": "pending"
}
ms_data.setdefault("pending_transactions", []).append(pending_tx)
with open(multisig_path, "w") as f:
json.dump(ms_data, f, indent=2)
success(f"Transaction proposed: {tx_id}")
output({
"tx_id": tx_id,
"to": to_address,
"amount": amount,
"signatures_needed": ms_data["threshold"],
"status": "pending"
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="multisig-sign")
@click.option("--wallet", "wallet_name", required=True, help="Multisig wallet name")
@click.argument("tx_id")
@click.option("--signer", required=True, help="Signer address")
@click.pass_context
def multisig_sign(ctx, wallet_name: str, tx_id: str, signer: str):
"""Sign a pending multisig transaction"""
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
multisig_path = wallet_dir / f"{wallet_name}_multisig.json"
if not multisig_path.exists():
error(f"Multisig wallet '{wallet_name}' not found")
return
with open(multisig_path) as f:
ms_data = json.load(f)
if signer not in ms_data.get("signers", []):
error(f"'{signer}' is not an authorized signer")
ctx.exit(1)
return
pending = ms_data.get("pending_transactions", [])
tx = next((t for t in pending if t["tx_id"] == tx_id and t["status"] == "pending"), None)
if not tx:
error(f"Pending transaction '{tx_id}' not found")
ctx.exit(1)
return
if signer in tx["signatures"]:
error(f"'{signer}' has already signed this transaction")
return
tx["signatures"].append(signer)
# Check if threshold met
if len(tx["signatures"]) >= ms_data["threshold"]:
tx["status"] = "approved"
# Execute the transaction
ms_data["balance"] = ms_data.get("balance", 0) - tx["amount"]
ms_data["transactions"].append({
"type": "multisig_send",
"amount": -tx["amount"],
"to": tx["to"],
"tx_id": tx["tx_id"],
"signatures": tx["signatures"],
"timestamp": datetime.now().isoformat()
})
success(f"Transaction {tx_id} approved and executed!")
else:
success(f"Signed. {len(tx['signatures'])}/{ms_data['threshold']} signatures collected")
with open(multisig_path, "w") as f:
json.dump(ms_data, f, indent=2)
output({
"tx_id": tx_id,
"signatures": tx["signatures"],
"threshold": ms_data["threshold"],
"status": tx["status"]
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="liquidity-stake")
@click.argument("amount", type=float)
@click.option("--pool", default="main", help="Liquidity pool name")
@click.option("--lock-days", type=int, default=0, help="Lock period in days (higher APY)")
@click.pass_context
def liquidity_stake(ctx, amount: float, pool: str, lock_days: int):
"""Stake tokens into a liquidity pool"""
wallet_path = ctx.obj.get('wallet_path')
if not wallet_path or not Path(wallet_path).exists():
error("Wallet not found")
ctx.exit(1)
return
with open(wallet_path) as f:
wallet_data = json.load(f)
balance = wallet_data.get('balance', 0)
if balance < amount:
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
ctx.exit(1)
return
# APY tiers based on lock period
if lock_days >= 90:
apy = 12.0
tier = "platinum"
elif lock_days >= 30:
apy = 8.0
tier = "gold"
elif lock_days >= 7:
apy = 5.0
tier = "silver"
else:
apy = 3.0
tier = "bronze"
import secrets
stake_id = f"liq_{secrets.token_hex(6)}"
now = datetime.now()
liq_record = {
"stake_id": stake_id,
"pool": pool,
"amount": amount,
"apy": apy,
"tier": tier,
"lock_days": lock_days,
"start_date": now.isoformat(),
"unlock_date": (now + timedelta(days=lock_days)).isoformat() if lock_days > 0 else None,
"status": "active"
}
wallet_data.setdefault('liquidity', []).append(liq_record)
wallet_data['balance'] = balance - amount
wallet_data['transactions'].append({
"type": "liquidity_stake",
"amount": -amount,
"pool": pool,
"stake_id": stake_id,
"timestamp": now.isoformat()
})
with open(wallet_path, "w") as f:
json.dump(wallet_data, f, indent=2)
success(f"Staked {amount} AITBC into '{pool}' pool ({tier} tier, {apy}% APY)")
output({
"stake_id": stake_id,
"pool": pool,
"amount": amount,
"apy": apy,
"tier": tier,
"lock_days": lock_days,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="liquidity-unstake")
@click.argument("stake_id")
@click.pass_context
def liquidity_unstake(ctx, stake_id: str):
"""Withdraw from a liquidity pool with rewards"""
wallet_path = ctx.obj.get('wallet_path')
if not wallet_path or not Path(wallet_path).exists():
error("Wallet not found")
ctx.exit(1)
return
with open(wallet_path) as f:
wallet_data = json.load(f)
liquidity = wallet_data.get('liquidity', [])
record = next((r for r in liquidity if r["stake_id"] == stake_id and r["status"] == "active"), None)
if not record:
error(f"Active liquidity stake '{stake_id}' not found")
ctx.exit(1)
return
# Check lock period
if record.get("unlock_date"):
unlock = datetime.fromisoformat(record["unlock_date"])
if datetime.now() < unlock:
error(f"Stake is locked until {record['unlock_date']}")
ctx.exit(1)
return
# Calculate rewards
start = datetime.fromisoformat(record["start_date"])
days_staked = max((datetime.now() - start).total_seconds() / 86400, 0.001)
rewards = record["amount"] * (record["apy"] / 100) * (days_staked / 365)
total = record["amount"] + rewards
record["status"] = "completed"
record["end_date"] = datetime.now().isoformat()
record["rewards"] = round(rewards, 6)
wallet_data['balance'] = wallet_data.get('balance', 0) + total
wallet_data['transactions'].append({
"type": "liquidity_unstake",
"amount": total,
"principal": record["amount"],
"rewards": round(rewards, 6),
"pool": record["pool"],
"stake_id": stake_id,
"timestamp": datetime.now().isoformat()
})
with open(wallet_path, "w") as f:
json.dump(wallet_data, f, indent=2)
success(f"Withdrawn {total:.6f} AITBC (principal: {record['amount']}, rewards: {rewards:.6f})")
output({
"stake_id": stake_id,
"pool": record["pool"],
"principal": record["amount"],
"rewards": round(rewards, 6),
"total_returned": round(total, 6),
"days_staked": round(days_staked, 2),
"apy": record["apy"],
"new_balance": round(wallet_data['balance'], 6)
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.pass_context
def rewards(ctx):
"""View all earned rewards (staking + liquidity)"""
wallet_path = ctx.obj.get('wallet_path')
if not wallet_path or not Path(wallet_path).exists():
error("Wallet not found")
ctx.exit(1)
return
with open(wallet_path) as f:
wallet_data = json.load(f)
staking = wallet_data.get('staking', [])
liquidity = wallet_data.get('liquidity', [])
# Staking rewards
staking_rewards = sum(s.get('rewards', 0) for s in staking if s.get('status') == 'completed')
active_staking = sum(s['amount'] for s in staking if s.get('status') == 'active')
# Liquidity rewards
liq_rewards = sum(r.get('rewards', 0) for r in liquidity if r.get('status') == 'completed')
active_liquidity = sum(r['amount'] for r in liquidity if r.get('status') == 'active')
# Estimate pending rewards for active positions
pending_staking = 0
for s in staking:
if s.get('status') == 'active':
start = datetime.fromisoformat(s['start_date'])
days = max((datetime.now() - start).total_seconds() / 86400, 0)
pending_staking += s['amount'] * (s['apy'] / 100) * (days / 365)
pending_liquidity = 0
for r in liquidity:
if r.get('status') == 'active':
start = datetime.fromisoformat(r['start_date'])
days = max((datetime.now() - start).total_seconds() / 86400, 0)
pending_liquidity += r['amount'] * (r['apy'] / 100) * (days / 365)
output({
"staking_rewards_earned": round(staking_rewards, 6),
"staking_rewards_pending": round(pending_staking, 6),
"staking_active_amount": active_staking,
"liquidity_rewards_earned": round(liq_rewards, 6),
"liquidity_rewards_pending": round(pending_liquidity, 6),
"liquidity_active_amount": active_liquidity,
"total_earned": round(staking_rewards + liq_rewards, 6),
"total_pending": round(pending_staking + pending_liquidity, 6),
"total_staked": active_staking + active_liquidity
}, ctx.obj.get('output_format', 'table'))