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:
oib
2026-02-12 20:58:21 +01:00
parent 5120861e17
commit 65b63de56f
47 changed files with 5622 additions and 1148 deletions

View File

@@ -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)

View 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'))

View File

@@ -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'])

View File

@@ -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'))

View File

@@ -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)