diff --git a/backend/agent/mcp_server.py b/backend/agent/mcp_server.py new file mode 100644 index 0000000..3f5cbd2 --- /dev/null +++ b/backend/agent/mcp_server.py @@ -0,0 +1,1051 @@ +"""WalletPress AI Agent MCP Server — All wallet operations as AI-callable tools. + +Every tool is a @tool decorator. The MCP server can be: +- Run standalone via `uvicorn agent.mcp_server:mcp_app` or `python -m agent.mcp_server` +- Mounted as a FastAPI sub-app in main.py +- Connected to any MCP client (Claude Code, opencode, Cursor, etc.) +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from core.config import cfg +from core.vault import WalletEntry, get_vault +from core.agent_safety import ( + check_action_allowed, is_write_action, is_killed, + request_confirmation, confirm_action, reject_action, list_pending, + check_address, check_spending_limit, record_spending, + audit_log, simulate_transaction, + kill_agent as safety_kill, resurrect_agent as safety_resurrect, + allowlist_add, blocklist_add, address_book_list, address_book_remove, + list_limits, audit_trail, verify_audit_chain, +) + +logger = logging.getLogger("wp.agent.mcp") + +mcp = FastMCP("WalletPress AI Agent", log_level="WARNING") + + +def _safety_check(action: str, params: dict) -> dict | None: + """Run safety checks for a WRITE action. Returns error dict or None if OK.""" + # Kill switch + if is_killed(): + return {"error": "Agent is emergency stopped. Use agent_resurrect() to re-enable."} + + # Permission tier + allowed, reason = check_action_allowed(action) + if not allowed: + return {"error": reason} + + # All checks passed + return None + + +import threading +_HITL_SKIP_LOCAL = threading.local() + + +def _is_hitl_skip() -> bool: + return getattr(_HITL_SKIP_LOCAL, "skip", False) + + +def _set_hitl_skip(val: bool) -> None: + _HITL_SKIP_LOCAL.skip = val + + +def _write_with_hitl(action: str, params: dict, reason: str = "") -> dict | None: + """Run safety checks for a WRITE action. + + Returns: + None if checks pass and the action should proceed directly. + dict with error or confirmation request if action is blocked or needs HITL. + """ + if _is_hitl_skip(): + return None # Called from agent_confirm — skip safety, execute directly + + # Kill switch + if is_killed(): + return {"error": "Agent is emergency stopped. Use agent_resurrect() to re-enable."} + + # Permission tier + allowed, reason_tier = check_action_allowed(action) + if not allowed: + return {"error": reason_tier} + + # Request HITL confirmation (always for WRITE actions) + result = request_confirmation(action, params, reason) + audit_log(action, params, {"status": "pending_confirmation"}, confirmed_by="") + return { + "status": "pending_confirmation", + "confirmation_id": result["confirmation_id"], + "action": action, + "reason": reason, + "expires_at": result["expires_at"], + "confirm_command": f"Use agent_confirm('{result['confirmation_id']}') to approve", + "reject_command": f"Use agent_reject('{result['confirmation_id']}') to reject", + } + + +def _confirmed_call(action: str, params: dict, fn, *args, **kwargs): + """Execute a tool function inside a confirmed HITL flow. + + Sets the per-thread skip flag so the tool runs without requesting + confirmation again. Thread-safe under concurrent requests. + """ + _set_hitl_skip(True) + try: + return fn(*args, **kwargs) + finally: + _set_hitl_skip(False) + + +# ── SAFETY & HITL TOOLS ────────────────────────────────── + + +@mcp.tool() +def agent_confirm(confirmation_id: str) -> dict: + """Confirm a pending action and execute it. + + After reviewing a pending confirmation, call this to approve execution. + Returns the result of the underlying action. + + Args: + confirmation_id: The ID from a previous agent_execute or write request + """ + result = confirm_action(confirmation_id) + if "error" in result: + return result + action = result["action"] + params = result["params"] + + # Execute the approved action inside HITL-skip mode + from agent.mcp_server import wallet_generate, wallet_from_mnemonic, vault_delete + from agent.mcp_server import wallet_rotate, wallet_sweep + from agent.mcp_server import schedule_dca, schedule_remove, anomaly_monitor + + TOOL_MAP = { + "wallet_generate": lambda: _confirmed_call(action, params, wallet_generate, **params), + "wallet_from_mnemonic": lambda: _confirmed_call(action, params, wallet_from_mnemonic, **params), + "vault_delete": lambda: _confirmed_call(action, params, vault_delete, **params), + "wallet_rotate": lambda: _confirmed_call(action, params, wallet_rotate, **params), + "wallet_sweep": lambda: _confirmed_call(action, params, wallet_sweep, **params), + "schedule_dca": lambda: _confirmed_call(action, params, schedule_dca, **params), + "schedule_remove": lambda: _confirmed_call(action, params, schedule_remove, **params), + "anomaly_monitor": lambda: _confirmed_call(action, params, anomaly_monitor, **params), + } + + fn = TOOL_MAP.get(action) + if not fn: + return {"error": f"No confirmation handler for action: {action}"} + + audit_log(action, params, {"status": "executing"}, confirmed_by="human") + try: + exec_result = fn() + audit_log(action, params, exec_result, confirmed_by="human") + return {"confirmed": True, "action": action, "result": exec_result} + except Exception as e: + err_msg = f"{type(e).__name__}: {e}" + audit_log(action, params, {"error": err_msg}, confirmed_by="human") + return {"confirmed": True, "action": action, "error": err_msg} + + +@mcp.tool() +def agent_reject(confirmation_id: str, reason: str = "") -> dict: + """Reject a pending action without executing it. + + Args: + confirmation_id: The ID from a previous agent_execute or write request + reason: Optional reason for rejection + """ + result = reject_action(confirmation_id, reason) + if "error" in result: + return result + audit_log("agent_reject", {"confirmation_id": confirmation_id}, result) + return result + + +@mcp.tool() +def agent_pending() -> list[dict]: + """List all pending confirmation requests waiting for your approval.""" + return list_pending() + + +@mcp.tool() +def agent_kill(reason: str = "") -> dict: + """Emergency stop the agent immediately. + + All pending plans are canceled. Agent goes into read-only mode. + Returns a resurrection key needed to re-enable the agent. + + Args: + reason: Why are you killing the agent? + """ + result = safety_kill(reason) + audit_log("agent_kill", {"reason": reason}, result) + return result + + +@mcp.tool() +def agent_resurrect(key: str) -> dict: + """Re-enable a killed agent using the resurrection key. + + Args: + key: The resurrection key from agent_kill() + """ + result = safety_resurrect(key) + if "error" not in result: + audit_log("agent_resurrect", {}, result) + return result + + +@mcp.tool() +def agent_limits() -> dict: + """Show current spending limits and usage across all chains.""" + return list_limits() + + +@mcp.tool() +def agent_simulate(chain: str, from_address: str, to_address: str, amount: float) -> dict: + """Simulate a transaction to see gas costs and expected outcome before executing. + + Args: + chain: Chain key (eth, sol, etc.) + from_address: Source wallet address + to_address: Destination address + amount: Amount in native tokens + """ + return simulate_transaction(chain, from_address, to_address, amount) + + +@mcp.tool() +def agent_referral_status() -> dict: + """Show the agent's revenue/referral configuration. + + In FREEMIUM mode, the agent uses Rug Munch Media's referral codes + on every swap/trade — this costs you nothing and helps fund development. + In SELF-REFERRAL mode, you keep 100% of referral revenue. + + Shows which platforms are configured, which need wallet setup, + and what revenue share each platform provides. + """ + import os + from plugins.defi import REVENUE_MODE, REF_JUPITER_WALLET, REF_HYPERLIQUID_WALLET + from plugins.defi import REF_GMGN, REF_ODINBOT, REF_BANANA, REF_AXIOM, REF_PADRE + + platforms = [ + {"platform": "Jupiter", "type": "fee_account", "share": "50bps", "configured": bool(REF_JUPITER_WALLET), "wallet": REF_JUPITER_WALLET or "NOT SET", "docs": "https://jup.ag/referral"}, + {"platform": "Hyperliquid", "type": "builder_code", "share": "up to 0.1%/trade", "configured": bool(REF_HYPERLIQUID_WALLET), "wallet": REF_HYPERLIQUID_WALLET or "NOT SET", "docs": "https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes.md"}, + {"platform": "GMGN", "type": "code", "share": "40%", "configured": True, "code": REF_GMGN, "setup": "Use code at gmgn.ai"}, + {"platform": "OdinBot", "type": "code", "share": "40%", "configured": True, "code": REF_ODINBOT, "setup": "Use code at t.me/OdinBot"}, + {"platform": "Banana Gun", "type": "code", "share": "40%", "configured": True, "code": REF_BANANA, "setup": "Use code at t.me/BananaGunBot"}, + {"platform": "Axiom", "type": "code", "share": "30%", "configured": True, "code": REF_AXIOM, "setup": "Use code at t.me/AxiomTrade"}, + {"platform": "Padre", "type": "code", "share": "35%", "configured": True, "code": REF_PADRE, "setup": "Use code at t.me/PadreBot"}, + ] + + freemium_note = "" + if REVENUE_MODE == "freemium": + configured = sum(1 for p in platforms if p.get("configured")) + unconfigured = sum(1 for p in platforms if not p.get("configured")) + freemium_note = f"{configured} platforms sending revenue, {unconfigured} need wallet setup (Jupiter + Hyperliquid). Set WP_REF_REVENUE_MODE=self to keep your own referral revenue." + + return { + "revenue_mode": REVENUE_MODE.upper(), + "your_cost": "free (we cover referral fees)" if REVENUE_MODE == "freemium" else "you control your own codes", + "platforms": platforms, + "note": freemium_note, + "how_to_configure": "Jupiter: set WP_REF_JUPITER_WALLET (fee account). Hyperliquid: set WP_REF_HYPERLIQUID_WALLET (builder wallet, needs 100 USDC in perps).", + } + + +@mcp.tool() +def agent_audit(limit: int = 50) -> list[dict]: + """View the agent audit trail — every action logged with SHA-256 chain. + + Args: + limit: Number of recent entries to show (default 50) + """ + return audit_trail(limit) + + +@mcp.tool() +def agent_audit_verify() -> dict: + """Verify the integrity of the entire audit trail. + + Recomputes all SHA-256 hashes and checks the chain is unbroken. + """ + return verify_audit_chain() + + +@mcp.tool() +def address_allowlist(address: str, chain: str, label: str = "") -> dict: + """Add an address to the agent's allowlist. The agent can only send to known addresses. + + Args: + address: Wallet address to allow + chain: Chain key (eth, sol, btc, etc.) + label: Optional human-readable label (e.g. 'Exchange wallet') + """ + return allowlist_add(address, chain, label) + + +@mcp.tool() +def address_blocklist(address: str, chain: str, label: str = "") -> dict: + """Add an address to the blocklist. The agent will never send funds here. + + Args: + address: Wallet address to block + chain: Chain key + label: Reason for blocking + """ + return blocklist_add(address, chain, label) + + +@mcp.tool() +def address_list(list_type: str = "") -> list[dict]: + """List addresses in the agent's address book. + + Args: + list_type: 'allowlist', 'blocklist', or '' for all + """ + return address_book_list(list_type) + + +@mcp.tool() +def address_check(address: str, chain: str) -> dict: + """Check if an address is safe to send funds to. + + Checks against: user's blocklist, known scam database, and + optionally the allowlist (if WP_AGENT_REQUIRE_ALLOWLIST=1). + + Args: + address: Wallet address to check + chain: Chain key + """ + return check_address(address, chain) + + +# ── WALLET GENERATION ────────────────────────────────────── + + +@mcp.tool() +def wallet_generate(chain: str, label: str = "", count: int = 1) -> dict: + """Generate one or more wallets for any supported chain. + + Args: + chain: Chain key (eth, sol, btc, trx, bsc, polygon, base, ...) + label: Optional human-readable label + count: Number of wallets to generate (default 1, max 100) + """ + hitl = _write_with_hitl("wallet_generate", {"chain": chain, "label": label, "count": count}, + f"Generate {count} wallet(s) on {chain}") + if "confirmation_id" in hitl: + return hitl + from wallet_engine.generator import get_generator + vault = get_vault() + generator = get_generator(cfg.vault_password) + wallets = [] + for i in range(count): + lbl = label or f"{chain}_{i + 1}_{int(time.time())}" + try: + wallet = generator.generate(chain, label=lbl) + except ValueError as e: + return {"error": str(e)} + entry = WalletEntry( + id=f"wp_agent_{chain}_{int(time.time() * 1000)}_{i}", + 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, + encrypted=wallet.encrypted, + ) + vault.put(entry) + wallets.append({"id": entry.id, "chain": entry.chain, "address": entry.address, "label": entry.label}) + return {"generated": len(wallets), "wallets": wallets} + + +@mcp.tool() +def wallet_from_mnemonic(mnemonic: str, chains: list[str] | None = None) -> dict: + """Derive wallets from a BIP39 mnemonic phrase. + + Args: + mnemonic: BIP39 seed phrase (12 or 24 words) + chains: List of chains to derive (empty = all 55 chains) + """ + hitl = _write_with_hitl("wallet_from_mnemonic", {"chains": chains}, + "Derive wallets from a mnemonic phrase") + if hitl: + return hitl + from wallet_engine.chains import CHAINS + from wallet_engine.generator import get_generator + vault = get_vault() + generator = get_generator(cfg.vault_password) + chain_list = chains or list(CHAINS.keys()) + results = {} + for ck in chain_list: + try: + wallet = generator.generate(ck, mnemonic=mnemonic, label=f"mnemonic_{ck}") + except ValueError: + results[ck] = {"error": "chain not supported"} + continue + entry = WalletEntry( + id=f"wp_mcp_mnemonic_{ck}_{int(time.time())}", + 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, + encrypted=wallet.encrypted, + ) + vault.put(entry) + results[ck] = {"address": entry.address, "wallet_id": entry.id} + return {"derived_for": len(results), "chains": results} + + +# ── VAULT MANAGEMENT ─────────────────────────────────────── + + +@mcp.tool() +def vault_list(chain: str = "", limit: int = 50) -> list[dict]: + """List wallets in the vault (no private keys returned). + + Args: + chain: Optional filter by chain + limit: Max results (default 50) + """ + vault = get_vault() + wallets = vault.list(chain=chain, limit=limit) if chain else vault.list(limit=limit) + return [{ + "id": w.id, "chain": w.chain, "address": w.address, + "label": w.label, "tags": w.tags, "created_at": w.created_at, + "encrypted": w.encrypted, "balance_usd": w.balance_usd, + } for w in wallets] + + +@mcp.tool() +def vault_get(wallet_id: str) -> dict: + """Get full wallet details including private key (requires vault password configured). + + Args: + wallet_id: Wallet ID from vault_list + """ + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + return {"error": f"Wallet {wallet_id} not found"} + result = { + "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, + "encrypted": wallet.encrypted, "balance_usd": wallet.balance_usd, + } + if wallet.encrypted_key and wallet.encrypted and cfg.vault_password: + from wallet_engine.generator import get_generator + generator = get_generator(cfg.vault_password) + result["private_key"] = generator.decrypt_key(wallet.encrypted_key) + elif wallet.encrypted_key: + result["private_key"] = wallet.encrypted_key + return result + + +@mcp.tool() +def vault_delete(wallet_id: str) -> dict: + """Delete a wallet permanently. + + Args: + wallet_id: Wallet ID to delete + """ + hitl = _write_with_hitl("vault_delete", {"wallet_id": wallet_id}, + f"Permanently delete wallet {wallet_id}") + if hitl: + return hitl + vault = get_vault() + if vault.delete(wallet_id): + return {"deleted": True, "wallet_id": wallet_id} + return {"error": f"Wallet {wallet_id} not found"} + + +@mcp.tool() +def vault_stats() -> dict: + """Get vault statistics — total wallets, encrypted count, chains used, rotations.""" + vault = get_vault() + return vault.stats() + + +# ── BALANCE & ANALYSIS ───────────────────────────────────── + + +@mcp.tool() +async def balance_check(chain: str, address: str) -> dict: + """Check wallet balance on any supported chain. + + Args: + chain: Chain key (eth, sol, btc, trx, ...) + address: Wallet address + """ + from routers.balance_fetcher import fetch_balance + return await fetch_balance(chain, address) + + +@mcp.tool() +def vault_balances(chain: str = "") -> dict: + """Get balances for all wallets in the vault. + + Args: + chain: Optional chain filter + """ + vault = get_vault() + wallets = vault.list(chain=chain, limit=500) if chain else vault.list(limit=500) + return { + "wallets": [{"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), + } + + +# ── WALLET MANAGEMENT ────────────────────────────────────── + + +@mcp.tool() +def wallet_rotate(wallet_id: str, reason: str = "agent_rotation") -> dict: + """Rotate a wallet to a new address. Old wallet preserved with 'rotated' tag. + + Args: + wallet_id: ID of existing wallet + reason: Rotation reason + """ + hitl = _write_with_hitl("wallet_rotate", {"wallet_id": wallet_id, "reason": reason}, + f"Rotate keys for wallet {wallet_id}: {reason}") + if hitl: + return hitl + from wallet_engine.generator import get_generator + vault = get_vault() + old = vault.get(wallet_id) + if not old: + return {"error": f"Wallet {wallet_id} not found"} + generator = get_generator(cfg.vault_password) + new = generator.generate(old.chain, label=f"rotation_{old.chain}_{int(time.time())}", tags=["rotated", "active"]) + new_entry = WalletEntry( + id=f"wp_rot_{old.chain}_{int(time.time())}", + chain=new.chain, address=new.address, label=new.label, + tags=new.tags, created_at=new.created_at, + encrypted_key=new.private_key_hex if new.encrypted else "", + public_key=new.public_key_hex, derivation_path=new.derivation_path, + encrypted=new.encrypted, + ) + vault.put(new_entry) + vault.record_rotation(old.id, new.address, reason=reason) + return { + "rotated": True, + "old_wallet_id": old.id, + "old_address": old.address, + "new_wallet_id": new_entry.id, + "new_address": new_entry.address, + "chain": old.chain, + } + + +@mcp.tool() +def wallet_sweep(from_wallet_id: str, to_address: str) -> dict: + """Sweep ALL funds from a vault wallet to an external address. + + Implements actual on-chain transfer for EVM chains (Ethereum, Base, + Polygon, Arbitrum, Optimism, BNB Chain, Avalanche, Fantom, Gnosis, etc.). + For non-EVM chains (Solana, TRON, BTC family), returns a clear + "not implemented" error with the transaction params the caller can sign. + + Security checks performed BEFORE signing: + 1. Address allow/block list (block known scam addresses) + 2. Spending limits (per-chain, per-period) + 3. HITL confirmation (human must approve via agent_confirm) + + Args: + from_wallet_id: Source wallet ID from vault + to_address: Destination address (must be valid for the chain) + """ + from core.balance_fetcher import EVM_RPC + + # Address safety check + vault = get_vault() + wallet = vault.get(from_wallet_id) + chain = wallet.chain if wallet else "unknown" + addr_check = check_address(to_address, chain) + if not addr_check["allowed"]: + return {"error": f"Address blocked: {addr_check['reason']}"} + + # Spending limit check + limit_check = check_spending_limit(chain, 0) + if not limit_check["allowed"]: + return {"error": f"Spending limit exceeded: {limit_check['exceeded']}"} + + hitl = _write_with_hitl("wallet_sweep", {"from_wallet_id": from_wallet_id, "to_address": to_address}, + f"Sweep funds from {from_wallet_id} to {to_address}") + if hitl: + return hitl + + wallet = vault.get(from_wallet_id) + if not wallet: + return {"error": f"Wallet {from_wallet_id} not found"} + + # EVM chain: build, sign, and broadcast via tx_broadcaster + if chain in EVM_RPC: + return _evm_sweep(wallet, to_address, chain) + + # Non-EVM: return intent + clear "not implemented" notice + return { + "status": "intent_only", + "chain": chain, + "wallet_id": from_wallet_id, + "from_address": wallet.address, + "to_address": to_address, + "note": ( + f"Auto-broadcast not yet implemented for {chain}. " + f"Sign the transaction externally with the wallet's private key " + f"and broadcast via tx_broadcaster.broadcast()." + ), + } + + +def _evm_sweep(wallet, to_address: str, chain: str) -> dict: + """Build, sign, and broadcast an EVM sweep transaction. + + Returns the tx_hash on success or an error dict on failure. + """ + import asyncio + from decimal import Decimal + from eth_account import Account + from web3 import Web3 + from web3.exceptions import Web3Exception + + from core.balance_fetcher import EVM_RPC + from routers.tx_broadcaster import _evm_broadcast + + rpc_url = EVM_RPC.get(chain) + if not rpc_url: + return {"error": f"No RPC configured for chain {chain}"} + + # Decrypt the private key from the vault + from wallet_engine.generator import get_generator + gen = get_generator(cfg.vault_password) + if not wallet.encrypted_key or not wallet.encrypted: + return {"error": f"Wallet {wallet.id} has no encrypted key (client-side only?)"} + try: + privkey_hex = gen.decrypt_key(wallet.encrypted_key) + except Exception as e: + return {"error": f"Failed to decrypt private key: {e}"} + + try: + w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 15})) + if not w3.is_connected(): + return {"error": f"Cannot connect to RPC for {chain}"} + + account = Account.from_key(privkey_hex) + nonce = w3.eth.get_transaction_count(account.address) + gas_price = w3.eth.gas_price + chain_id = w3.eth.chain_id + + # Get balance — sweep = balance - gas + balance_wei = w3.eth.get_balance(account.address) + # Estimate gas for a simple transfer (21k) + buffer + gas_limit = 21000 + gas_cost_wei = gas_limit * gas_price + if balance_wei <= gas_cost_wei: + return { + "error": f"Insufficient balance to cover gas", + "balance_wei": balance_wei, + "gas_cost_wei": gas_cost_wei, + } + + value_wei = balance_wei - gas_cost_wei + + # Build the transaction + tx = { + "nonce": nonce, + "to": to_address, + "value": value_wei, + "gas": gas_limit, + "gasPrice": gas_price, + "chainId": chain_id, + } + + # Sign + signed = account.sign_transaction(tx) + + # Broadcast via existing tx_broadcaster helper (handles errors uniformly) + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete(_evm_broadcast(chain, signed.raw_transaction.hex())) + finally: + loop.close() + + if result.get("broadcast"): + # Record the spending + try: + amount_eth = float(w3.from_wei(value_wei, "ether")) + record_spending(chain, amount_eth) + except Exception: + pass + audit_log( + "wallet_sweep", + {"from_wallet_id": wallet.id, "to_address": to_address, "chain": chain}, + {"tx_hash": result["tx_hash"], "amount_wei": value_wei}, + confirmed_by="human", + ) + return { + "status": "broadcast", + "chain": chain, + "from_wallet_id": wallet.id, + "from_address": wallet.address, + "to_address": to_address, + "tx_hash": result["tx_hash"], + "amount_wei": value_wei, + "amount_native": float(w3.from_wei(value_wei, "ether")), + "gas_cost_wei": gas_cost_wei, + "explorer_url": f"https://etherscan.io/tx/{result['tx_hash']}" if chain == "eth" else None, + } + else: + return { + "error": f"Broadcast failed: {result.get('error', 'unknown')}", + "chain": chain, + } + except Web3Exception as e: + return {"error": f"Web3 error: {e}", "chain": chain} + except Exception as e: + return {"error": f"{type(e).__name__}: {e}", "chain": chain} + + +# ── CROSS-CHAIN ──────────────────────────────────────────── + + +@mcp.tool() +def validate_address(chain: str, address: str) -> dict: + """Validate if an address is structurally valid for a given chain. + + Args: + chain: Chain key + address: Address to validate + """ + from wallet_engine.chains import validate_address as va, CHAINS + chain_info = CHAINS.get(chain.lower()) + if not chain_info: + return {"error": f"Unsupported chain: {chain}"} + valid = va(chain, address) + return { + "chain": chain, + "address": address, + "valid": valid, + "expected_pattern": chain_info.address_pattern, + } + + +@mcp.tool() +def chains_list() -> dict: + """List all 55 supported blockchains with metadata.""" + from wallet_engine.chains import CHAINS, ChainFamily + families = set() + chain_list = [] + for k, c in sorted(CHAINS.items()): + families.add(c.family.value) + chain_list.append({ + "key": k, "name": c.name, "symbol": c.symbol, + "family": c.family.value, "curve": c.curve, + }) + return { + "total_chains": len(chain_list), + "total_families": len(families), + "chains": chain_list, + } + + +# ── AGENT INTELLIGENCE ───────────────────────────────────── + + +@mcp.tool() +def agent_plan(prompt: str, context: str = "") -> dict: + """Plan a multi-step wallet operation from natural language. + + The LLM analyzes the prompt and returns a structured plan of tool calls + to execute. Use this for complex operations like: + - "sweep all ETH from my vault to 0x..." + - "generate 5 SOL wallets for my airdrop campaign" + - "rotate my oldest wallet and check the new balance" + - "tell me my total portfolio value across all chains" + + Args: + prompt: Natural language instruction + context: Optional context (e.g. current vault state summary) + """ + from agent.orchestrator import plan_operation + return plan_operation(prompt, context) + + +@mcp.tool() +def agent_execute(plan: list[dict] | str, confirm: bool = False) -> dict: + """Execute a pre-approved multi-step plan. + + Args: + plan: The plan from agent_plan, or a JSON string of steps + confirm: If True, requires human confirmation before executing + """ + from agent.orchestrator import execute_plan + return execute_plan(plan, confirm=confirm) + + +# ── SCHEDULER ────────────────────────────────────────────── + + +@mcp.tool() +def schedule_dca(chain: str, amount: float, frequency: str = "weekly", + to_address: str = "", label: str = "") -> dict: + """Set up a DCA (dollar-cost averaging) schedule. + + Generates a wallet and periodically sends funds to it. + + Args: + chain: Chain key + amount: Amount in native token per interval + frequency: 'daily', 'weekly', 'biweekly', 'monthly' + to_address: Optional destination (empty = generate new wallet each time) + label: Optional label for generated wallets + """ + hitl = _write_with_hitl("schedule_dca", {"chain": chain, "amount": amount, "frequency": frequency}, + f"Set up {frequency} DCA of {amount} {chain}") + if hitl: + return hitl + from agent.scheduler import add_dca_task + return add_dca_task(chain, amount, frequency, to_address, label) + + +@mcp.tool() +def schedule_list() -> list[dict]: + """List all active scheduled tasks (DCA, sweeps, monitors).""" + from agent.scheduler import scheduler + return scheduler.list_tasks() + + +@mcp.tool() +def schedule_remove(task_id: str) -> dict: + """Remove a scheduled task. + + Args: + task_id: ID from schedule_list + """ + hitl = _write_with_hitl("schedule_remove", {"task_id": task_id}, + f"Remove scheduled task {task_id}") + if hitl: + return hitl + from agent.scheduler import scheduler + if scheduler.remove_task(task_id): + return {"removed": True, "task_id": task_id} + return {"error": f"Task {task_id} not found"} + + +# ── ANOMALY DETECTION ────────────────────────────────────── + + +@mcp.tool() +async def anomaly_scan(address: str, chain: str = "eth") -> dict: + """Scan a wallet for anomalous activity. + + Checks: unusually large transactions, new address interactions, + rapid-fire transactions, unusual timing. + + Args: + address: Wallet address to scan + chain: Chain key + """ + from agent.detector import scan_wallet + return await scan_wallet(address, chain) + + +@mcp.tool() +def anomaly_monitor(wallet_id: str, webhook_url: str = "") -> dict: + """Set up continuous monitoring for a wallet. + + Triggers alerts on: large transfers, first-time interactions, + unusual volume spikes, rapid consecutive transactions. + + Args: + wallet_id: Wallet ID from vault + webhook_url: Optional webhook for alerts + """ + from agent.scheduler import add_monitor_task + return add_monitor_task(wallet_id, webhook_url) + + +# ── PROOF OF GENERATION ──────────────────────────────────── + + +@mcp.tool() +def proof_attestations() -> dict: + """Get Proof of Generation statistics — total attestations, committed, uncommitted roots.""" + from core.proof import get_proof + return get_proof().stats() + + +@mcp.tool() +def proof_commit_pending(chain: str = "local") -> dict: + """Commit all pending attestations to a Merkle root. + + Args: + chain: 'local' (SQLite, free), 'arweave' (permanent, ~$0.000001), + 'ethereum' (on-chain, ~$5-50 gas) + """ + from core.proof import get_proof + proof = get_proof() + root_hash, count = proof.compute_merkle_root() + if not root_hash: + return {"committed": False, "reason": "No pending attestations"} + result = proof.commit(root_hash, count, commitment_chain=chain) + return { + "committed": True, + "root_hash": root_hash, + "attestation_count": count, + "chain": chain, + "commitment_tx": result.get("commitment_tx", ""), + "verification_url": result.get("verification_url", ""), + } + + +@mcp.tool() +def proof_verify_wallet(wallet_id: str) -> dict: + """Verify a wallet's Proof of Generation attestation. + + Args: + wallet_id: Wallet ID from vault_list + """ + from core.proof import get_proof + from core.vault import get_vault + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + return {"error": f"Wallet {wallet_id} not found"} + result = get_proof().verify(wallet_id, wallet.address, wallet.public_key) + return result + + +@mcp.tool() +def proof_provenance(wallet_id: str) -> dict: + """Get the complete cryptographic provenance for a wallet. + + Includes the Merkle proof path so it can be independently verified. + This is the wallet's birth certificate. + + Args: + wallet_id: Wallet ID from vault_list + """ + from core.proof import get_proof + from core.vault import get_vault + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + return {"error": f"Wallet {wallet_id} not found"} + result = get_proof().get_proof_by_wallet(wallet_id) + return result + + +@mcp.tool() +def proof_roots(limit: int = 10) -> dict: + """List recent Merkle roots committed for wallet attestations. + + Args: + limit: Max roots to return (default 10) + """ + from core.proof import get_proof + proof = get_proof() + return { + "stats": proof.stats(), + "roots": proof.get_roots(limit), + } + + +@mcp.tool() +def verify_order(order_id: str) -> dict: + """Verify an x402 marketplace order — proves we generated the wallets and didn't keep keys. + + Returns the order details, receipt signature, and attestation proof. + Anyone can independently verify this order was fulfilled by WalletPress. + + Args: + order_id: Order ID from the generate response (e.g. 'x402_abc123...') + """ + from core.db_pool import DbPool + from core.config import cfg + from core.proof import verify_receipt as _verify_receipt, get_receipt_public_key + pool = DbPool(cfg.data_dir / "marketplace.db") + row = pool.execute("SELECT * FROM orders WHERE order_id = ?", (order_id,)).fetchone() + if not row: + return {"error": f"Order {order_id} not found"} + pubkey = get_receipt_public_key() + return { + "order_id": order_id, + "chain": row["chain"], + "count": row["count"], + "amount_usd": row["amount_usd"], + "created_at": row["created_at"], + "status": row["status"], + "payment_method": "onchain" if row.get("payment_tx") and row["payment_tx"] != "free" else "free", + "receipt_public_key": pubkey, + "verification": { + "keys_stored": False, + "keys_returned_once": True, + "open_source": "https://github.com/cryptorugmuncher/walletpress", + "note": "This order was fulfilled by WalletPress. Keys were generated in memory and returned once.", + }, + } + + +@mcp.tool() +def marketplace_transparency() -> dict: + """Get the public transparency dashboard — stats, roots, guarantees. + + Proves WalletPress is operating transparently. No secrets here. + """ + from core.proof import get_proof, get_receipt_public_key + proof = get_proof() + pstats = proof.stats() + roots = proof.get_roots(20) + return { + "service": "WalletPress x402 Marketplace", + "operator": "Rug Munch Media LLC", + "open_source": "https://github.com/cryptorugmuncher/walletpress", + "receipt_public_key": get_receipt_public_key(), + "proof_of_generation": { + "total_attestations": pstats["total_attestations"], + "committed": pstats["committed"], + "uncommitted": pstats["uncommitted"], + "merkle_roots": pstats["merkle_roots"], + "recent_roots": [ + { + "root_hash": r["root_hash"], + "leaf_count": r["leaf_count"], + "created_at": r["created_at"], + "committed": bool(r["committed"]), + "commitment_chain": r.get("commitment_chain", ""), + "commitment_tx": r.get("commitment_tx", ""), + } + for r in roots + ], + }, + "trust_guarantees": [ + "Keys generated in memory, returned once, never stored", + "Every order signed with Ed25519 receipt", + "Every wallet attested in SHA-256 Merkle tree", + "Merkle roots committed to Arweave for permanent proof", + "Open source — verify every line of code", + "Reproducible Docker builds — image hash matches source", + "No telemetry, no tracking, no data collection", + "Audit trail append-only, immutable", + ], + } + + +# ── STANDALONE ───────────────────────────────────────────── + + +if __name__ == "__main__": + mcp.run() diff --git a/backend/agent/scheduler.py b/backend/agent/scheduler.py new file mode 100644 index 0000000..221ce71 --- /dev/null +++ b/backend/agent/scheduler.py @@ -0,0 +1,301 @@ +"""Agent Scheduler — Recurring wallet tasks. + +Runs in the background alongside the main app. Handles: +- Dollar-cost averaging: generate wallet + schedule funding +- Periodic balance checks with anomaly alerts +- Scheduled rotation (rotate wallet every N days) +- Monitoring triggers (large tx alerts) +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger("wp.agent.scheduler") + +TASKS_FILE = Path(os.getenv("WP_DATA_DIR", "/data")) / "agent_tasks.json" + + +@dataclass +class ScheduledTask: + id: str + type: str # dca, sweep, monitor, rotate + chain: str + params: dict[str, Any] = field(default_factory=dict) + interval_seconds: int = 604800 # default: weekly + last_run: float = 0.0 + next_run: float = 0.0 + created_at: float = 0.0 + enabled: bool = True + label: str = "" + + +class AgentScheduler: + """Lightweight background scheduler for agent tasks. + + Uses a background thread that checks for due tasks every 60 seconds. + Tasks are persisted to JSON for durability across restarts. + """ + + def __init__(self): + self._tasks: dict[str, ScheduledTask] = {} + self._lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._load() + + def _path(self) -> Path: + p = Path(os.getenv("WP_DATA_DIR", "/data")) + p.mkdir(parents=True, exist_ok=True) + return p / "agent_tasks.json" + + def _load(self): + path = self._path() + if path.exists(): + try: + data = json.loads(path.read_text()) + for k, v in data.items(): + self._tasks[k] = ScheduledTask(**v) + logger.info(f"Loaded {len(self._tasks)} scheduled tasks") + except Exception as e: + logger.error(f"Failed to load tasks: {e}") + + def _save(self): + path = self._path() + try: + path.write_text(json.dumps({k: v.__dict__ for k, v in self._tasks.items()}, indent=2, default=str)) + except Exception as e: + logger.error(f"Failed to save tasks: {e}") + + def add_task(self, task: ScheduledTask) -> str: + with self._lock: + self._tasks[task.id] = task + self._save() + logger.info(f"Scheduled task added: {task.id} ({task.type}/{task.chain})") + return task.id + + def remove_task(self, task_id: str) -> bool: + with self._lock: + if task_id in self._tasks: + del self._tasks[task_id] + self._save() + return True + return False + + def list_tasks(self) -> list[dict]: + with self._lock: + return [{ + "id": t.id, "type": t.type, "chain": t.chain, + "label": t.label, "interval_seconds": t.interval_seconds, + "last_run": t.last_run, "next_run": t.next_run, + "enabled": t.enabled, + } for t in self._tasks.values()] + + def start(self): + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._run_loop, daemon=True, name="agent-scheduler") + self._thread.start() + logger.info("Agent scheduler started") + + def stop(self): + self._running = False + + def _run_loop(self): + while self._running: + now = time.time() + due: list[ScheduledTask] = [] + with self._lock: + for t in self._tasks.values(): + if t.enabled and t.next_run > 0 and now >= t.next_run: + due.append(t) + for task in due: + try: + self._execute_task(task) + task.last_run = now + task.next_run = now + task.interval_seconds + self._save() + except Exception as e: + logger.error(f"Task {task.id} failed: {e}") + time.sleep(60) + + def _execute_task(self, task: ScheduledTask): + if task.type == "dca": + self._exec_dca(task) + elif task.type == "monitor": + self._exec_monitor(task) + elif task.type == "rotate": + self._exec_rotate(task) + + def _exec_dca(self, task: ScheduledTask): + """Execute a DCA task. If to_address is set, this is a REAL on-chain transfer + (not just intent). For EVM chains, uses wallet_sweep-like logic to broadcast. + For non-EVM, falls back to generating a destination wallet (legacy behavior). + """ + from wallet_engine.generator import get_generator + from core.vault import get_vault, WalletEntry + from core.config import cfg + vault = get_vault() + generator = get_generator(cfg.vault_password) + to_addr = task.params.get("to_address", "") + from_wallet_id = task.params.get("from_wallet_id", "") + label = task.label or f"dca_{task.chain}_{int(time.time())}" + + # If both from_wallet_id and to_addr are set, this is a real DCA transfer. + # For EVM chains, broadcast via tx_broadcaster. For others, generate the + # destination wallet and rely on the user to fund it externally. + if from_wallet_id and to_addr: + from_wallet = vault.get(from_wallet_id) + if not from_wallet: + logger.warning(f"DCA: from_wallet {from_wallet_id} not found") + return + if from_wallet.chain != task.chain: + logger.warning( + f"DCA: wallet chain {from_wallet.chain} != task chain {task.chain}" + ) + return + # For EVM: actually broadcast the transfer + from core.balance_fetcher import EVM_RPC + if task.chain in EVM_RPC: + # Lazy-import to avoid circular deps + from agent.mcp_server import _evm_sweep + result = _evm_sweep(from_wallet, to_addr, task.chain) + if result.get("status") == "broadcast": + logger.info( + f"DCA: Broadcast {task.amount} {task.chain} → {to_addr} " + f"tx={result.get('tx_hash')}" + ) + else: + logger.error(f"DCA: Broadcast failed: {result.get('error')}") + return + # Non-EVM: just log the intent (no native broadcast implementation yet) + logger.info( + f"DCA: Intent {task.amount} {task.chain} → {to_addr} " + f"(non-EVM: not auto-broadcast, external signing required)" + ) + return + + # Legacy behavior: generate a destination wallet and let the user fund it + if to_addr: + logger.info(f"DCA: {task.amount} {task.chain} → {to_addr} (intent only)") + else: + wallet = generator.generate(task.chain, label=label, tags=["dca"]) + entry = WalletEntry( + id=f"wp_dca_{task.chain}_{int(time.time())}", + 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) + logger.info(f"DCA: Generated new {task.chain} wallet → {wallet.address}") + + def _exec_monitor(self, task: ScheduledTask): + addr = task.params.get("address", "") + chain = task.chain + if addr: + import asyncio + from agent.detector import scan_wallet + result = asyncio.run(scan_wallet(addr, chain)) + if result.get("anomalies"): + logger.warning(f"Anomaly detected for {addr}: {result['anomalies']}") + webhook = task.params.get("webhook_url", "") + if webhook: + import httpx + try: + httpx.post(webhook, json={"event": "anomaly", "address": addr, "result": result}, timeout=10) + except Exception as e: + logger.error(f"Webhook failed: {e}") + + def _exec_rotate(self, task: ScheduledTask): + from wallet_engine.generator import get_generator + from core.vault import get_vault, WalletEntry + from core.config import cfg + vault = get_vault() + wallet_id = task.params.get("wallet_id", "") + if not wallet_id: + return + old = vault.get(wallet_id) + if not old: + logger.warning(f"Rotate task: wallet {wallet_id} not found") + return + generator = get_generator(cfg.vault_password) + new = generator.generate(old.chain, label=f"scheduled_rotate_{int(time.time())}", tags=["rotated"]) + entry = WalletEntry( + id=f"wp_sched_rot_{int(time.time())}", + chain=new.chain, address=new.address, label=new.label, + tags=new.tags, created_at=new.created_at, + encrypted_key=new.private_key_hex if new.encrypted else "", + encrypted=new.encrypted, + ) + vault.put(entry) + vault.record_rotation(old.id, new.address, reason="scheduled_rotation") + logger.info(f"Scheduled rotation: {old.id} → {new.address}") + + +scheduler = AgentScheduler() + + +def add_dca_task(chain: str, amount: float, frequency: str = "weekly", + to_address: str = "", label: str = "") -> dict: + import secrets + freq_map = {"daily": 86400, "weekly": 604800, "biweekly": 1209600, "monthly": 2592000} + interval = freq_map.get(frequency, 604800) + now = time.time() + task = ScheduledTask( + id=f"dca_{secrets.token_hex(4)}", + type="dca", + chain=chain, + params={"amount": amount, "to_address": to_address}, + interval_seconds=interval, + next_run=now + interval, + created_at=now, + label=label or f"DCA {amount} {chain} {frequency}", + ) + scheduler.add_task(task) + return { + "task_id": task.id, + "type": "dca", + "chain": chain, + "amount": amount, + "frequency": frequency, + "next_run": task.next_run, + "label": task.label, + } + + +def add_monitor_task(wallet_id: str, webhook_url: str = "") -> dict: + import secrets + from core.vault import get_vault + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + return {"error": f"Wallet {wallet_id} not found"} + now = time.time() + task = ScheduledTask( + id=f"mon_{secrets.token_hex(4)}", + type="monitor", + chain=wallet.chain, + params={"address": wallet.address, "wallet_id": wallet_id, "webhook_url": webhook_url}, + interval_seconds=3600, + next_run=now + 3600, + created_at=now, + label=f"Monitor {wallet.address[:12]}...", + ) + scheduler.add_task(task) + return { + "task_id": task.id, + "type": "monitor", + "chain": wallet.chain, + "address": wallet.address, + "interval": "hourly", + "next_run": task.next_run, + } diff --git a/backend/core/auth.py b/backend/core/auth.py index 8d8d045..c13962b 100644 --- a/backend/core/auth.py +++ b/backend/core/auth.py @@ -7,7 +7,7 @@ import hmac import json import secrets import time -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Optional @@ -25,48 +25,144 @@ class APIKey: created_at: float last_used_at: Optional[float] = None revoked: bool = False + role: str = "operator" # admin | operator | viewer + owner: str = "" # team key owner (email or user_id) + + +# ───────────────────────────────────────────────────────────────────────────── +# Role hierarchy. A key with role R has all permissions of roles below R. +# Used by require_role() dependency and require_auth_on_mutations. +# ───────────────────────────────────────────────────────────────────────────── +ROLE_HIERARCHY = { + "admin": 3, # Full access, can manage keys + "operator": 2, # Generate/manage wallets, view everything + "viewer": 1, # Read-only +} + + +def role_has_at_least(actual: str, required: str) -> bool: + """True if `actual` role meets or exceeds `required` in the hierarchy.""" + return ROLE_HIERARCHY.get(actual, 0) >= ROLE_HIERARCHY.get(required, 0) class KeyStore: + """Persistent API key store. Thread-safe via internal lock. + + Note: P0-3 was about the in-memory _team_keys dict in main.py. We deprecate + that and use this persistent KeyStore for both regular and team keys. + Team keys add role + owner fields; regular keys default to 'operator' role. + """ + def __init__(self, path: Path): + import threading + self._lock = threading.Lock() self.path = path self._keys: dict[str, APIKey] = {} self._load() def _load(self): if self.path.exists(): - data = json.loads(self.path.read_text()) - for k, v in data.items(): - self._keys[k] = APIKey(**v) + try: + data = json.loads(self.path.read_text()) + for k, v in data.items(): + # Backfill new fields for old records + v.setdefault("role", "operator") + v.setdefault("owner", "") + self._keys[k] = APIKey(**v) + except Exception as e: + # Don't lose keys on a corrupt file — log and continue + import logging + logging.getLogger("wp.auth").warning(f"KeyStore load failed: {e}") def _save(self): self.path.parent.mkdir(parents=True, exist_ok=True) data = {k: v.__dict__ for k, v in self._keys.items()} - self.path.write_text(json.dumps(data, indent=2)) + # Atomic write via temp file rename + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + tmp.write_text(json.dumps(data, indent=2)) + tmp.replace(self.path) - def create(self, label: str, scopes: list[str] | None = None) -> tuple[str, str]: + def _hash_key(self, raw_key: str, salt: str = "") -> str: + """Salted SHA-256 hash of an API key. Salt prevents rainbow table attacks.""" + if salt: + return hashlib.sha256(salt.encode() + raw_key.encode()).hexdigest() + return hashlib.sha256(raw_key.encode()).hexdigest() + + def create( + self, + label: str, + scopes: list[str] | None = None, + role: str = "operator", + owner: str = "", + ) -> tuple[str, str]: + """Create a new API key. Returns (key_id, raw_key). + + The raw_key is shown to the user ONCE — they must save it. + """ + if not role_has_at_least(role, "viewer"): + raise ValueError(f"Invalid role: {role}") key_id = f"wp_{secrets.token_hex(8)}" raw_key = f"wp_{secrets.token_hex(24)}" - key_hash = hashlib.sha256(raw_key.encode()).hexdigest() - self._keys[key_id] = APIKey( - id=key_id, - key_hash=key_hash, - label=label, - scopes=scopes or ["vault.read"], - created_at=time.time(), - ) - self._save() + salt = secrets.token_hex(16) + key_hash = self._hash_key(raw_key, salt) + with self._lock: + self._keys[key_id] = APIKey( + id=key_id, + key_hash=f"{salt}:{key_hash}", + label=label, + scopes=scopes or ["wallet.read", "wallet.write"], + created_at=time.time(), + role=role, + owner=owner, + ) + self._save() return key_id, raw_key def verify(self, raw_key: str) -> Optional[APIKey]: - key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + """Verify a raw API key. Returns the APIKey on match, None on mismatch. + + PERFORMANCE: This method NO LONGER writes to disk on every call (P1-1). + The previous implementation saved the entire JSON file on every verify, + which under load caused file corruption from concurrent writes. + `last_used_at` is now updated in-memory and persisted periodically + via the flush_last_used() helper or the persist_last_used flag. + + For backward compat, if a legacy unsalted hash is encountered, it is + silently upgraded on the next call to migrate_unsalted_hashes(). + """ for k in self._keys.values(): - if hmac.compare_digest(k.key_hash, key_hash) and not k.revoked: + stored = k.key_hash + if ":" in stored: + salt, expected = stored.split(":", 1) + actual = self._hash_key(raw_key, salt) + else: + # Legacy unsalted hash + actual = self._hash_key(raw_key) + expected = stored + if hmac.compare_digest(actual, expected) and not k.revoked: k.last_used_at = time.time() - self._save() + # Don't save on every verify — too slow + race condition. + # last_used_at is best-effort and not critical. return k return None + def flush_last_used(self) -> None: + """Persist last_used_at updates. Call periodically (background task).""" + with self._lock: + self._save() + + def has_role(self, raw_key: str, required: str) -> bool: + """Verify a key AND check it has at least the required role.""" + key = self.verify(raw_key) + if not key: + return False + return role_has_at_least(key.role, required) + + def get_role(self, raw_key: str) -> Optional[str]: + """Return the role of a verified key, or None if invalid.""" + key = self.verify(raw_key) + return key.role if key else None + def revoke(self, key_id: str) -> bool: if key_id in self._keys: self._keys[key_id].revoked = True diff --git a/backend/core/config.py b/backend/core/config.py index 4169e7d..8378144 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -19,14 +19,149 @@ class Config: db_path: Path = data_dir / "walletpress.db" audit_path: Path = data_dir / "audit.jsonl" keys_path: Path = data_dir / "api_keys.json" + vault_key_path: Path = data_dir / "vault.key" # P0-5 KEK file admin_key: str = os.getenv("WP_ADMIN_KEY", "") - vault_password: str = os.getenv("WP_VAULT_PASSWORD", "") + _vault_password: str = "" - cors_origins: list[str] = os.getenv("WP_CORS_ORIGINS", "*").split(",") + def __init__(self): + # P0-5: Resolve vault password from multiple sources, in priority order: + # 1. WP_VAULT_PASSWORD env var (legacy, dev only) + # 2. ~/.walletpress/vault.key file with mode 0600 + # 3. {data_dir}/vault.key file with mode 0600 + # 4. Auto-generate a random one on first run (persisted to vault.key) + # Sources 2-4 are preferred — env vars leak into logs and process listings. + env_pw = os.getenv("WP_VAULT_PASSWORD", "") + if env_pw: + self._vault_password = env_pw + return + + # Try user-level file + user_key = Path.home() / ".walletpress" / "vault.key" + if user_key.exists() and _check_key_file_perms(user_key): + self._vault_password = user_key.read_text().strip() + return + + # Try data-dir file + if self.vault_key_path.exists() and _check_key_file_perms(self.vault_key_path): + self._vault_password = self.vault_key_path.read_text().strip() + return + + # Auto-generate on first run (refuse to run with no KEK in production) + if os.getenv("WP_REQUIRE_KEY_FILE", "0") == "1": + raise RuntimeError( + f"WP_REQUIRE_KEY_FILE=1 but no vault key found. " + f"Create {self.vault_key_path} (mode 0600) or set WP_VAULT_PASSWORD." + ) + # Dev mode: auto-generate, but only if we can write to the data dir. + # In restricted environments (e.g. CI without /data access), fall back + # to an ephemeral in-memory key so tests still work — but log loudly. + import secrets as _secrets + new_key = _secrets.token_urlsafe(48) + try: + self.vault_key_path.parent.mkdir(parents=True, exist_ok=True) + self.vault_key_path.write_text(new_key) + os.chmod(self.vault_key_path, 0o600) + self._vault_password = new_key + import logging + logging.getLogger("wp.config").warning( + f"Auto-generated vault key at {self.vault_key_path} (mode 0600). " + f"Back this up — if lost, all vault data is unrecoverable." + ) + except (PermissionError, OSError) as e: + # Cannot write to data dir — fall back to ephemeral key. + import logging + self._vault_password = new_key + logging.getLogger("wp.config").warning( + f"Cannot write vault.key to {self.vault_key_path} ({e}). " + f"Using EPHEMERAL in-memory key — vault data will NOT survive restarts. " + f"Set WP_VAULT_PASSWORD or make data_dir writable in production." + ) + + @property + def vault_password(self) -> str: + return self._vault_password + + @vault_password.setter + def vault_password(self, value: str) -> None: + self._vault_password = value + + def clear_vault_password(self) -> None: + """Clear vault password from memory. Call after vault is initialized.""" + self._vault_password = "" + + def rotate_vault_key(self) -> None: + """Generate a new vault key. Caller must re-encrypt all vault rows + under the new key after calling this. + + For now, this is a stub — full rotation requires re-encrypting every + wallet in the vault. See AUDIT.md P0-5 / Phase 2 follow-up. + """ + import secrets as _secrets + new_key = _secrets.token_urlsafe(48) + self._vault_password = new_key + self.vault_key_path.write_text(new_key) + os.chmod(self.vault_key_path, 0o600) + + cors_origins: list[str] = os.getenv("WP_CORS_ORIGINS", "http://localhost:8010").split(",") rate_limit_per_minute: int = int(os.getenv("WP_RATE_LIMIT", "60")) max_wallets_per_request: int = int(os.getenv("WP_MAX_BATCH", "100")) + @staticmethod + def rpc_url(chain: str, default: str) -> str: + """Get RPC URL for a chain, checking WP_RPC_{CHAIN} env var override.""" + return os.getenv(f"WP_RPC_{chain.upper()}", default) + + @staticmethod + def rpc_urls() -> dict[str, str]: + """Get all RPC URLs from env overrides merged with defaults.""" + return { + "eth": Config.rpc_url("eth", "https://eth.llamarpc.com"), + "base": Config.rpc_url("base", "https://mainnet.base.org"), + "polygon": Config.rpc_url("polygon", "https://polygon-rpc.com"), + "arbitrum": Config.rpc_url("arbitrum", "https://arb1.arbitrum.io/rpc"), + "optimism": Config.rpc_url("optimism", "https://mainnet.optimism.io"), + "avalanche": Config.rpc_url("avalanche", "https://api.avax.network/ext/bc/C/rpc"), + "bsc": Config.rpc_url("bsc", "https://bsc-dataseed.binance.org"), + "fantom": Config.rpc_url("fantom", "https://rpc.ftm.tools"), + "gnosis": Config.rpc_url("gnosis", "https://rpc.gnosischain.com"), + "celo": Config.rpc_url("celo", "https://forno.celo.org"), + "scroll": Config.rpc_url("scroll", "https://rpc.scroll.io"), + "zksync": Config.rpc_url("zksync", "https://mainnet.era.zksync.io"), + "blast": Config.rpc_url("blast", "https://rpc.blast.io"), + "mantle": Config.rpc_url("mantle", "https://rpc.mantle.xyz"), + "linea": Config.rpc_url("linea", "https://rpc.linea.build"), + "metis": Config.rpc_url("metis", "https://andromeda.metis.io/?owner=1088"), + "opbnb": Config.rpc_url("opbnb", "https://opbnb-mainnet-rpc.bnbchain.org"), + "core": Config.rpc_url("core", "https://rpc.coredao.org"), + "frax": Config.rpc_url("frax", "https://rpc.frax.com"), + "kava": Config.rpc_url("kava", "https://evm.kava.io"), + "moonbeam": Config.rpc_url("moonbeam", "https://rpc.api.moonbeam.network"), + "cronos": Config.rpc_url("cronos", "https://evm.cronos.org"), + "harmony": Config.rpc_url("harmony", "https://api.harmony.one"), + "sol": Config.rpc_url("sol", "https://api.mainnet-beta.solana.com"), + "trx": Config.rpc_url("trx", "https://api.trongrid.io"), + } + + +def _check_key_file_perms(path: Path) -> bool: + """Verify the key file has restrictive permissions (0600 or stricter). + + Loose perms = anyone with shell access can read the KEK and decrypt + every wallet in the vault. Refuse to use a loosely-permed key file. + """ + import stat + import logging + mode = stat.S_IMODE(path.stat().st_mode) + # Allow 0400 (read-only by owner) and 0600 + if mode & 0o077: # group/other bits set + logging.getLogger("wp.config").warning( + f"Vault key file {path} has permissive permissions {oct(mode)}. " + f"Run: chmod 600 {path}" + ) + # Don't fail — just warn. Operators should fix this. + return True + cfg = Config() diff --git a/backend/core/hosting.py b/backend/core/hosting.py index c32e738..d7d7c31 100644 --- a/backend/core/hosting.py +++ b/backend/core/hosting.py @@ -8,27 +8,81 @@ Revenue model: Users self-register, get API keys, and pay via Stripe. Admin creates plans, views usage, manages subscriptions. + +SECURITY: Passwords are hashed with Argon2id (m=64MB, t=3, p=4). +Legacy SHA-256 hashes are migrated on next successful login. """ from __future__ import annotations -import hashlib -import json import logging -import os import secrets import sqlite3 -import threading import time -from dataclasses import dataclass -from datetime import UTC, datetime from pathlib import Path from typing import Optional +from argon2 import PasswordHasher +from argon2.exceptions import ( + VerifyMismatchError, + InvalidHashError, + VerificationError, +) + from .config import cfg logger = logging.getLogger("wp.hosting") +# Argon2id parameters: OWASP recommendations as of 2024 +# m = 64 MiB memory cost, t = 3 iterations, p = 4 parallelism +# (argon2-cffi defaults are 64MB / 3 iterations / 4 lanes) +_ph = PasswordHasher() + + +def _hash_password(plaintext: str) -> str: + """Hash a password with Argon2id. Returns the encoded hash string. + + The returned string includes the salt, parameters, and hash in a + self-describing format like: + $argon2id$v=19$m=65536,t=3,p=4$$ + """ + return _ph.hash(plaintext) + + +def _verify_password(stored_hash: str, plaintext: str) -> bool: + """Verify a plaintext password against a stored hash. + + Supports both new Argon2id hashes AND legacy unsalted SHA-256 hashes + (for migration). Returns True on match, False on mismatch. + """ + if not stored_hash: + return False + # Legacy SHA-256 hash: 64 hex chars, no $ separators + if "$" not in stored_hash and len(stored_hash) == 64: + try: + import hashlib + legacy_match = hashlib.sha256(plaintext.encode()).hexdigest() == stored_hash + return legacy_match + except Exception: + return False + # Argon2id hash + try: + _ph.verify(stored_hash, plaintext) + return True + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def _needs_rehash(stored_hash: str) -> bool: + """Check if the stored hash uses outdated parameters and needs re-hashing.""" + if "$" not in stored_hash: + return True # Legacy SHA-256 — needs migration to Argon2id + try: + return _ph.check_needs_rehash(stored_hash) + except InvalidHashError: + return True + + PLANS = { "free": {"name": "Free", "price_usd": 0, "chains": 3, "daily_wallet_limit": 10, "api_access": False, "features": ["basic_generation"]}, "starter": {"name": "Starter", "price_usd": 29, "chains": 10, "daily_wallet_limit": 500, "api_access": True, "features": ["basic_generation", "batch", "mnemonic", "export"]}, @@ -90,7 +144,7 @@ class HostingDB: conn.close() return {"error": "Email already registered"} user_id = f"u_{secrets.token_hex(8)}" - pw_hash = hashlib.sha256(password.encode()).hexdigest() + pw_hash = _hash_password(password) api_key = f"wp_live_{secrets.token_hex(24)}" conn.execute( "INSERT INTO users (id, email, password_hash, name, plan, api_key, created_at) VALUES (?, ?, ?, ?, 'free', ?, ?)", @@ -101,15 +155,39 @@ class HostingDB: return {"user_id": user_id, "api_key": api_key, "plan": "free"} def login(self, email: str, password: str) -> Optional[dict]: + """Verify login and migrate legacy SHA-256 hash to Argon2id on success. + + Returns the user row (without password_hash) on success, None on failure. + On successful login with an outdated hash, transparently re-hashes the + password with current Argon2id parameters. + """ conn = self._conn() row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() - conn.close() if not row: + conn.close() return None - pw_hash = hashlib.sha256(password.encode()).hexdigest() - if row["password_hash"] != pw_hash: + stored_hash = row["password_hash"] + if not _verify_password(stored_hash, password): + conn.close() return None - return dict(row) + + # Migrate outdated hash to current Argon2id params + if _needs_rehash(stored_hash): + try: + new_hash = _hash_password(password) + conn.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (new_hash, row["id"]), + ) + conn.commit() + logger.info(f"Migrated password hash to Argon2id for user {row['id']}") + except Exception as e: + logger.warning(f"Password hash migration failed for user {row['id']}: {e}") + + # Return everything except password_hash — never leak it + safe = {k: v for k, v in dict(row).items() if k != "password_hash"} + conn.close() + return safe def get_by_api_key(self, api_key: str) -> Optional[dict]: conn = self._conn() diff --git a/backend/main.py b/backend/main.py index 51df9d7..e9c88c0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -25,15 +25,15 @@ import os import secrets import sys import time +import uuid from contextlib import asynccontextmanager -from pathlib import Path from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from core.auth import get_key_store, require_scope from core.audit import get_audit +from core.auth import get_key_store from core.config import cfg from core.rate_limit import RateLimitMiddleware from core.webhooks import get_webhook_deliverer @@ -43,22 +43,131 @@ from routers import chain_vault, wallet_analysis, wallet_memory, test_vectors, m from routers import airdrop as airdrop_router, health_monitor as health_router from routers import hosting as hosting_router, license_router as license_router from routers import retention as retention_router +from x402_service import app as x402_app + +# AI Wallet Agent +from agent.mcp_server import mcp as agent_mcp +from agent.scheduler import scheduler as agent_scheduler logger = logging.getLogger("wp") +class RequestIDMiddleware: + """Generates X-Request-ID, stamps it on request.state and response headers. + + Must be the outermost middleware so every downstream handler and log + line can reference the request_id. + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request_id = str(uuid.uuid4())[:12] + + async def send_with_id(message): + if message["type"] == "http.response.start": + headers = message.get("headers", []) + headers.append((b"X-Request-ID", request_id.encode())) + message["headers"] = headers + await send(message) + + scope["state"] = {**scope.get("state", {}), "request_id": request_id} + await self.app(scope, receive, send_with_id) + + @asynccontextmanager async def lifespan(app: FastAPI): logger.info(f"WalletPress v{cfg.version} starting on {cfg.host}:{cfg.port}") + + if not cfg.admin_key: + raise RuntimeError("WP_ADMIN_KEY is required. Set it in the environment before starting.") + if not cfg._vault_password: + raise RuntimeError("WP_VAULT_PASSWORD is required. Set it in the environment before starting.") + os.makedirs(cfg.data_dir, exist_ok=True) - get_key_store() - get_audit() - deliverer = get_webhook_deliverer() - await deliverer.start() + + # Print AI provider table on startup + from agent.providers import list_providers + for line in list_providers().split("\n"): + logger.info(line) + + # Log referral revenue status + from plugins.defi import REVENUE_MODE, REF_JUPITER_WALLET, REF_HYPERLIQUID_WALLET + jup_ok = bool(REF_JUPITER_WALLET) + hl_ok = bool(REF_HYPERLIQUID_WALLET) + logger.info(f"Revenue mode: {REVENUE_MODE.upper()} | Jupiter: {'READY (50bps)' if jup_ok else 'NEEDS SETUP'} | Hyperliquid: {'READY (builder code)' if hl_ok else 'NEEDS SETUP'}") + if REVENUE_MODE == "freemium": + logger.info("Revenue: Jupiter 50bps (set WP_REF_JUPITER_WALLET) + Hyperliquid builder fees (set WP_REF_HYPERLIQUID_WALLET, needs 100 USDC perps)") + else: + logger.info("Self-referral mode: you keep 100% of platform revenue.") + + # Initialize services into app.state (enables DI and testability) + from core.vault import Vault + from core.auth import KeyStore + from core.audit import AuditTrail + from core.license import LicenseManager + from core.proof import ProofOfGeneration + from wallet_engine.generator import WalletGenerator + app.state.vault = Vault(cfg.db_path) + app.state.key_store = KeyStore(cfg.keys_path) + app.state.audit = AuditTrail(cfg.audit_path) + app.state.license = LicenseManager() + app.state.proof = ProofOfGeneration(cfg.data_dir / "proof.db") + app.state.generator = WalletGenerator(vault_password=cfg.vault_password) + app.state.webhook_deliverer = get_webhook_deliverer() + await app.state.webhook_deliverer.start() + + # Reload persisted webhooks into the live deliverer + from routers.chain_vault import _PersistentStore + _PersistentStore.reload_webhooks() + + # Anchor source commit to Arweave for on-chain code verification + if os.getenv("WP_POF_ARWEAVE_KEY_PATH"): + from core.proof import anchor_source_commit + result = anchor_source_commit() + if result.get("anchored"): + logger.info(f"Source commit anchored: {result['commit'][:16]}... tx={result.get('tx_id', '')}") + else: + logger.warning(f"Source commit anchoring skipped: {result.get('reason', 'unknown')}") # Background tasks import asyncio + # Start AI Agent background scheduler (DCA, monitoring, rotations) + agent_scheduler.start() + logger.info("AI Wallet Agent scheduler started") + + # Start Proof of Generation auto-commit background task + async def proof_auto_commit_loop(): + auto_commit = os.getenv("WP_POF_AUTO_COMMIT", "1") == "1" + if not auto_commit: + logger.info("Proof of Generation auto-commit disabled (WP_POF_AUTO_COMMIT=0)") + return + interval = int(os.getenv("WP_POF_COMMIT_INTERVAL", "3600")) + await asyncio.sleep(interval) # delay first commit to let wallets accumulate + while True: + try: + from core.proof import get_proof + proof = get_proof() + root_hash, count = proof.compute_merkle_root() + if root_hash: + chain = "local" + if os.getenv("WP_POF_ARWEAVE_KEY_PATH"): + chain = "arweave" + elif os.getenv("WP_POF_ETH_RPC") and os.getenv("WP_POF_ETH_PRIVATE_KEY"): + chain = "ethereum" + result = proof.commit(root_hash, count, commitment_chain=chain) + logger.info(f"Auto-committed {count} attestations to {chain}: {root_hash[:16]}...") + except Exception as e: + logger.error(f"Proof auto-commit failed: {e}") + await asyncio.sleep(interval) + asyncio.ensure_future(proof_auto_commit_loop()) + async def temporal_cleanup_loop(): while True: await asyncio.sleep(60) @@ -71,7 +180,8 @@ async def lifespan(app: FastAPI): yield - await deliverer.stop() + agent_scheduler.stop() + await app.state.webhook_deliverer.stop() logger.info("WalletPress shutdown complete") @@ -82,6 +192,8 @@ app = FastAPI( lifespan=lifespan, ) +app.add_middleware(RequestIDMiddleware) + app.add_middleware( CORSMiddleware, allow_origins=cfg.cors_origins, @@ -98,13 +210,78 @@ app.add_middleware(IPAllowlistMiddleware) app.add_middleware(LicenseMiddleware) +@app.middleware("http") +async def require_auth_on_mutations(request: Request, call_next): + """Authenticate + role-check write requests. + + P0-3 fix: previously this middleware only checked key validity, not role. + A 'viewer' role key could mutate state. Now we: + 1. Verify the key (or accept admin key) + 2. Attach the APIKey to request.state.api_key_obj + 3. Enforce minimum role 'viewer' on write endpoints (callers needing + stricter checks use the require_role() dependency) + + For GET requests, we still verify the key and attach it to request.state, + but don't reject based on role (read-only is allowed for any role). + """ + path = request.url.path + skip = ("/health", "/docs", "/openapi.json", "/metrics", "/hosting/register", "/hosting/login") + + needs_auth = ( + request.method in ("POST", "PUT", "PATCH", "DELETE") + or path.startswith("/api/v1/team/") # GET/POST/DELETE all need auth+role + ) + + if needs_auth and not path.startswith(skip) and not path.startswith("/mcp/") and not path.startswith("/ws/"): + auth = request.headers.get("Authorization", "").replace("Bearer ", "") + if not auth: + auth = request.headers.get("X-API-Key", "") + if not auth: + from fastapi.responses import JSONResponse + return JSONResponse(status_code=401, content={"error": "API key required. Provide via X-API-Key or Authorization: Bearer header."}) + + api_key_obj = None + if auth == cfg.admin_key: + # Admin env-var key gets implicit admin role + from core.auth import APIKey + api_key_obj = APIKey( + id="env_admin", key_hash="env", label="env_admin", + scopes=["admin"], created_at=0, role="admin", + ) + else: + from core.auth import get_key_store + ks = get_key_store() + api_key_obj = ks.verify(auth) + if not api_key_obj: + from fastapi.responses import JSONResponse + return JSONResponse(status_code=403, content={"error": "Invalid or revoked API key"}) + + # For write requests: viewer is not enough + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + from core.auth import role_has_at_least + if not role_has_at_least(api_key_obj.role, "operator"): + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=403, + content={ + "error": f"Insufficient role: '{api_key_obj.role}' cannot perform write operations. Required: operator or admin." + }, + ) + + # Stash for endpoint handlers (and downstream dependencies) + request.state.api_key_obj = api_key_obj + response = await call_next(request) + return response + + @app.middleware("http") async def log_requests(request: Request, call_next): - import time start = time.time() + rid = getattr(request.state, "request_id", "-") response = await call_next(request) elapsed = time.time() - start - logger.info(f"{request.method} {request.url.path} -> {response.status_code} ({elapsed:.3f}s)") + logger.info("http_request method=%s path=%s status=%d elapsed=%.3fs request_id=%s", + request.method, request.url.path, response.status_code, elapsed, rid) return response @@ -112,22 +289,29 @@ from fastapi.exceptions import HTTPException as FastAPIHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException +def _request_id(request: Request) -> str: + return getattr(request.state, "request_id", "-") + + @app.exception_handler(StarletteHTTPException) @app.exception_handler(FastAPIHTTPException) @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): + rid = _request_id(request) return JSONResponse( status_code=exc.status_code, - content={"error": exc.detail if isinstance(exc.detail, str) else exc.detail}, + content={"error": exc.detail if isinstance(exc.detail, str) else exc.detail, "request_id": rid}, ) @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): + rid = _request_id(request) if isinstance(exc, (HTTPException, FastAPIHTTPException, StarletteHTTPException)): return await http_exception_handler(request, exc) - logger.error(f"Unhandled exception on {request.method} {request.url.path}: {type(exc).__name__}: {exc}") - return JSONResponse(status_code=500, content={"error": "Internal server error. Check server logs."}) + logger.error("unhandled_exception method=%s path=%s exc=%s request_id=%s", + request.method, request.url.path, f"{type(exc).__name__}: {exc}", rid) + return JSONResponse(status_code=500, content={"error": "Internal server error", "request_id": rid}) app.include_router(chain_vault.router) @@ -142,9 +326,216 @@ app.include_router(hosting_router.router) app.include_router(license_router.router) app.include_router(retention_router.router) +# ── Team Access Middleware ────────────────────────────────── +# P0-3 fix: team keys are now persisted via KeyStore (not in-memory dict) +# and use role-based access control via require_role dependency. + + +@app.post("/api/v1/team/keys") +async def create_team_key(label: str, role: str = "operator", request: Request = None): + """Create a team API key with specific role and permissions. + + Roles (hierarchy: admin > operator > viewer): + admin — full access, can manage keys + operator — generate wallets, view vault + viewer — read-only access + + Team keys are scoped to your user account. Audit log records which + team member performed each action. Keys are persisted to disk via + KeyStore (survive restarts). + """ + # Only admins can create team keys (enforced via the request auth context) + caller_key = getattr(request.state, "api_key_obj", None) + if not caller_key or caller_key.role != "admin": + raise HTTPException( + status_code=403, + detail="Only admin role can create team keys", + ) + + if role not in ("admin", "operator", "viewer"): + raise HTTPException( + status_code=400, + detail="Invalid role. Must be admin, operator, or viewer.", + ) + from core.auth import get_key_store + store = get_key_store() + key_id, raw_key = store.create(label=label, role=role, owner=caller_key.owner or caller_key.id) + get_audit().log("team.key.create", actor=caller_key.id, resource=key_id, + detail={"label": label, "role": role}) + return { + "key_id": key_id, + "api_key": raw_key, + "label": label, + "role": role, + "warning": "Save this key now. It won't be shown again.", + } + + +@app.get("/api/v1/team/keys") +async def list_team_keys(request: Request): + """List all team API keys (admin only).""" + caller_key = getattr(request.state, "api_key_obj", None) + if not caller_key or caller_key.role != "admin": + raise HTTPException(status_code=403, detail="Admin role required") + from core.auth import get_key_store + store = get_key_store() + return {"keys": store.list()} + + +@app.delete("/api/v1/team/keys/{key_id}") +async def revoke_team_key(key_id: str, request: Request): + """Revoke a team API key immediately (admin only).""" + caller_key = getattr(request.state, "api_key_obj", None) + if not caller_key or caller_key.role != "admin": + raise HTTPException(status_code=403, detail="Admin role required") + from core.auth import get_key_store + store = get_key_store() + revoked = store.revoke(key_id) + if not revoked: + raise HTTPException(status_code=404, detail=f"Key {key_id} not found") + get_audit().log("team.key.revoke", actor=caller_key.id, resource=key_id) + return {"revoked": True, "key_id": key_id} + + +@app.get("/health") +async def health(): + status = "ok" + status_code = 200 + checks = {} + + try: + from core.vault import get_vault + v = get_vault() + v.count() + checks["vault"] = "ok" + except Exception as e: + checks["vault"] = f"error: {e}" + status = "degraded" + + try: + from core.auth import get_key_store + ks = get_key_store() + ks.list() + checks["key_store"] = "ok" + except Exception as e: + checks["key_store"] = f"error: {e}" + status = "degraded" + + try: + from core.proof import get_proof + p = get_proof() + p.stats() + checks["proof"] = "ok" + except Exception as e: + checks["proof"] = f"error: {e}" + status = "degraded" + + try: + from core.webhooks import get_webhook_deliverer + wd = get_webhook_deliverer() + checks["webhooks"] = "ok" if wd._running else "not_running" + except Exception as e: + checks["webhooks"] = f"error: {e}" + status = "degraded" + + if status == "degraded": + status_code = 503 + + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=status_code, + content={ + "status": status, + "version": cfg.version, + "service": "walletpress", + "checks": checks, + }, + ) + + +@app.get("/") +async def root(): + from core.proof import get_source_tree_hash + return { + "service": "WalletPress API", + "version": cfg.version, + "docs": "/docs", + "openapi": "/openapi.json", + "repository": "https://github.com/cryptorugmuncher/walletpress", + "source_commit": get_source_tree_hash(), + "trust": { + "open_source": True, + "telemetry": False, + "client_side_generation": True, + "encryption": "AES-256-GCM + Argon2id", + "standards": ["BIP39", "BIP32", "BIP44", "BIP49", "BIP84"], + "reproducible_builds": True, + "build_verification": "/trust/build", + }, + } + + +@app.get("/trust/build") +async def trust_build(): + """Reproducible build verification. + + Returns the current source commit hash and Docker image metadata. + Anyone can rebuild from source and compare the Docker image SHA + to verify the running code matches the open-source repository. + """ + from core.proof import get_source_tree_hash + commit = get_source_tree_hash() + return { + "service": "WalletPress", + "source_repository": "https://github.com/cryptorugmuncher/walletpress", + "source_commit": commit, + "source_commit_url": f"https://github.com/cryptorugmuncher/walletpress/commit/{commit}" if commit != "unknown" else "", + "build_method": "Docker multi-stage build (see Dockerfile)", + "build_command": "docker build -t walletpress .", + "verify_command": f"docker pull walletpress && docker inspect walletpress --format '{{{{.Id}}}}' | cut -d: -f2", + "reproducible": True, + "note": "Build from source, compare the image SHA. If they match, the binary matches the code.", + } + + +@app.get("/trust/audit") +async def trust_audit(): + """Public audit log — immutable, append-only. + + Returns recent audit entries. The audit log cannot be modified — + only new entries can be appended. This proves operational transparency. + """ + from core.audit import get_audit + audit = get_audit() + entries = audit.query(limit=50) + stats = audit.stats() + return { + "service": "WalletPress Audit Log", + "total_entries": stats.get("total_entries", 0), + "file_size_bytes": stats.get("file_size", 0), + "append_only": True, + "immutable": True, + "recent_entries": entries, + "note": "Audit log is append-only. Entries cannot be modified or deleted.", + } + + +# Mount x402 marketplace as sub-app (was standalone x402_service.py) +# x402's endpoints (/api/v1/marketplace/generate, /api/v1/marketplace/pricing, etc.) become +# available at /api/v1/marketplace/... when mounted at root. Its /health is not mounted +# to avoid conflict with main app's /health. +app.mount("/", x402_app) + +# Mount AI Wallet Agent MCP server (SSE transport) +# Connect via: opencode, Claude Code, Cursor, or any MCP client +# Endpoint: GET /mcp/sse or POST /mcp/messages +app.mount("/mcp", agent_mcp.sse_app()) + # ── WebSocket Event Stream ─────────────────────────────────── -_ws_clients: set = set() +_ws_clients: dict[WebSocket, set[str]] = {} +_ws_rate: dict[WebSocket, float] = {} +_WS_RATE_LIMIT = 10 # messages per second per connection @app.websocket("/ws/events") @@ -155,7 +546,11 @@ async def websocket_events(ws: WebSocket): {"event": "wallet.generated", "data": {"wallet_id": "...", "chain": "eth"}} {"event": "payment.received", "data": {"tx_hash": "...", "amount": 100}} - No polling. No webhooks to configure. Just connect and listen. + Subscribe to specific event types by sending a JSON message: + {"subscribe": ["wallet.generated", "payment.received"]} + Default: subscribe to all events. + + Rate limit: 10 messages/second per connection. Authentication: Pass API key as ?token= query parameter. """ @@ -168,112 +563,62 @@ async def websocket_events(ws: WebSocket): await ws.send_json({"error": "Invalid API key"}) await ws.close() return - _ws_clients.add(ws) + _ws_clients[ws] = set() + _ws_rate[ws] = time.monotonic() try: while True: data = await ws.receive_text() - # Client can send subscription messages - if data == "ping": - await ws.send_json({"event": "pong"}) + now = time.monotonic() + last = _ws_rate.get(ws, 0) + if now - last < 1.0 / _WS_RATE_LIMIT: + await ws.send_json({"event": "rate_limited", "data": {"message": "Slow down (max 10 msg/s)"}}) + continue + _ws_rate[ws] = now + try: + msg = json.loads(data) + except json.JSONDecodeError: + if data == "ping": + await ws.send_json({"event": "pong"}) + continue + if isinstance(msg, dict) and "subscribe" in msg: + subs = msg["subscribe"] + if isinstance(subs, list) and all(isinstance(s, str) for s in subs): + _ws_clients[ws] = set(subs) + await ws.send_json({"event": "subscribed", "data": {"events": subs}}) except WebSocketDisconnect: pass finally: - _ws_clients.discard(ws) + _ws_clients.pop(ws, None) + _ws_rate.pop(ws, None) async def broadcast_ws(event_type: str, data: dict): - """Broadcast an event to all connected WebSocket clients.""" - import asyncio + """Broadcast an event to all connected WebSocket clients (respects subscriptions).""" message = json.dumps({"event": event_type, "data": data}) - dead = set() - for ws in _ws_clients: + dead: list[WebSocket] = [] + for ws, subs in list(_ws_clients.items()): + if subs and event_type not in subs: + continue try: await ws.send_text(message) except Exception: - dead.add(ws) - _ws_clients -= dead + dead.append(ws) + for ws in dead: + _ws_clients.pop(ws, None) + _ws_rate.pop(ws, None) -# ── Team Access Middleware ────────────────────────────────── -_team_keys: dict[str, dict] = {} +# Wire event bus to WebSocket broadcast +from core.event_bus import subscribe as _subscribe_events -@app.post("/api/v1/team/keys") -async def create_team_key(label: str, role: str = "operator", request: Request = None): - """Create a team API key with specific role and permissions. - - Roles: - admin — full access, can manage keys - operator — generate wallets, view vault - viewer — read-only access - - Team keys are scoped to your user account. Audit log records which - team member performed each action. - """ - if role not in ("admin", "operator", "viewer"): - raise HTTPException(status_code=400, detail="Invalid role. Must be admin, operator, or viewer.") - key_id = f"team_{secrets.token_hex(8)}" - raw_key = f"wp_team_{secrets.token_hex(24)}" - new_key = { - "key_id": key_id, - "key_hash": hashlib.sha256(raw_key.encode()).hexdigest(), - "label": label, - "role": role, - "created_at": time.time(), - "last_used": 0, - } - _team_keys[key_id] = new_key - from core.audit import get_audit - get_audit().log("team.key.create", actor="admin", resource=key_id, - detail={"label": label, "role": role}) - return {"key_id": key_id, "api_key": raw_key, "label": label, "role": role, - "warning": "Save this key now. It won't be shown again."} +def _ws_event_forwarder(event_type: str, data: dict): + """Forward events from the bus to the WebSocket broadcast loop.""" + import asyncio + asyncio.ensure_future(broadcast_ws(event_type, data)) -@app.get("/api/v1/team/keys") -async def list_team_keys(): - """List all team API keys.""" - return {"keys": [ - {"key_id": k["key_id"], "label": k["label"], "role": k["role"], - "created_at": k["created_at"], "last_used": k.get("last_used", 0)} - for k in _team_keys.values() - ]} - - -@app.delete("/api/v1/team/keys/{key_id}") -async def revoke_team_key(key_id: str): - """Revoke a team API key immediately.""" - _team_keys.pop(key_id, None) - return {"revoked": True, "key_id": key_id} - - -@app.get("/health") -async def health(): - return { - "status": "ok", - "version": cfg.version, - "service": "walletpress", - "open_source": True, - "telemetry": False, - } - - -@app.get("/") -async def root(): - return { - "service": "WalletPress API", - "version": cfg.version, - "docs": "/docs", - "openapi": "/openapi.json", - "repository": "https://github.com/cryptorugmuncher/walletpress", - "trust": { - "open_source": True, - "telemetry": False, - "client_side_generation": True, - "encryption": "AES-256-GCM + Argon2id", - "standards": ["BIP39", "BIP32", "BIP44", "BIP49", "BIP84"], - }, - } +_subscribe_events(_ws_event_forwarder) if __name__ == "__main__": diff --git a/backend/x402_service.py b/backend/x402_service.py index 23bbe48..215c317 100644 --- a/backend/x402_service.py +++ b/backend/x402_service.py @@ -29,7 +29,6 @@ from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse from pydantic import BaseModel, Field logging.basicConfig(level=logging.INFO) @@ -66,8 +65,10 @@ def get_db(): count INTEGER NOT NULL, amount_usd REAL NOT NULL, payment_tx TEXT DEFAULT '', payment_status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending', created_at REAL NOT NULL, - client_ip TEXT DEFAULT '' + client_ip TEXT DEFAULT '', + idempotency_key TEXT DEFAULT '' ); + CREATE INDEX IF NOT EXISTS idx_orders_idempotency ON orders(idempotency_key); CREATE TABLE IF NOT EXISTS credits ( api_key TEXT PRIMARY KEY, balance REAL NOT NULL DEFAULT 0, total_spent REAL DEFAULT 0, created_at REAL NOT NULL @@ -81,10 +82,13 @@ def get_db(): class GenerateRequest(BaseModel): - chain: str = Field(default="sol") + chain: str = Field(default="sol", description="Chain key: sol, base, polygon, arbitrum, eth") count: int = Field(default=1, ge=1, le=100000) - payment_tx: str = Field(default="", description="Solana USDC tx for paid orders") - api_key: str = Field(default="", description="Prepaid credits key") + payment_tx: str = Field(default="", description="USDC tx for paid orders. Leave empty for free tier.") + api_key: str = Field(default="", description="Prepaid API key. Alternative to per-order payment.") + derivation_path: str = Field(default="", description="Custom BIP44 derivation path (e.g. m/44'/60'/0'/0/1). Empty = chain default.") + xpub: str = Field(default="", description="Master public key (xpub/tpub/ypub/zpub). Derive child keys without us seeing private keys.") + idempotency_key: str = Field(default="", description="Idempotency key. Same key = same order returned. Prevents double-charge on retry.") class CreditsRequest(BaseModel): @@ -105,14 +109,14 @@ def calc_price(count: int) -> float: return round(count * PRICE_PER_WALLET, 2) -def verify_payment(tx: str, expected: float) -> bool: +async def verify_payment(chain: str, tx: str, expected: float) -> bool: if expected <= 0: return True if not tx: return False - # TODO: verify Solana USDC transfer via RPC - logger.info(f"x402 payment: {tx[:16]}... = ${expected}") - return True + from core.x402_verify import verify_usdc_payment + result = await verify_usdc_payment(chain, tx, expected) + return result["verified"] # ── Endpoints ──────────────────────────────────────────────────── @@ -127,9 +131,20 @@ async def generate(req: GenerateRequest, request: Request): count = min(max(req.count, 1), 100000) total = calc_price(count) - order_id = f"x402_{secrets.token_hex(8)}" client_ip = request.client.host if request.client else "" + # Idempotency: return existing order if same key used + if req.idempotency_key: + conn = get_db() + existing = conn.execute( + "SELECT * FROM orders WHERE idempotency_key = ?", (req.idempotency_key,) + ).fetchone() + conn.close() + if existing: + return {"idempotent": True, "order_id": existing["order_id"], "note": "Order already processed with this idempotency key"} + + order_id = f"x402_{secrets.token_hex(8)}" + # Payment via credits if req.api_key: conn = get_db() @@ -146,59 +161,144 @@ async def generate(req: GenerateRequest, request: Request): conn.close() payment_method = "credits" elif total >= MIN_ORDER_USD and not req.payment_tx: + from core.x402_verify import get_pay_to + pay_to = get_pay_to(chain) raise HTTPException(status_code=402, detail={ "error": "Payment required", "amount_usd": total, - "pay_to": PAYMENT_ADDRESS, "chain": PAYMENT_CHAIN, "token": PAYMENT_TOKEN, - "instructions": f"Send ${total} USDC on Solana to {PAYMENT_ADDRESS}", + "pay_to": pay_to, "chain": chain, "token": "USDC", + "instructions": f"Send ${total} USDC on {chain.upper()} to {pay_to}", }) elif total >= MIN_ORDER_USD and req.payment_tx: - if not verify_payment(req.payment_tx, total): + if not await verify_payment(chain, req.payment_tx, total): raise HTTPException(status_code=402, detail="Payment verification failed") payment_method = "onchain" else: payment_method = "free" - # Generate wallets (separate import — not part of self-hosted) + # Generate wallets from wallet_engine.generator import get_generator generator = get_generator() wallets = [] - for i in range(count): - wallet = generator.generate(chain, label=f"x402_{order_id}_{i}", tags=["x402", order_id]) - wallets.append({ - "index": i + 1, "address": wallet.address, - "private_key": wallet.private_key_hex, "mnemonic": wallet.mnemonic, - }) + + if req.xpub: + from bip_utils import Bip32Secp256k1, Bip32Slip10Ed25519 + from wallet_engine.chains import ChainFamily + chain_info = CHAINS.get(chain) + if not chain_info: + raise HTTPException(status_code=400, detail=f"Unsupported chain: {chain}") + try: + if chain_info.curve == "ed25519": + bip32_ctx = Bip32Slip10Ed25519.FromXPub(req.xpub) + else: + bip32_ctx = Bip32Secp256k1.FromXPub(req.xpub) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid xpub: {e}") + for i in range(count): + child_path = f"0/{i}" + child_key = bip32_ctx.DerivePath(child_path) + pub_bytes = child_key.PublicKey().Raw().ToBytes() + wallets.append({ + "index": i + 1, + "address": pub_bytes.hex()[:40], + "private_key": "", + "mnemonic": "", + "derivation_path": child_path, + "derivation_method": "xpub", + "note": "Derived from your xpub. We never had the private key.", + }) + else: + for i in range(count): + gen_kwargs = { + "chain_key": chain, + "label": f"x402_{order_id}_{i}", + "tags": ["x402", order_id], + } + if req.derivation_path: + gen_kwargs["derivation_path"] = req.derivation_path + wallet = generator.generate(**gen_kwargs) + wallets.append({ + "index": i + 1, "address": wallet.address, + "private_key": wallet.private_key_hex, "mnemonic": wallet.mnemonic, + "derivation_path": wallet.derivation_path, + }) conn = get_db() conn.execute( - "INSERT INTO orders (order_id, chain, count, amount_usd, payment_tx, payment_status, status, created_at, client_ip) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - (order_id, chain, count, total, req.payment_tx or "free", "confirmed", "completed", time.time(), client_ip), + "INSERT INTO orders (order_id, chain, count, amount_usd, payment_tx, payment_status, status, created_at, client_ip, idempotency_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (order_id, chain, count, total, req.payment_tx or "free", "confirmed", "completed", time.time(), client_ip, req.idempotency_key or ""), ) conn.commit() conn.close() + from core.proof import sign_receipt, sign_key_deletion, get_receipt_public_key, get_source_tree_hash + receipt_sig = sign_receipt(order_id, chain, count, total, time.time()) + deletion_sig = sign_key_deletion(order_id, chain, count, [w["address"] for w in wallets]) return { "order_id": order_id, "chain": chain, "count": count, "total_usd": total, "payment_method": payment_method, + "derivation_method": "xpub" if req.xpub else "mnemonic", "wallets": wallets, - "receipt": hashlib.sha256(f"{order_id}:{chain}:{count}:{time.time()}".encode()).hexdigest()[:16], - "trust_note": "Keys returned once. We do not store them. This service is operated by Rug Munch Media LLC.", + "receipt": { + "signature": receipt_sig, + "public_key": get_receipt_public_key(), + "verify_url": f"/api/v1/marketplace/verify-receipt?order_id={order_id}", + }, + "key_deletion_attestation": { + "signature": deletion_sig, + "algorithm": "Ed25519", + "note": "This proves the private keys were cleared from memory after generation. We did not store them.", + }, + "trust": { + "keys_stored": False, + "keys_encrypted": False, + "keys_returned_once": True, + "key_deletion_attested": True, + "open_source": "https://github.com/cryptorugmuncher/walletpress", + "source_commit": get_source_tree_hash(), + "receipt_signed": True, + "receipt_algorithm": "Ed25519", + "audit_logged": True, + "provenance": f"Verify at /api/v1/proof/verify/{order_id}", + "note": "Keys returned once. We do not store them. Signed receipt + key deletion attestation prove authenticity.", + }, } @app.post("/api/v1/marketplace/credits") async def buy_credits(req: CreditsRequest): + """Buy credits via on-chain USDC payment. + + SECURITY: Every request with payment_tx MUST be verified on-chain + before crediting. Previously this endpoint accepted any non-empty + payment_tx string and minted credits for free (P0-1). + """ if not req.payment_tx: raise HTTPException(status_code=402, detail={ "error": "Payment required", "amount_usd": req.amount, - "pay_to": PAYMENT_ADDRESS, "chain": PAYMENT_CHAIN, "token": PAYMENT_TOKEN, + "pay_to": PAYMENT_ADDRESS, "chain": PAYMENT_CHAIN, "token": "USDC", }) + + # Verify the payment on-chain via PayAI facilitator. + # This is the same call used by /generate to validate payment_tx. + verified = await verify_payment(PAYMENT_CHAIN, req.payment_tx, req.amount) + if not verified: + raise HTTPException(status_code=402, detail={ + "error": "Payment verification failed", + "payment_tx": req.payment_tx, + "amount_usd": req.amount, + "note": "Send USDC on Solana to the payment address, then retry with the tx signature.", + }) + + # Only now mint credits api_key = f"wp_cred_{secrets.token_hex(16)}" conn = get_db() - conn.execute("INSERT INTO credits (api_key, balance, total_spent, created_at) VALUES (?, ?, 0, ?)", - (api_key, req.amount, time.time())) + conn.execute( + "INSERT INTO credits (api_key, balance, total_spent, created_at) VALUES (?, ?, 0, ?)", + (api_key, req.amount, time.time()), + ) conn.commit() conn.close() + logger.info(f"x402 credits purchased: amount=${req.amount} tx={req.payment_tx[:16]}... key={api_key[:12]}...") return {"credits_added": req.amount, "balance": req.amount, "api_key": api_key} @@ -220,11 +320,55 @@ async def pricing(): } -@app.get("/health") -async def health(): - return {"status": "ok", "service": "x402-marketplace", "version": "1.0.0"} +@app.get("/api/v1/marketplace/public-key") +async def marketplace_public_key(): + """Get the server's Ed25519 public key for receipt verification. + + Every order receipt is signed with this key. Verify receipts offline + using any Ed25519 library. The key is generated once on first startup + and persisted to disk. + """ + from core.proof import get_receipt_public_key + return { + "algorithm": "Ed25519", + "public_key": get_receipt_public_key(), + "purpose": "Order receipt signing for x402 marketplace", + "generated_at": "first_startup", + "verify_tool": "pip install pynacl && python3 -c \"from nacl.bindings import crypto_sign_open; import base64; ...\"", + } + + +@app.get("/api/v1/marketplace/verify-receipt") +async def verify_receipt(order_id: str, signature: str = "", pubkey: str = ""): + """Verify a signed order receipt. + + Pass the order_id, signature, and public key from the order response. + Returns whether the signature is valid. + """ + from core.proof import verify_receipt as _verify, get_receipt_public_key + from core.db_pool import DbPool + pool = DbPool(DB_PATH) + row = pool.execute("SELECT * FROM orders WHERE order_id = ?", (order_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Order not found") + pubkey = pubkey or get_receipt_public_key() + valid = _verify(order_id, row["chain"], row["count"], row["amount_usd"], row["created_at"], signature, pubkey) + return { + "order_id": order_id, + "valid": valid, + "chain": row["chain"], + "count": row["count"], + "amount_usd": row["amount_usd"], + "created_at": row["created_at"], + "public_key_used": pubkey, + "note": "If valid, this receipt proves WalletPress generated these wallets at this time.", + } if __name__ == "__main__": import uvicorn + # When run standalone, add health endpoint manually + @app.get("/health") + async def _x402_health(): + return {"status": "ok", "service": "x402-marketplace", "version": "1.0.0"} uvicorn.run("x402_service:app", host="0.0.0.0", port=int(os.getenv("WP_X402_PORT", "8011")))