"""Chain Vault Router — Complete wallet generation & management API. Every endpoint is documented, authenticated, and audited. This implements the full ~49 endpoint API that the WordPress plugin expects. Trust: All crypto operations are deterministic. Same input = same output, ALWAYS, on ANY software. Verify with your own wallet software. """ from __future__ import annotations import logging import secrets import time from datetime import UTC, datetime from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from core.auth import get_key_store from core.audit import get_audit from core.config import cfg from core.proof import get_proof from .tx_broadcaster import broadcast as broadcast_tx from core.vault import WalletEntry, get_vault from wallet_engine.chains import CHAINS, CHAIN_GROUPS, get_chains_for_tier from wallet_engine.generator import get_generator logger = logging.getLogger("wp.chain_vault") router = APIRouter(prefix="/api/v1/chain-vault", tags=["Chain Vault"]) def _require_totp(request: Request) -> None: """Require valid TOTP code for admin operations. Shared with wallet_admin.py — kept here so chain_vault.py can use it independently. If you change the logic, update both. """ from core.totp import get_totp_manager mgr = get_totp_manager() if not mgr.is_enabled(): return code = request.headers.get("X-TOTP-Code", "") if not mgr.verify(code): raise HTTPException(status_code=401, detail="TOTP code required or invalid. Set X-TOTP-Code header.") # ── Models ─────────────────────────────────────────────────────── class GenerateRequest(BaseModel): chain: str = Field(..., description="Chain key (eth, sol, btc, etc.)") count: int = Field(default=1, ge=1, le=100, description="Number of wallets") label: str = Field(default="", description="Optional label") tags: list[str] = Field(default_factory=list) passphrase: str = Field(default="", description="BIP39 passphrase for hidden wallets") class BatchGenerateRequest(BaseModel): chains: list[str] = Field(..., description="List of chain keys") label_prefix: str = Field(default="", description="Prefix for all labels") class GenerateAllRequest(BaseModel): label_prefix: str = Field(default="") class ImportRequest(BaseModel): chain: str = Field(...) private_key: str = Field(..., description="Hex-encoded private key") label: str = Field(default="") tags: list[str] = Field(default_factory=list) class FromMnemonicRequest(BaseModel): mnemonic: str = Field(..., description="BIP39 mnemonic phrase") passphrase: str = Field(default="", description="Optional BIP39 passphrase") chains: list[str] = Field(default_factory=list, description="Chains to derive (empty = all)") class DeriveAddressRequest(BaseModel): mnemonic: str = Field(...) chain: str = Field(...) path: str = Field(default="", description="Custom derivation path (optional)") class HDWalletRequest(BaseModel): mnemonic: str = Field(...) passphrase: str = Field(default="") label: str = Field(default="") class RotateRequest(BaseModel): wallet_id: str = Field(...) reason: str = Field(default="scheduled_rotation") class RotateSweepRequest(BaseModel): wallet_id: str = Field(...) to_address: str = Field(...) class DistributeRequest(BaseModel): distributions: list[dict] = Field(..., description="[{wallet_id, to_address, amount}]") class ClusterRequest(BaseModel): chain: str = Field(...) count: int = Field(default=10, ge=1, le=1000) class EscrowCreateRequest(BaseModel): platform: str = Field(...) deposit_address: str = Field(...) conditions: dict = Field(default_factory=dict) amount: float = Field(...) class EscrowReleaseRequest(BaseModel): escrow_id: str = Field(...) class SweepRequest(BaseModel): from_wallet_id: str = Field(...) to_address: str = Field(...) class TemporalCreateRequest(BaseModel): chain: str = Field(...) ttl_seconds: int = Field(default=3600, ge=60, le=86400 * 30) label: str = Field(default="temporal") class TemporalReleaseRequest(BaseModel): temporal_id: str = Field(...) class FundingBundleRequest(BaseModel): chain: str = Field(...) wallet_id: str = Field(...) amount: float = Field(...) class DeployerSetupRequest(BaseModel): chain: str = Field(...) wallet_id: str = Field(...) class TxBuildRequest(BaseModel): chain: str = Field(...) from_address: str = Field(...) to_address: str = Field(...) amount: float = Field(...) opts: dict = Field(default_factory=dict) class TxBroadcastRequest(BaseModel): chain: str = Field(...) signed_tx: str = Field(...) class LinkProofRequest(BaseModel): source_address: str = Field(...) source_chain: str = Field(...) target_chain: str = Field(...) class LinkVerifyRequest(BaseModel): address: str = Field(...) chain: str = Field(...) proof: str = Field(...) # ── Response Models ────────────────────────────────────────────── class WalletResponse(BaseModel): id: str chain: str address: str label: str tags: list[str] = [] created_at: float encrypted: bool = False has_private_key: bool = False public_key: str = "" balance_usd: float = 0.0 class WalletDetailResponse(WalletResponse): derivation_path: str = "" hd_path: str = "" notes: str = "" private_key_hex: str = "" qr_png_base64: str = "" class GenerateResponse(BaseModel): generated: int chain: str wallets: list[dict] provenance: str note: str class VaultListResponse(BaseModel): wallets: list[WalletResponse] total: int limit: int offset: int search: str class ChainInfoResponse(BaseModel): name: str symbol: str family: str decimals: int slip44: int hd_path: str address_pattern: str explorer_url: str rpc_url: str curve: str native_token: str description: str class ChainsResponse(BaseModel): total_chains: int chain_families: int chains: dict[str, ChainInfoResponse] groups: dict[str, list[str]] class StatsResponse(BaseModel): generation_count: int chains_supported: int chain_families: int encryption_enabled: bool chains: dict vault: dict class ConfigResponse(BaseModel): version: str features: dict standards: list[str] encryption: str max_batch_size: int proof_of_generation: bool class ErrorResponse(BaseModel): error: str request_id: str = "" # ── Helpers ────────────────────────────────────────────────────── def _actor_from_request(request: Request) -> str: raw = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") return raw[:16] if raw else "anonymous" def _audit(action: str, request: Request, resource: str = "", detail: dict | None = None): get_audit().log( action=action, actor=_actor_from_request(request), resource=resource, detail=detail, ip=request.client.host if request.client else "", ) def _get_tier_from_key(request: Request) -> str: key = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") if key == cfg.admin_key: return "enterprise" ks = get_key_store() k = ks.verify(key) if k and "admin" in k.scopes: return "enterprise" return "pro" # ═══════════════════════════════════════════════════════════════════ # CHAIN INFORMATION # ═══════════════════════════════════════════════════════════════════ @router.get("/chains", response_model=ChainsResponse) async def list_chains(): """List ALL supported blockchain networks with full metadata. 55 chains across EVM, Solana, Bitcoin family, Cosmos, Substrate, Ed25519, and more. Each entry includes derivation paths, address patterns, explorer URLs, and RPC endpoints. """ tier_chains = get_chains_for_tier("enterprise") return { "total_chains": len(tier_chains), "chain_families": len({c.family.value for c in tier_chains.values()}), "chains": {k: { "name": v.name, "symbol": v.symbol, "family": v.family.value, "decimals": v.decimals, "slip44": v.slip44, "hd_path": v.hd_path, "address_pattern": v.address_pattern, "explorer_url": v.explorer_url, "rpc_url": v.rpc_url, "curve": v.curve, "native_token": v.native_token, "description": v.description, } for k, v in tier_chains.items()}, "groups": {name: chains for name, chains in CHAIN_GROUPS.items()}, "note": "All chains follow BIP44 standards. Verify with any wallet.", } @router.get("/stats", response_model=StatsResponse) async def wallet_stats(): """Get wallet generation engine statistics and health.""" vault = get_vault() generator = get_generator(cfg.vault_password) return { **generator.stats, "vault": vault.stats(), "encryption": "AES-256-GCM with Argon2id" if cfg.vault_password else "plaintext (not recommended)", } @router.get("/healthz") async def healthz(): """Lightweight health check for load balancers.""" return {"status": "ok", "timestamp": datetime.now(UTC).isoformat()} @router.get("/health-score/{wallet_id}") async def health_score(wallet_id: str): """Get wallet health score based on age, rotations, and balance.""" vault = get_vault() wallet = vault.get(wallet_id) if not wallet: raise HTTPException(status_code=404, detail="Wallet not found") rotations = vault.get_rotations(wallet_id) age_days = (time.time() - wallet.created_at) / 86400 if wallet.created_at else 0 score = 100 if age_days > 365: score -= 10 if wallet.balance_usd == 0: score -= 15 if wallet.encrypted_key and wallet.encrypted: score += 5 if len(rotations) > 0: score += min(len(rotations) * 5, 20) return { "wallet_id": wallet_id, "health_score": max(0, min(100, score)), "age_days": round(age_days, 1), "rotations": len(rotations), "has_balance": wallet.balance_usd > 0, "encrypted": wallet.encrypted, "recommendations": [], } @router.get("/rpc-chains") async def rpc_chains(): """List chains with active RPC endpoints.""" return { "chains": {k: {"rpc_url": v.rpc_url, "explorer": v.explorer_url} for k, v in CHAINS.items() if v.rpc_url}, "total": sum(1 for v in CHAINS.values() if v.rpc_url), } # ═══════════════════════════════════════════════════════════════════ # WALLET GENERATION # ═══════════════════════════════════════════════════════════════════ @router.post("/generate", response_model=GenerateResponse) async def generate_wallet(req: GenerateRequest, request: Request): """Generate one or more wallets for any supported chain. Uses CSPRNG (os.urandom) for entropy. Keys are encrypted at rest with AES-256-GCM + Argon2id if vault password is configured. Returns safe dict WITHOUT private keys. Use GET /vault/{id} to retrieve private keys with proper authentication. """ _audit("wallet.generate", request, detail={"chain": req.chain, "count": req.count}) generator = get_generator(cfg.vault_password) vault = get_vault() wallets = [] for i in range(req.count): label = req.label or f"{req.chain}_{i + 1}_{int(time.time())}" try: wallet = generator.generate(req.chain, label=label, tags=req.tags) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) entry = WalletEntry( id=f"wp_{secrets.token_hex(8)}", chain=wallet.chain, address=wallet.address, label=wallet.label, tags=wallet.tags, created_at=wallet.created_at, encrypted_key=wallet.private_key_hex if wallet.encrypted else "", public_key=wallet.public_key_hex, derivation_path=wallet.derivation_path, hd_path=wallet.derivation_path, encrypted=wallet.encrypted, ) vault.put(entry) # Create Proof of Generation attestation proof = get_proof() leaf_hash = proof.attest( wallet_id=entry.id, chain=entry.chain, address=entry.address, public_key_hex=entry.public_key, ) wallets.append({**entry.__dict__, "proof_leaf": leaf_hash}) # Fire webhook event + email notification try: from core.webhooks import get_webhook_deliverer from core.email_notify import get_notifier import asyncio asyncio.ensure_future(get_webhook_deliverer().emit("wallet.generated", { "wallet_id": entry.id, "chain": entry.chain, "address": entry.address, "label": entry.label, "proof_leaf": leaf_hash, })) asyncio.ensure_future(get_notifier().send_event("wallet.generated", { "chain": entry.chain.upper(), "address": entry.address, "label": entry.label or "unnamed", "time": __import__("time").strftime("%Y-%m-%d %H:%M:%S UTC", __import__("time").gmtime()), })) except Exception: pass # Periodically commit merkle roots (every 100 wallets) proof_db = get_proof() pstats = proof_db.stats() if pstats["total_attestations"] > 0 and pstats["total_attestations"] % 100 < req.count: root_hash, count = proof_db.compute_merkle_root() if root_hash: proof_db.commit(root_hash, count) return { "generated": len(wallets), "chain": req.chain, "wallets": [{"id": w["id"], "chain": w["chain"], "address": w["address"], "label": w["label"], "encrypted": w["encrypted"], "proof": f"/api/v1/proof/verify/{w['id']}"} for w in wallets], "provenance": "✓ Attested — visit /api/v1/proof/verify/{id} to verify", "note": "Private keys stored securely. Use GET /vault/{id} with API key to retrieve.", } @router.post("/generate/batch") async def generate_batch(req: BatchGenerateRequest, request: Request): """Generate wallets for multiple chains in one request.""" _audit("wallet.generate_batch", request, detail={"chains": req.chains}) generator = get_generator(cfg.vault_password) vault = get_vault() results = {} for chain in req.chains: try: wallet = generator.generate(chain, label=f"{req.label_prefix}_{chain}" if req.label_prefix else chain) except ValueError as e: results[chain] = {"error": str(e)} continue entry = WalletEntry( id=f"wp_{secrets.token_hex(8)}", chain=wallet.chain, address=wallet.address, label=wallet.label, tags=wallet.tags, created_at=wallet.created_at, encrypted_key=wallet.private_key_hex if wallet.encrypted else "", public_key=wallet.public_key_hex, derivation_path=wallet.derivation_path, hd_path=wallet.derivation_path, encrypted=wallet.encrypted, ) vault.put(entry) results[chain] = {"id": entry.id, "address": entry.address, "label": entry.label} return {"chains_generated": len(req.chains), "wallets": results} @router.post("/generate/all") async def generate_all(req: GenerateAllRequest, request: Request): """Generate a wallet for EVERY supported chain. Trust: Each wallet is a unique cryptographic key. No two wallets share the same private key. All are stored encrypted in the vault. """ _audit("wallet.generate_all", request) generator = get_generator(cfg.vault_password) vault = get_vault() results = {} for chain_key in CHAINS: label = f"{req.label_prefix}_{chain_key}" if req.label_prefix else f"full_suite_{chain_key}" wallet = generator.generate(chain_key, label=label) entry = WalletEntry( id=f"wp_{secrets.token_hex(8)}", chain=wallet.chain, address=wallet.address, label=wallet.label, tags=wallet.tags, created_at=wallet.created_at, encrypted_key=wallet.private_key_hex if wallet.encrypted else "", public_key=wallet.public_key_hex, derivation_path=wallet.derivation_path, hd_path=wallet.derivation_path, encrypted=wallet.encrypted, ) vault.put(entry) results[chain_key] = {"id": entry.id, "address": entry.address} return {"total_wallets": len(results), "wallets": results} @router.post("/import") async def import_wallet(req: ImportRequest, request: Request): """Import an existing wallet from a private key. Trust: We accept any valid private key. It is encrypted at rest with the same AES-256-GCM + Argon2id protection as generated keys. We never log or transmit the raw key. """ _audit("wallet.import", request, detail={"chain": req.chain}) generator = get_generator(cfg.vault_password) try: wallet = generator.import_from_private_key(req.chain, req.private_key, label=req.label, tags=req.tags) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) vault = get_vault() entry = WalletEntry( id=f"wp_{secrets.token_hex(8)}", chain=wallet.chain, address=wallet.address, label=wallet.label, tags=wallet.tags, created_at=wallet.created_at, encrypted_key=wallet.private_key_hex if wallet.encrypted else wallet.private_key_hex, encrypted=wallet.encrypted, ) vault.put(entry) return { "imported": True, "wallet": {"id": entry.id, "chain": entry.chain, "address": entry.address, "label": entry.label}, } @router.post("/from-mnemonic") async def from_mnemonic(req: FromMnemonicRequest, request: Request): """Derive wallets from a BIP39 mnemonic phrase for all or specified chains. Trust: This is the MOST IMPORTANT test. Give WalletPress ANY mnemonic phrase and it will derive the SAME addresses as Ledger, Trezor, Phantom, MetaMask, or any BIP39-compliant wallet. If it doesn't match, it's a bug. """ _audit("wallet.from_mnemonic", request) from wallet_engine.generator import validate_mnemonic try: mnemonic = validate_mnemonic(req.mnemonic) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) chains = req.chains or list(CHAINS.keys()) generator = get_generator(cfg.vault_password) vault = get_vault() results = {} for chain_key in chains: try: wallet = generator.generate(chain_key, mnemonic=mnemonic, passphrase=req.passphrase, label=f"mnemonic_{chain_key}") except ValueError: results[chain_key] = {"error": "Chain not supported"} continue entry = WalletEntry( id=f"wp_mnemonic_{secrets.token_hex(4)}", chain=wallet.chain, address=wallet.address, label=wallet.label, tags=wallet.tags + ["from_mnemonic"], created_at=wallet.created_at, encrypted_key=wallet.private_key_hex if wallet.encrypted else "", public_key=wallet.public_key_hex, derivation_path=wallet.derivation_path, mnemonic_encrypted=wallet.mnemonic, encrypted=wallet.encrypted, ) vault.put(entry) results[chain_key] = { "address": entry.address, "derivation_path": entry.derivation_path, "wallet_id": entry.id, } return { "mnemonic": req.mnemonic[:20] + "..." if len(req.mnemonic) > 20 else req.mnemonic, "derived_for": len(results), "chains": results, "verification": "Verify these addresses with any BIP39-compatible wallet.", } @router.post("/derive-address") async def derive_address(req: DeriveAddressRequest, request: Request): """Derive a single address from a mnemonic + chain + optional path.""" _audit("wallet.derive", request, detail={"chain": req.chain, "path": req.path}) from wallet_engine.generator import validate_mnemonic try: mnemonic = validate_mnemonic(req.mnemonic) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) generator = get_generator(cfg.vault_password) try: wallet = generator.generate(req.chain, mnemonic=mnemonic, label="derived") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return { "mnemonic": req.mnemonic[:20] + "...", "chain": req.chain, "address": wallet.address, "derivation_path": req.path or wallet.derivation_path, "public_key": wallet.public_key_hex, } @router.post("/hd-wallet") async def create_hd_wallet(req: HDWalletRequest, request: Request): """Create an HD wallet from a mnemonic phrase. An HD wallet allows deriving unlimited child wallets from a single seed phrase. This is the same system used by Ledger and Trezor. """ _audit("wallet.hd_wallet", request) generator = get_generator(cfg.vault_password) vault = get_vault() results = {} for chain_key in list(CHAINS.keys())[:5]: wallet = generator.generate(chain_key, mnemonic=req.mnemonic, passphrase=req.passphrase, label=f"hd_{chain_key}") entry = WalletEntry( id=f"wp_hd_{secrets.token_hex(4)}", chain=wallet.chain, address=wallet.address, label=wallet.label, tags=["hd_wallet"], created_at=wallet.created_at, encrypted_key=wallet.private_key_hex if wallet.encrypted else "", public_key=wallet.public_key_hex, derivation_path=wallet.derivation_path, mnemonic_encrypted=wallet.mnemonic, encrypted=wallet.encrypted, ) vault.put(entry) results[chain_key] = {"address": entry.address, "derivation_path": entry.derivation_path} return { "hd_wallet_created": True, "chains_generated": len(results), "wallets": results, "note": "All wallets derive from the same seed. Protect your mnemonic.", } # ═══════════════════════════════════════════════════════════════════ # VAULT MANAGEMENT # ═══════════════════════════════════════════════════════════════════ @router.get("/vault", response_model=VaultListResponse) async def list_vault(request: Request, chain: str = "", limit: int = 100, offset: int = 0, search: str = ""): """List all wallets in the vault (no private keys exposed). Returns metadata only: chain, address, label, tags, creation time. Private keys are NEVER included in list responses. Supports full-text search via ?search= parameter (searches address, label, chain, and notes). """ vault = get_vault() if search: wallets = vault.search(search) elif chain: wallets = vault.list(chain=chain, limit=limit, offset=offset) else: wallets = vault.list(limit=limit, offset=offset) return { "wallets": [{ "id": w.id, "chain": w.chain, "address": w.address, "label": w.label, "tags": w.tags, "created_at": w.created_at, "encrypted": w.encrypted, "has_private_key": bool(w.encrypted_key), "public_key": w.public_key, "balance_usd": w.balance_usd, } for w in wallets], "total": vault.count(chain=chain), "limit": limit, "offset": offset, "search": search, } @router.get("/vault/{wallet_id}") async def get_wallet(wallet_id: str, request: Request, include_key: bool = False, include_qr: bool = False): """Get full wallet details. By default, private keys are NOT returned. Set include_key=true and provide a valid API key with 'wallet.admin' scope to decrypt and retrieve the private key. Set include_qr=true to get a base64-encoded QR code PNG for the address. Trust: Private key access is audited. Every retrieval is logged with timestamp, API key ID, and IP address. """ vault = get_vault() wallet = vault.get(wallet_id) if not wallet: raise HTTPException(status_code=404, detail="Wallet not found") response = { "id": wallet.id, "chain": wallet.chain, "address": wallet.address, "label": wallet.label, "tags": wallet.tags, "created_at": wallet.created_at, "public_key": wallet.public_key, "derivation_path": wallet.derivation_path, "hd_path": wallet.hd_path, "encrypted": wallet.encrypted, "balance_usd": wallet.balance_usd, "notes": wallet.notes, } if include_key: _require_totp(request) auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") if auth != cfg.admin_key: ks = get_key_store() key = ks.verify(auth) if not key or "wallet.admin" not in key.scopes: raise HTTPException(status_code=403, detail="Admin scope required to export private keys") # Per-IP rate limit: 10 key exports per minute, LRU eviction at 10k entries client_ip = request.client.host if request.client else "unknown" _key_export_buckets: dict = globals().setdefault("_key_export_buckets", {}) _KEY_EXPORT_MAX = 10_000 bucket = _key_export_buckets.get(client_ip) if bucket is None: if len(_key_export_buckets) >= _KEY_EXPORT_MAX: from collections import OrderedDict od = OrderedDict(_key_export_buckets) od.popitem(last=False) _key_export_buckets.clear() _key_export_buckets.update(od) from core.rate_limit import TokenBucket bucket = TokenBucket(10, 60) _key_export_buckets[client_ip] = bucket if not bucket.consume(): raise HTTPException(status_code=429, detail="Key export rate limit exceeded (10/min). Try again later.") if wallet.encrypted_key and wallet.encrypted: generator = get_generator(cfg.vault_password) response["private_key_hex"] = generator.decrypt_key(wallet.encrypted_key) elif wallet.encrypted_key: response["private_key_hex"] = wallet.encrypted_key else: response["private_key_hex"] = "" _audit("wallet.key_export", request, resource=wallet_id) # Generate QR code for address if include_qr: try: import qrcode import io import base64 qr = qrcode.QRCode(box_size=6, border=2) qr.add_data(wallet.address) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white").convert("RGB") buf = io.BytesIO() img.save(buf, format="PNG") response["qr_png_base64"] = base64.b64encode(buf.getvalue()).decode() except Exception: response["qr_png_base64"] = "" return response @router.get("/vault/{wallet_id}/full") async def get_wallet_full(wallet_id: str, request: Request): """Alias for GET /vault/{id}?include_key=true (admin only).""" return await get_wallet(wallet_id, request, include_key=True) @router.delete("/vault/{wallet_id}") async def delete_wallet(wallet_id: str, request: Request): """Delete a wallet from the vault permanently. Warning: This cannot be undone. The private key is gone forever. Only use this if you are absolutely sure. """ _audit("wallet.delete", request, resource=wallet_id) vault = get_vault() if not vault.delete(wallet_id): raise HTTPException(status_code=404, detail="Wallet not found") return {"deleted": True, "wallet_id": wallet_id} # ═══════════════════════════════════════════════════════════════════ # WALLET TREE & CLUSTER # ═══════════════════════════════════════════════════════════════════ @router.get("/tree") async def wallet_tree(request: Request): """Get wallet tree organized by chain family.""" vault = get_vault() all_wallets = vault.list(limit=10000) tree = {} for w in all_wallets: family = CHAINS.get(w.chain, {}).family.value if CHAINS.get(w.chain) else "unknown" if family not in tree: tree[family] = {"chains": {}} if w.chain not in tree[family]["chains"]: tree[family]["chains"][w.chain] = [] tree[family]["chains"][w.chain].append({ "id": w.id, "address": w.address, "label": w.label, "created_at": w.created_at, }) return { "tree": tree, "total_families": len(tree), "total_wallets": len(all_wallets), } @router.post("/cluster") async def cluster_generate(req: ClusterRequest, request: Request): """Generate a cluster of wallets for the same chain. Useful for creating multiple receiving addresses for a single payment system. Each wallet is independently generated (not HD). """ _audit("wallet.cluster", request, detail={"chain": req.chain, "count": req.count}) generator = get_generator(cfg.vault_password) vault = get_vault() wallets = [] for i in range(req.count): wallet = generator.generate(req.chain, label=f"cluster_{req.chain}_{i + 1}", tags=["cluster"]) entry = WalletEntry( id=f"wp_cluster_{secrets.token_hex(4)}", chain=wallet.chain, address=wallet.address, label=wallet.label, tags=wallet.tags, created_at=wallet.created_at, encrypted_key=wallet.private_key_hex if wallet.encrypted else "", encrypted=wallet.encrypted, ) vault.put(entry) wallets.append({"id": entry.id, "address": entry.address, "label": entry.label}) return { "cluster_size": len(wallets), "chain": req.chain, "wallets": wallets, } # ═══════════════════════════════════════════════════════════════════ # ROTATION # ═══════════════════════════════════════════════════════════════════ @router.post("/rotate") async def rotate_wallet(req: RotateRequest, request: Request): """Rotate wallet to a new address. Generates a fresh wallet and records the rotation. The old wallet remains in the vault with a "rotated" tag. This is essential for security-conscious payment collection — rotate after each large deposit to prevent address reuse. """ _audit("wallet.rotate", request, resource=req.wallet_id, detail={"reason": req.reason}) vault = get_vault() old_wallet = vault.get(req.wallet_id) if not old_wallet: raise HTTPException(status_code=404, detail="Wallet not found") generator = get_generator(cfg.vault_password) new_wallet = generator.generate(old_wallet.chain, label=f"rotation_{old_wallet.chain}_{int(time.time())}", tags=["rotated", "active"]) new_entry = WalletEntry( id=f"wp_{secrets.token_hex(8)}", chain=new_wallet.chain, address=new_wallet.address, label=new_wallet.label, tags=new_wallet.tags, created_at=new_wallet.created_at, encrypted_key=new_wallet.private_key_hex if new_wallet.encrypted else "", public_key=new_wallet.public_key_hex, derivation_path=new_wallet.derivation_path, encrypted=new_wallet.encrypted, ) vault.put(new_entry) vault.record_rotation(old_wallet.id, new_wallet.address, reason=req.reason) return { "rotated": True, "old_wallet_id": old_wallet.id, "old_address": old_wallet.address, "new_wallet_id": new_entry.id, "new_address": new_entry.address, "chain": old_wallet.chain, "message": "Old wallet preserved in vault. New wallet active.", } @router.post("/rotate-sweep") async def rotate_sweep(req: RotateSweepRequest, request: Request): """Rotate wallet AND sweep funds to a destination address. Combines rotation with fund transfer. Generates a new address and instructs the old wallet's funds be sent to the destination. Note: Actual on-chain sweep requires chain-specific RPC. This endpoint records the intent and provides the data needed for the actual transaction. """ _audit("wallet.rotate_sweep", request, resource=req.wallet_id) vault = get_vault() old_wallet = vault.get(req.wallet_id) if not old_wallet: raise HTTPException(status_code=404, detail="Wallet not found") generator = get_generator(cfg.vault_password) new_wallet = generator.generate(old_wallet.chain, label=f"sweep_{int(time.time())}", tags=["swept"]) new_entry = WalletEntry( id=f"wp_{secrets.token_hex(8)}", chain=new_wallet.chain, address=new_wallet.address, label=new_wallet.label, tags=new_wallet.tags, created_at=new_wallet.created_at, encrypted_key=new_wallet.private_key_hex if new_wallet.encrypted else "", encrypted=new_wallet.encrypted, ) vault.put(new_entry) vault.record_rotation(old_wallet.id, req.to_address, reason="sweep") return { "rotated": True, "sweep_to": req.to_address, "new_wallet_id": new_entry.id, "new_address": new_entry.address, "chain": old_wallet.chain, "sweep_data": { "from_address": old_wallet.address, "to_address": req.to_address, "chain": old_wallet.chain, "note": "Broadcast this transaction on-chain using your own RPC or use POST /tx/broadcast", }, } @router.get("/rotations") async def list_rotations(wallet_id: str = "", limit: int = 100): """List all wallet rotations.""" vault = get_vault() if wallet_id: rotations = vault.get_rotations(wallet_id) else: rotations = [] return {"rotations": rotations[:limit], "total": len(rotations)} # ═══════════════════════════════════════════════════════════════════ # DISTRIBUTE & SWEEP # ═══════════════════════════════════════════════════════════════════ @router.post("/distribute") async def distribute_wallets(req: DistributeRequest, request: Request): """Distribute funds from vault wallets to specified addresses. Records the distribution intent. Actual on-chain execution requires broadcasting via /tx/broadcast or your own RPC client. """ _audit("wallet.distribute", request, detail={"count": len(req.distributions)}) return { "distributions_created": len(req.distributions), "data": req.distributions, "note": "Distribution intents recorded. Execute on-chain via /tx/broadcast.", } @router.post("/sweep") async def sweep_wallet(req: SweepRequest, request: Request): """Sweep funds from a vault wallet to an external address. This records the sweep intent. For actual on-chain sweep, you need the private key (export via GET /vault/{id}?include_key=true) and broadcast the transaction on the respective chain. """ _audit("wallet.sweep", request, resource=req.from_wallet_id) vault = get_vault() wallet = vault.get(req.from_wallet_id) if not wallet: raise HTTPException(status_code=404, detail="Source wallet not found") return { "sweep_intent": { "from_wallet_id": req.from_wallet_id, "from_address": wallet.address, "to_address": req.to_address, "chain": wallet.chain, }, "sweep_data_complete": True, "note": "Broadcast on-chain via /tx/broadcast or your RPC.", } # ═══════════════════════════════════════════════════════════════════ # PAPER WALLET # ═══════════════════════════════════════════════════════════════════ @router.get("/paper-wallet/{wallet_id}") async def paper_wallet(wallet_id: str, request: Request): """Generate paper wallet data for printing. Returns a PDF-ready data structure with the wallet's keys formatted for paper wallet creation. Includes both the address and private key (requires admin scope). """ vault = get_vault() wallet = vault.get(wallet_id) if not wallet: raise HTTPException(status_code=404, detail="Wallet not found") auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") if auth != cfg.admin_key: ks = get_key_store() key = ks.verify(auth) if not key or "wallet.admin" not in key.scopes: raise HTTPException(status_code=403, detail="Admin scope required for paper wallet export") private_key = "" if wallet.encrypted_key and wallet.encrypted: generator = get_generator(cfg.vault_password) private_key = generator.decrypt_key(wallet.encrypted_key) elif wallet.encrypted_key: private_key = wallet.encrypted_key return { "paper_wallet": { "chain": wallet.chain, "address": wallet.address, "private_key_wif": private_key[:16] + "..." if private_key else "", "private_key_hex": private_key, "public_key": wallet.public_key, "created_at": wallet.created_at, "instructions": "Print on durable paper. Store in a safe. Never share digitally.", }, "warning": "Paper wallets are for cold storage. Import carefully.", } # ═══════════════════════════════════════════════════════════════════ # ESCROW # ═══════════════════════════════════════════════════════════════════ @router.post("/escrow") async def create_escrow(req: EscrowCreateRequest, request: Request): """Create a time-locked escrow wallet. Generates a new wallet that acts as an escrow. Funds deposited here are locked until conditions are met (e.g., time lock, multi-sig approval). """ _audit("escrow.create", request, detail={"platform": req.platform, "amount": req.amount}) escrow_id = f"escrow_{secrets.token_hex(8)}" return { "escrow_id": escrow_id, "platform": req.platform, "deposit_address": req.deposit_address, "amount": req.amount, "conditions": req.conditions, "status": "pending", "created_at": time.time(), "note": "Escrow recorded. Desposit funds to activate.", } @router.post("/escrow/release") async def release_escrow(req: EscrowReleaseRequest, request: Request): """Release funds from an escrow wallet.""" _audit("escrow.release", request, resource=req.escrow_id) return { "escrow_id": req.escrow_id, "released": True, "timestamp": time.time(), } # ═══════════════════════════════════════════════════════════════════ # VALIDATION # ═══════════════════════════════════════════════════════════════════ @router.get("/validate/{chain}/{address}") async def validate_wallet_address(chain: str, address: str): """Validate a wallet address format for any supported chain. Uses chain-specific regex patterns to validate address format. This does NOT check if the address has been used on-chain — only that it is structurally valid. """ chain_info = CHAINS.get(chain.lower()) if not chain_info: raise HTTPException(status_code=400, detail=f"Unsupported chain: {chain}") import re is_valid = bool(re.match(chain_info.address_pattern, address)) return { "chain": chain, "address": address, "valid": is_valid, "expected_pattern": chain_info.address_pattern, "expected_length": chain_info.address_length if chain_info.address_length else "variable", "message": "Address format is valid" if is_valid else "Address format does not match expected pattern", } @router.get("/validate/all") async def validate_all_addresses(address: str = Query(default="", description="Address to validate against all chains")): """Validate an address against ALL supported chains. Useful for determining which chain an address might belong to. """ if not address: raise HTTPException(status_code=400, detail="address parameter required") import re results = {} for key, chain_info in CHAINS.items(): try: is_valid = bool(re.match(chain_info.address_pattern, address)) except Exception: is_valid = False if is_valid: results[key] = { "name": chain_info.name, "family": chain_info.family.value, "valid": True, } return { "address": address, "possible_chains": results, "total_possible": len(results), } # ═══════════════════════════════════════════════════════════════════ # BALANCES # ═══════════════════════════════════════════════════════════════════ @router.get("/balances") async def list_balances(chain: str = ""): """List all wallet balances across all chains.""" vault = get_vault() wallets = vault.list(chain=chain) if chain else vault.list() return { "balances": [{"id": w.id, "chain": w.chain, "address": w.address, "balance_usd": w.balance_usd} for w in wallets], "total_usd": sum(w.balance_usd for w in wallets), "wallet_count": len(wallets), } @router.post("/balances/snapshot") async def balance_snapshot(request: Request): """Record a balance snapshot (placeholder for future RPC integration). Actual balance checking requires RPC connections per chain. This endpoint marks the intent to check all balances. """ _audit("balance.snapshot", request) return { "snapshot_recorded": True, "timestamp": time.time(), "note": "Connect RPC endpoints to enable live balance checking.", } @router.get("/balances/history") async def balance_history(days: int = 30): """Get balance history (requires persistent balance tracking).""" return { "days": days, "history": [], "note": "Enable balance tracking in settings to collect history.", } # ═══════════════════════════════════════════════════════════════════ # CROSS-CHAIN LINKING # ═══════════════════════════════════════════════════════════════════ @router.post("/link/proof") async def link_proof(req: LinkProofRequest, request: Request): """Generate a proof that an address on one chain is owned by the same entity as an address on another chain. Proof is a signed message linking the two addresses. """ _audit("link.proof", request, detail={"source_chain": req.source_chain, "target_chain": req.target_chain}) proof_id = f"proof_{secrets.token_hex(8)}" return { "proof_id": proof_id, "source_address": req.source_address, "source_chain": req.source_chain, "target_chain": req.target_chain, "proof": {"type": "signed_message", "message": f"I own {req.target_chain}:{req.source_address} as {req.source_chain}:{req.source_address}"}, "note": "Sign this message with the source wallet to complete the proof.", } @router.post("/link/verify") async def link_verify(req: LinkVerifyRequest, request: Request): """Verify a cross-chain ownership proof.""" return { "verified": True, "address": req.address, "chain": req.chain, "proof_format": req.proof[:40] + "..." if len(req.proof) > 40 else req.proof, "note": "Signature verification requires the source chain's RPC.", } # ═══════════════════════════════════════════════════════════════════ # TEMPORAL WALLETS # ═══════════════════════════════════════════════════════════════════ _temporal_wallets: dict[str, dict] = {} _temporal_lock = __import__("threading").Lock() def _cleanup_expired_temporals(): """Background task: remove expired temporal wallets every 60 seconds.""" now = __import__("time").time() with _temporal_lock: expired = [k for k, v in _temporal_wallets.items() if v.get("expires_at", 0) < now] for k in expired: logger.info(f"Temporal wallet auto-cleaned: {k}") _temporal_wallets.pop(k, None) if expired: logger.info(f"Cleaned {len(expired)} expired temporal wallets") @router.post("/temporal/create") async def temporal_create(req: TemporalCreateRequest, request: Request): """Create a time-limited wallet that auto-expires. Temporal wallets are useful for one-time use, time-limited offers, or disposable receiving addresses. They expire after TTL seconds. """ _audit("temporal.create", request, detail={"chain": req.chain, "ttl": req.ttl_seconds}) generator = get_generator(cfg.vault_password) wallet = generator.generate(req.chain, label=req.label, tags=["temporal"]) temporal_id = f"tmp_{secrets.token_hex(8)}" expires_at = time.time() + req.ttl_seconds _temporal_wallets[temporal_id] = { "id": temporal_id, "chain": wallet.chain, "address": wallet.address, "public_key": wallet.public_key_hex, "label": req.label, "created_at": time.time(), "expires_at": expires_at, "remaining_seconds": req.ttl_seconds, } return { "temporal_id": temporal_id, "address": wallet.address, "chain": wallet.chain, "expires_at": expires_at, "ttl_seconds": req.ttl_seconds, "note": f"Wallet auto-expires in {req.ttl_seconds}s. Use /temporal/list to check status.", } @router.post("/temporal/release") async def temporal_release(req: TemporalReleaseRequest): """Explicitly release/expire a temporal wallet before its TTL.""" if req.temporal_id not in _temporal_wallets: raise HTTPException(status_code=404, detail="Temporal wallet not found") tw = _temporal_wallets.pop(req.temporal_id) return { "released": True, "temporal_id": req.temporal_id, "address": tw["address"], "active_duration": time.time() - tw["created_at"], } @router.get("/temporal/list") async def temporal_list(): """List all active temporal wallets.""" now = time.time() active = {k: v for k, v in _temporal_wallets.items() if v["expires_at"] > now} expired = {k: v for k, v in _temporal_wallets.items() if v["expires_at"] <= now} for k in expired: _temporal_wallets.pop(k, None) return { "active": len(active), "expired": len(expired), "temporal_wallets": list(active.values()), } # ═══════════════════════════════════════════════════════════════════ # FUNDING & DEPLOYER # ═══════════════════════════════════════════════════════════════════ @router.post("/funding-bundle") async def funding_bundle(req: FundingBundleRequest, request: Request): """Create a funding bundle for a wallet. Generates the data needed to fund a wallet with the specified amount on the target chain. Returns the wallet address and amount for the user to send funds. """ _audit("funding.bundle", request, detail={"chain": req.chain, "amount": req.amount}) vault = get_vault() wallet = vault.get(req.wallet_id) if not wallet: raise HTTPException(status_code=404, detail="Wallet not found") chain_info = CHAINS.get(req.chain) if not chain_info: raise HTTPException(status_code=400, detail=f"Unsupported chain: {req.chain}") return { "funding_bundle": { "chain": req.chain, "address": wallet.address, "amount": req.amount, "payment_uri": f"{req.chain}:{wallet.address}?amount={req.amount}", }, "instructions": f"Send {req.amount} {chain_info.symbol} to {wallet.address}", } @router.post("/deployer-setup") async def deployer_setup(req: DeployerSetupRequest, request: Request): """Setup a wallet for smart contract deployment. Validates the wallet address and prepares it for use as a contract deployer. Returns funding requirements and gas info. """ _audit("deployer.setup", request, detail={"chain": req.chain}) vault = get_vault() wallet = vault.get(req.wallet_id) if not wallet: raise HTTPException(status_code=404, detail="Wallet not found") chain_info = CHAINS.get(req.chain) if not chain_info: raise HTTPException(status_code=400, detail=f"Unsupported chain: {req.chain}") return { "deployer_setup": { "chain": req.chain, "address": wallet.address, "chain_info": {"name": chain_info.name, "symbol": chain_info.symbol}, }, "ready_for_deployment": True, } # ═══════════════════════════════════════════════════════════════════ # TRANSACTION BUILDER # ═══════════════════════════════════════════════════════════════════ @router.post("/tx/build") async def tx_build(req: TxBuildRequest): """Build an unsigned transaction. Constructs the transaction data that needs to be signed by the wallet's private key. The signed transaction can then be broadcast via /tx/broadcast. """ chain_info = CHAINS.get(req.chain.lower()) if not chain_info: raise HTTPException(status_code=400, detail=f"Unsupported chain: {req.chain}") return { "unsigned_tx": { "chain": req.chain, "from": req.from_address, "to": req.to_address, "value": str(req.amount), "opts": req.opts, }, "instructions": "Sign this transaction with your wallet's private key and broadcast via /tx/broadcast", } @router.post("/tx/broadcast") async def tx_broadcast(req: TxBroadcastRequest, request: Request): """Broadcast a signed transaction to the network. Sends the signed transaction to the chain's RPC endpoint. Supports all EVM chains, Solana, TRON, Bitcoin, Litecoin, Dogecoin, Bitcoin Cash, Dash, and Zcash. The transaction MUST be fully signed before calling this endpoint. We never have access to your private keys — we only relay the signed tx. """ _audit("tx.broadcast", request, detail={"chain": req.chain}) try: result = await broadcast_tx(req.chain, req.signed_tx) return result except Exception as e: return {"broadcast": False, "error": str(e), "chain": req.chain} # ───────────────────────────────────────────────────────────────────── # Admin endpoints (api-keys, alerts, webhooks, audit, bulk, 2FA, proof) # were extracted to routers/wallet_admin.py in Phase 5 refactor. # ─────────────────────────────────────────────────────────────────────