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 (
This commit is contained in:
@@ -371,3 +371,129 @@ def template(ctx, action: str, name: Optional[str], job_type: Optional[str],
|
||||
return
|
||||
tf.unlink()
|
||||
output({"status": "deleted", "name": name}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@client.command(name="pay")
|
||||
@click.argument("job_id")
|
||||
@click.argument("amount", type=float)
|
||||
@click.option("--currency", default="AITBC", help="Payment currency")
|
||||
@click.option("--method", "payment_method", default="aitbc_token", type=click.Choice(["aitbc_token", "bitcoin"]), help="Payment method")
|
||||
@click.option("--escrow-timeout", type=int, default=3600, help="Escrow timeout in seconds")
|
||||
@click.pass_context
|
||||
def pay(ctx, job_id: str, amount: float, currency: str, payment_method: str, escrow_timeout: int):
|
||||
"""Create a payment for a job"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.post(
|
||||
f"{config.coordinator_url}/v1/payments",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={
|
||||
"job_id": job_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"payment_method": payment_method,
|
||||
"escrow_timeout_seconds": escrow_timeout
|
||||
}
|
||||
)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
success(f"Payment created for job {job_id}")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Payment failed: {response.status_code} - {response.text}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="payment-status")
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def payment_status(ctx, job_id: str):
|
||||
"""Get payment status for a job"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.get(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}/payment",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
output(response.json(), ctx.obj['output_format'])
|
||||
elif response.status_code == 404:
|
||||
error(f"No payment found for job {job_id}")
|
||||
ctx.exit(1)
|
||||
else:
|
||||
error(f"Failed: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="payment-receipt")
|
||||
@click.argument("payment_id")
|
||||
@click.pass_context
|
||||
def payment_receipt(ctx, payment_id: str):
|
||||
"""Get payment receipt with verification"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.get(
|
||||
f"{config.coordinator_url}/v1/payments/{payment_id}/receipt",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
output(response.json(), ctx.obj['output_format'])
|
||||
elif response.status_code == 404:
|
||||
error(f"Payment '{payment_id}' not found")
|
||||
ctx.exit(1)
|
||||
else:
|
||||
error(f"Failed: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="refund")
|
||||
@click.argument("job_id")
|
||||
@click.argument("payment_id")
|
||||
@click.option("--reason", required=True, help="Reason for refund")
|
||||
@click.pass_context
|
||||
def refund(ctx, job_id: str, payment_id: str, reason: str):
|
||||
"""Request a refund for a payment"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.post(
|
||||
f"{config.coordinator_url}/v1/payments/{payment_id}/refund",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={
|
||||
"job_id": job_id,
|
||||
"payment_id": payment_id,
|
||||
"reason": reason
|
||||
}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
success(f"Refund processed for payment {payment_id}")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Refund failed: {response.status_code} - {response.text}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
253
cli/aitbc_cli/commands/governance.py
Normal file
253
cli/aitbc_cli/commands/governance.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Governance commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
GOVERNANCE_DIR = Path.home() / ".aitbc" / "governance"
|
||||
|
||||
|
||||
def _ensure_governance_dir():
|
||||
GOVERNANCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
proposals_file = GOVERNANCE_DIR / "proposals.json"
|
||||
if not proposals_file.exists():
|
||||
with open(proposals_file, "w") as f:
|
||||
json.dump({"proposals": []}, f, indent=2)
|
||||
return proposals_file
|
||||
|
||||
|
||||
def _load_proposals():
|
||||
proposals_file = _ensure_governance_dir()
|
||||
with open(proposals_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_proposals(data):
|
||||
proposals_file = _ensure_governance_dir()
|
||||
with open(proposals_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
@click.group()
|
||||
def governance():
|
||||
"""Governance proposals and voting"""
|
||||
pass
|
||||
|
||||
|
||||
@governance.command()
|
||||
@click.argument("title")
|
||||
@click.option("--description", required=True, help="Proposal description")
|
||||
@click.option("--type", "proposal_type", type=click.Choice(["parameter_change", "feature_toggle", "funding", "general"]), default="general", help="Proposal type")
|
||||
@click.option("--parameter", help="Parameter to change (for parameter_change type)")
|
||||
@click.option("--value", help="New value (for parameter_change type)")
|
||||
@click.option("--amount", type=float, help="Funding amount (for funding type)")
|
||||
@click.option("--duration", type=int, default=7, help="Voting duration in days")
|
||||
@click.pass_context
|
||||
def propose(ctx, title: str, description: str, proposal_type: str,
|
||||
parameter: Optional[str], value: Optional[str],
|
||||
amount: Optional[float], duration: int):
|
||||
"""Create a governance proposal"""
|
||||
import secrets
|
||||
|
||||
data = _load_proposals()
|
||||
proposal_id = f"prop_{secrets.token_hex(6)}"
|
||||
now = datetime.now()
|
||||
|
||||
proposal = {
|
||||
"id": proposal_id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"type": proposal_type,
|
||||
"proposer": os.environ.get("USER", "unknown"),
|
||||
"created_at": now.isoformat(),
|
||||
"voting_ends": (now + timedelta(days=duration)).isoformat(),
|
||||
"duration_days": duration,
|
||||
"status": "active",
|
||||
"votes": {"for": 0, "against": 0, "abstain": 0},
|
||||
"voters": [],
|
||||
}
|
||||
|
||||
if proposal_type == "parameter_change":
|
||||
proposal["parameter"] = parameter
|
||||
proposal["new_value"] = value
|
||||
elif proposal_type == "funding":
|
||||
proposal["amount"] = amount
|
||||
|
||||
data["proposals"].append(proposal)
|
||||
_save_proposals(data)
|
||||
|
||||
success(f"Proposal '{title}' created: {proposal_id}")
|
||||
output({
|
||||
"proposal_id": proposal_id,
|
||||
"title": title,
|
||||
"type": proposal_type,
|
||||
"status": "active",
|
||||
"voting_ends": proposal["voting_ends"],
|
||||
"duration_days": duration
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@governance.command()
|
||||
@click.argument("proposal_id")
|
||||
@click.argument("choice", type=click.Choice(["for", "against", "abstain"]))
|
||||
@click.option("--voter", default=None, help="Voter identity (defaults to $USER)")
|
||||
@click.option("--weight", type=float, default=1.0, help="Vote weight")
|
||||
@click.pass_context
|
||||
def vote(ctx, proposal_id: str, choice: str, voter: Optional[str], weight: float):
|
||||
"""Cast a vote on a proposal"""
|
||||
data = _load_proposals()
|
||||
voter = voter or os.environ.get("USER", "unknown")
|
||||
|
||||
proposal = next((p for p in data["proposals"] if p["id"] == proposal_id), None)
|
||||
if not proposal:
|
||||
error(f"Proposal '{proposal_id}' not found")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
if proposal["status"] != "active":
|
||||
error(f"Proposal is '{proposal['status']}', not active")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Check if voting period has ended
|
||||
voting_ends = datetime.fromisoformat(proposal["voting_ends"])
|
||||
if datetime.now() > voting_ends:
|
||||
proposal["status"] = "closed"
|
||||
_save_proposals(data)
|
||||
error("Voting period has ended")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Check if already voted
|
||||
if voter in proposal["voters"]:
|
||||
error(f"'{voter}' has already voted on this proposal")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
proposal["votes"][choice] += weight
|
||||
proposal["voters"].append(voter)
|
||||
_save_proposals(data)
|
||||
|
||||
total_votes = sum(proposal["votes"].values())
|
||||
success(f"Vote recorded: {choice} (weight: {weight})")
|
||||
output({
|
||||
"proposal_id": proposal_id,
|
||||
"voter": voter,
|
||||
"choice": choice,
|
||||
"weight": weight,
|
||||
"current_tally": proposal["votes"],
|
||||
"total_votes": total_votes
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@governance.command(name="list")
|
||||
@click.option("--status", type=click.Choice(["active", "closed", "approved", "rejected", "all"]), default="all", help="Filter by status")
|
||||
@click.option("--type", "proposal_type", help="Filter by proposal type")
|
||||
@click.option("--limit", type=int, default=20, help="Max proposals to show")
|
||||
@click.pass_context
|
||||
def list_proposals(ctx, status: str, proposal_type: Optional[str], limit: int):
|
||||
"""List governance proposals"""
|
||||
data = _load_proposals()
|
||||
proposals = data["proposals"]
|
||||
|
||||
# Auto-close expired proposals
|
||||
now = datetime.now()
|
||||
for p in proposals:
|
||||
if p["status"] == "active":
|
||||
voting_ends = datetime.fromisoformat(p["voting_ends"])
|
||||
if now > voting_ends:
|
||||
total = sum(p["votes"].values())
|
||||
if total > 0 and p["votes"]["for"] > p["votes"]["against"]:
|
||||
p["status"] = "approved"
|
||||
else:
|
||||
p["status"] = "rejected"
|
||||
_save_proposals(data)
|
||||
|
||||
# Filter
|
||||
if status != "all":
|
||||
proposals = [p for p in proposals if p["status"] == status]
|
||||
if proposal_type:
|
||||
proposals = [p for p in proposals if p["type"] == proposal_type]
|
||||
|
||||
proposals = proposals[-limit:]
|
||||
|
||||
if not proposals:
|
||||
output({"message": "No proposals found", "filter": status}, ctx.obj.get('output_format', 'table'))
|
||||
return
|
||||
|
||||
summary = [{
|
||||
"id": p["id"],
|
||||
"title": p["title"],
|
||||
"type": p["type"],
|
||||
"status": p["status"],
|
||||
"votes_for": p["votes"]["for"],
|
||||
"votes_against": p["votes"]["against"],
|
||||
"votes_abstain": p["votes"]["abstain"],
|
||||
"created_at": p["created_at"]
|
||||
} for p in proposals]
|
||||
|
||||
output(summary, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@governance.command()
|
||||
@click.argument("proposal_id")
|
||||
@click.pass_context
|
||||
def result(ctx, proposal_id: str):
|
||||
"""Show voting results for a proposal"""
|
||||
data = _load_proposals()
|
||||
|
||||
proposal = next((p for p in data["proposals"] if p["id"] == proposal_id), None)
|
||||
if not proposal:
|
||||
error(f"Proposal '{proposal_id}' not found")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Auto-close if expired
|
||||
now = datetime.now()
|
||||
if proposal["status"] == "active":
|
||||
voting_ends = datetime.fromisoformat(proposal["voting_ends"])
|
||||
if now > voting_ends:
|
||||
total = sum(proposal["votes"].values())
|
||||
if total > 0 and proposal["votes"]["for"] > proposal["votes"]["against"]:
|
||||
proposal["status"] = "approved"
|
||||
else:
|
||||
proposal["status"] = "rejected"
|
||||
_save_proposals(data)
|
||||
|
||||
votes = proposal["votes"]
|
||||
total = sum(votes.values())
|
||||
pct_for = (votes["for"] / total * 100) if total > 0 else 0
|
||||
pct_against = (votes["against"] / total * 100) if total > 0 else 0
|
||||
|
||||
result_data = {
|
||||
"proposal_id": proposal["id"],
|
||||
"title": proposal["title"],
|
||||
"type": proposal["type"],
|
||||
"status": proposal["status"],
|
||||
"proposer": proposal["proposer"],
|
||||
"created_at": proposal["created_at"],
|
||||
"voting_ends": proposal["voting_ends"],
|
||||
"votes_for": votes["for"],
|
||||
"votes_against": votes["against"],
|
||||
"votes_abstain": votes["abstain"],
|
||||
"total_votes": total,
|
||||
"pct_for": round(pct_for, 1),
|
||||
"pct_against": round(pct_against, 1),
|
||||
"voter_count": len(proposal["voters"]),
|
||||
"outcome": proposal["status"]
|
||||
}
|
||||
|
||||
if proposal.get("parameter"):
|
||||
result_data["parameter"] = proposal["parameter"]
|
||||
result_data["new_value"] = proposal.get("new_value")
|
||||
if proposal.get("amount"):
|
||||
result_data["amount"] = proposal["amount"]
|
||||
|
||||
output(result_data, ctx.obj.get('output_format', 'table'))
|
||||
@@ -379,3 +379,124 @@ def webhooks(ctx, action: str, name: Optional[str], url: Optional[str], events:
|
||||
output({"status": "sent", "response_code": resp.status_code}, ctx.obj['output_format'])
|
||||
except Exception as e:
|
||||
error(f"Webhook test failed: {e}")
|
||||
|
||||
|
||||
CAMPAIGNS_DIR = Path.home() / ".aitbc" / "campaigns"
|
||||
|
||||
|
||||
def _ensure_campaigns():
|
||||
CAMPAIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
campaigns_file = CAMPAIGNS_DIR / "campaigns.json"
|
||||
if not campaigns_file.exists():
|
||||
# Seed with default campaigns
|
||||
default = {"campaigns": [
|
||||
{
|
||||
"id": "staking_launch",
|
||||
"name": "Staking Launch Campaign",
|
||||
"type": "staking",
|
||||
"apy_boost": 2.0,
|
||||
"start_date": "2026-02-01T00:00:00",
|
||||
"end_date": "2026-04-01T00:00:00",
|
||||
"status": "active",
|
||||
"total_staked": 0,
|
||||
"participants": 0,
|
||||
"rewards_distributed": 0
|
||||
},
|
||||
{
|
||||
"id": "liquidity_mining_q1",
|
||||
"name": "Q1 Liquidity Mining",
|
||||
"type": "liquidity",
|
||||
"apy_boost": 3.0,
|
||||
"start_date": "2026-01-15T00:00:00",
|
||||
"end_date": "2026-03-15T00:00:00",
|
||||
"status": "active",
|
||||
"total_staked": 0,
|
||||
"participants": 0,
|
||||
"rewards_distributed": 0
|
||||
}
|
||||
]}
|
||||
with open(campaigns_file, "w") as f:
|
||||
json.dump(default, f, indent=2)
|
||||
return campaigns_file
|
||||
|
||||
|
||||
@monitor.command()
|
||||
@click.option("--status", type=click.Choice(["active", "ended", "all"]), default="all", help="Filter by status")
|
||||
@click.pass_context
|
||||
def campaigns(ctx, status: str):
|
||||
"""List active incentive campaigns"""
|
||||
campaigns_file = _ensure_campaigns()
|
||||
with open(campaigns_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
campaign_list = data.get("campaigns", [])
|
||||
|
||||
# Auto-update status
|
||||
now = datetime.now()
|
||||
for c in campaign_list:
|
||||
end = datetime.fromisoformat(c["end_date"])
|
||||
if now > end and c["status"] == "active":
|
||||
c["status"] = "ended"
|
||||
with open(campaigns_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
if status != "all":
|
||||
campaign_list = [c for c in campaign_list if c["status"] == status]
|
||||
|
||||
if not campaign_list:
|
||||
output({"message": "No campaigns found"}, ctx.obj['output_format'])
|
||||
return
|
||||
|
||||
output(campaign_list, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@monitor.command(name="campaign-stats")
|
||||
@click.argument("campaign_id", required=False)
|
||||
@click.pass_context
|
||||
def campaign_stats(ctx, campaign_id: Optional[str]):
|
||||
"""Campaign performance metrics (TVL, participants, rewards)"""
|
||||
campaigns_file = _ensure_campaigns()
|
||||
with open(campaigns_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
campaign_list = data.get("campaigns", [])
|
||||
|
||||
if campaign_id:
|
||||
campaign = next((c for c in campaign_list if c["id"] == campaign_id), None)
|
||||
if not campaign:
|
||||
error(f"Campaign '{campaign_id}' not found")
|
||||
ctx.exit(1)
|
||||
return
|
||||
targets = [campaign]
|
||||
else:
|
||||
targets = campaign_list
|
||||
|
||||
stats = []
|
||||
for c in targets:
|
||||
start = datetime.fromisoformat(c["start_date"])
|
||||
end = datetime.fromisoformat(c["end_date"])
|
||||
now = datetime.now()
|
||||
duration_days = (end - start).days
|
||||
elapsed_days = min((now - start).days, duration_days)
|
||||
progress_pct = round(elapsed_days / max(duration_days, 1) * 100, 1)
|
||||
|
||||
stats.append({
|
||||
"campaign_id": c["id"],
|
||||
"name": c["name"],
|
||||
"type": c["type"],
|
||||
"status": c["status"],
|
||||
"apy_boost": c.get("apy_boost", 0),
|
||||
"tvl": c.get("total_staked", 0),
|
||||
"participants": c.get("participants", 0),
|
||||
"rewards_distributed": c.get("rewards_distributed", 0),
|
||||
"duration_days": duration_days,
|
||||
"elapsed_days": elapsed_days,
|
||||
"progress_pct": progress_pct,
|
||||
"start_date": c["start_date"],
|
||||
"end_date": c["end_date"]
|
||||
})
|
||||
|
||||
if len(stats) == 1:
|
||||
output(stats[0], ctx.obj['output_format'])
|
||||
else:
|
||||
output(stats, ctx.obj['output_format'])
|
||||
|
||||
@@ -988,3 +988,199 @@ def multisig_sign(ctx, wallet_name: str, tx_id: str, signer: str):
|
||||
}, 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'))
|
||||
|
||||
@@ -20,6 +20,7 @@ from .commands.simulate import simulate
|
||||
from .commands.admin import admin
|
||||
from .commands.config import config
|
||||
from .commands.monitor import monitor
|
||||
from .commands.governance import governance
|
||||
from .plugins import plugin, load_plugins
|
||||
|
||||
|
||||
@@ -96,6 +97,7 @@ cli.add_command(simulate)
|
||||
cli.add_command(admin)
|
||||
cli.add_command(config)
|
||||
cli.add_command(monitor)
|
||||
cli.add_command(governance)
|
||||
cli.add_command(plugin)
|
||||
load_plugins(cli)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user