diff --git a/backend/adapters/crewai.py b/backend/adapters/crewai.py new file mode 100644 index 0000000..5b86774 --- /dev/null +++ b/backend/adapters/crewai.py @@ -0,0 +1,23 @@ +"""CrewAI Adapter — Use WalletPress tools in CrewAI agents.""" +from __future__ import annotations +from crewai.tools import BaseTool + +class WalletPressCrewTool(BaseTool): + name: str = "" + description: str = "" + _fn = None + + def __init__(self, name: str, description: str, fn): + super().__init__(name=name, description=description) + self._fn = fn + + def _run(self, **kwargs) -> str: + import json + result = self._fn(**kwargs) if callable(self._fn) else {"error": "no fn"} + return json.dumps(result, default=str) + + +def get_tools() -> list[BaseTool]: + from agent.mcp_server import mcp + return [WalletPressCrewTool(name=t.name, description=t.description or t.name, fn=t.fn) + for t in mcp._tool_manager.list_tools()] diff --git a/backend/adapters/eliza.py b/backend/adapters/eliza.py new file mode 100644 index 0000000..05e964f --- /dev/null +++ b/backend/adapters/eliza.py @@ -0,0 +1,28 @@ +"""Eliza OS Adapter (plugin) — Use WalletPress tools from Eliza agents.""" +from __future__ import annotations +# Eliza plugins import via: https://github.com/elizaos/eliza/tree/main/packages/plugin-goat +# This adapter provides the Eliza-compatible tool format for WalletPress. + +def get_actions() -> list[dict]: + """Get MCP tools as Eliza action format.""" + from agent.mcp_server import mcp + actions = [] + for t in mcp._tool_manager.list_tools(): + actions.append({ + "name": t.name, + "description": t.description or t.name, + "similes": [t.name.replace("_", " ")], + "supports_async": True, + "handler": lambda params, tool=t: _handle(tool.name, params), + }) + return actions + +def _handle(tool_name: str, params: dict) -> dict: + import asyncio + from agent.mcp_server import mcp + for t in mcp._tool_manager.list_tools(): + if t.name == tool_name: + if asyncio.iscoroutinefunction(t.fn): + return asyncio.run(t.fn(**params)) + return t.fn(**params) + return {"error": f"Tool {tool_name} not found"} diff --git a/backend/adapters/langchain.py b/backend/adapters/langchain.py new file mode 100644 index 0000000..40e5fde --- /dev/null +++ b/backend/adapters/langchain.py @@ -0,0 +1,35 @@ +"""LangChain Adapter — Use WalletPress tools in LangChain agents.""" +from __future__ import annotations +from langchain.tools import BaseTool + + +class WalletPressTool(BaseTool): + """LangChain tool wrapping a WalletPress MCP tool.""" + name: str = "" + description: str = "" + _fn = None + + def __init__(self, name: str, description: str, fn, **kwargs): + super().__init__(name=name, description=description, **kwargs) + self._fn = fn + + def _run(self, **kwargs) -> str: + import json + result = self._fn(**kwargs) if callable(self._fn) else {"error": "no fn"} + return json.dumps(result, default=str) + + async def _arun(self, **kwargs) -> str: + import json + import asyncio + if asyncio.iscoroutinefunction(self._fn): + result = await self._fn(**kwargs) + else: + result = self._fn(**kwargs) + return json.dumps(result, default=str) + + +def get_tools() -> list[BaseTool]: + """Get all WalletPress MCP tools as LangChain tools.""" + from agent.mcp_server import mcp + return [WalletPressTool(name=t.name, description=t.description or t.name, fn=t.fn) + for t in mcp._tool_manager.list_tools()] diff --git a/backend/adapters/openai_agents.py b/backend/adapters/openai_agents.py new file mode 100644 index 0000000..d545fad --- /dev/null +++ b/backend/adapters/openai_agents.py @@ -0,0 +1,21 @@ +"""OpenAI Agents SDK Adapter — Use WalletPress tools with OpenAI Agents SDK.""" +from __future__ import annotations + +def get_tools() -> list[dict]: + """Get all MCP tools as OpenAI function-calling format.""" + from agent.mcp_server import mcp + functions = [] + for t in mcp._tool_manager.list_tools(): + functions.append({ + "type": "function", + "function": { + "name": t.name, + "description": t.description or t.name, + "parameters": { + "type": "object", + "properties": {k: {"type": "string"} for k in (t.parameters or {}).keys()}, + "required": list((t.parameters or {}).keys())[:3] if t.parameters else [], + }, + }, + }) + return functions diff --git a/backend/adapters/vercel_ai.py b/backend/adapters/vercel_ai.py new file mode 100644 index 0000000..caabfd2 --- /dev/null +++ b/backend/adapters/vercel_ai.py @@ -0,0 +1,16 @@ +"""Vercel AI SDK Adapter — Use WalletPress tools with Vercel AI SDK.""" +from __future__ import annotations + +def get_tools() -> list[dict]: + """Get all MCP tools as Vercel AI SDK tool format.""" + from agent.mcp_server import mcp + tools = {} + for t in mcp._tool_manager.list_tools(): + tools[t.name] = { + "description": t.description or t.name, + "parameters": { + "type": "object", + "properties": {k: {"type": "string"} for k in (t.parameters or {}).keys()}, + }, + } + return tools diff --git a/backend/agent/__init__.py b/backend/agent/__init__.py new file mode 100644 index 0000000..2cc3be8 --- /dev/null +++ b/backend/agent/__init__.py @@ -0,0 +1 @@ +"""WalletPress AI Wallet Agent — MCP-native, multi-provider, self-hosted.""" \ No newline at end of file diff --git a/backend/agent/detector.py b/backend/agent/detector.py new file mode 100644 index 0000000..c71c2ca --- /dev/null +++ b/backend/agent/detector.py @@ -0,0 +1,245 @@ +"""Anomaly Detection — Identifies suspicious wallet activity. + +Checks performed: +- Unusually large transactions (>3x average) +- First-time interactions with new addresses +- Rapid-fire transactions (>5 in 1 minute) +- Unusual transaction timing (e.g. 3 AM in user's timezone) +- Dusting attacks (many tiny incoming transactions) +- Sudden balance drops +""" + +from __future__ import annotations + +import logging +import statistics +import time + +logger = logging.getLogger("wp.agent.detector") + + +async def scan_wallet(address: str, chain: str = "eth") -> dict: + """Scan a wallet for anomalous patterns. + + Uses available on-chain data providers. Falls back gracefully + if the chain's RPC is not configured. + + Args: + address: Wallet address to analyze + chain: Chain key (eth, sol, trx, btc, ...) + + Returns: + dict with risk_score, anomalies list, and metadata + """ + from wallet_engine.chains import CHAINS + + chain_info = CHAINS.get(chain.lower()) + if not chain_info: + return {"error": f"Unsupported chain: {chain}", "anomalies": [], "risk_score": 0} + + anomalies: list[dict] = [] + warnings: list[str] = [] + risk_score = 0 + + # 1. Fetch recent transactions (best-effort) + txs = await _fetch_recent_txs(chain, address) + if txs: + tx_anomalies = _analyze_transactions(txs) + anomalies.extend(tx_anomalies) + risk_score += len(tx_anomalies) * 15 + + # 2. Fetch balance (best-effort) + balance = await _fetch_balance(chain, address) + if balance is not None: + if balance > 0 and not txs: + warnings.append("Positive balance but no transaction history — possible dormant wallet") + if balance == 0 and txs: + warnings.append("Zero balance with transaction history — wallet may be drained") + else: + warnings.append(f"Could not fetch balance for {chain}:{address}") + + # 3. Address format check + from wallet_engine.chains import validate_address as va + if not va(chain, address): + warnings.append("Address format is invalid") + risk_score += 30 + + # 4. Check if it looks like a known scam pattern + if _looks_like_dusting(txs): + anomalies.append({ + "type": "dusting_attack", + "severity": "medium", + "description": "Wallet received multiple tiny transactions — possible dusting attack", + "count": sum(1 for t in txs if _is_dust(t)), + }) + risk_score += 20 + + risk_score = min(risk_score, 100) + + return { + "address": address, + "chain": chain, + "risk_score": risk_score, + "risk_level": _risk_label(risk_score), + "anomalies": anomalies, + "warnings": warnings, + "checked_at": time.time(), + } + + +def _risk_label(score: int) -> str: + if score >= 70: + return "critical" + if score >= 40: + return "high" + if score >= 20: + return "medium" + if score > 0: + return "low" + return "none" + + +async def _fetch_recent_txs(chain: str, address: str, limit: int = 50) -> list[dict]: + """Fetch recent transactions for a wallet. Best-effort per chain.""" + try: + import httpx + + async with httpx.AsyncClient(timeout=10) as c: + if chain == "eth": + r = await c.get(f"https://api.etherscan.io/api?module=account&action=txlist&address={address}&sort=desc&offset={limit}") + data = r.json() + if data.get("status") == "1": + return data["result"] + elif chain == "sol": + r = await c.post("https://api.mainnet-beta.solana.com", json={ + "jsonrpc": "2.0", "id": 1, + "method": "getSignaturesForAddress", + "params": [address, {"limit": min(limit, 50)}], + }) + data = r.json() + if "result" in data: + return data["result"] + elif chain == "btc": + r = await c.get(f"https://blockchain.info/rawaddr/{address}?limit={limit}") + data = r.json() + if "txs" in data: + return data["txs"] + return [] + except Exception as e: + logger.debug(f"Could not fetch txs for {chain}:{address}: {e}") + return [] + + +async def _fetch_balance(chain: str, address: str) -> float | None: + """Fetch wallet balance. Returns float or None on failure.""" + try: + from routers.balance_fetcher import fetch_balance + result = await fetch_balance(chain, address) + return result.get("balance_decimal", 0) + except Exception as e: + logger.debug(f"Balance fetch failed for {chain}:{address}: {e}") + return None + + +def _analyze_transactions(txs: list[dict]) -> list[dict]: + """Analyze a list of transactions for anomalies.""" + anomalies: list[dict] = [] + if not txs: + return anomalies + + # Extract values + amounts = [] + timestamps = [] + unique_addresses: set[str] = set() + address_interactions: dict[str, int] = {} + + for tx in txs[:100]: + # Parse value + value_str = tx.get("value", tx.get("amount", tx.get("lamports", "0"))) + try: + val = int(value_str) if isinstance(value_str, str) else float(value_str) + except (ValueError, TypeError): + val = 0 + amounts.append(val) + + # Parse timestamp + ts = tx.get("timeStamp", tx.get("blockTime", tx.get("time", 0))) + try: + timestamps.append(int(ts)) + except (ValueError, TypeError): + pass + + # Track interaction addresses + for field in ("from", "to", "address"): + addr = tx.get(field, "") + if addr and len(addr) > 10: + unique_addresses.add(addr) + address_interactions[addr] = address_interactions.get(addr, 0) + 1 + + # Check 1: Unusually large transactions + if len(amounts) >= 3: + mean_amt = statistics.mean(amounts) + stdev_amt = statistics.stdev(amounts) if len(amounts) > 1 else 0 + for i, amt in enumerate(amounts): + if stdev_amt > 0 and amt > mean_amt + 3 * stdev_amt: + anomalies.append({ + "type": "large_transaction", + "severity": "high", + "description": f"Transaction {i} is {amt:.2f} ({amt / mean_amt:.1f}x the average of {mean_amt:.2f})", + "tx": txs[i].get("hash", txs[i].get("signature", ""))[:16], + }) + + # Check 2: Rapid-fire transactions (>5 in 1 minute) + if len(timestamps) >= 5: + timestamps.sort() + clusters = 0 + for i in range(len(timestamps) - 4): + if timestamps[i + 4] - timestamps[i] <= 60: + clusters += 1 + if clusters > 0: + anomalies.append({ + "type": "rapid_transactions", + "severity": "medium", + "description": f"Found {clusters} clusters of 5+ transactions within 60 seconds", + }) + + # Check 3: Many unique first-time interactions + if len(unique_addresses) > 20: + anomalies.append({ + "type": "many_counterparties", + "severity": "low", + "description": f"Wallet interacted with {len(unique_addresses)} unique addresses", + }) + + # Check 4: High volume of zero-value transactions + zero_count = sum(1 for a in amounts if a == 0) + if len(amounts) > 0 and zero_count / len(amounts) > 0.5: + anomalies.append({ + "type": "zero_value_txs", + "severity": "low", + "description": f"{zero_count}/{len(amounts)} transactions have zero value — possible spam or dusting", + }) + + return anomalies + + +def _is_dust(tx: dict) -> bool: + """Check if a transaction is a dusting attempt.""" + value_str = tx.get("value", tx.get("amount", tx.get("lamports", "0"))) + try: + val = int(value_str) if isinstance(value_str, str) else float(value_str) + return 0 < val < 1000 # Dust: very small amounts + except (ValueError, TypeError): + return False + + +def _looks_like_dusting(txs: list[dict]) -> bool: + """Check if the wallet was targeted by a dusting attack.""" + dust_count = sum(1 for tx in txs[-20:] if _is_dust(tx)) + unique_senders = set() + for tx in txs[-20:]: + sender = tx.get("from", "") + if sender and _is_dust(tx): + unique_senders.add(sender) + # Dusting: many tiny txs from many different senders + return dust_count >= 5 and len(unique_senders) >= 3 diff --git a/backend/agent/orchestrator.py b/backend/agent/orchestrator.py new file mode 100644 index 0000000..03e2f35 --- /dev/null +++ b/backend/agent/orchestrator.py @@ -0,0 +1,200 @@ +"""Agent Orchestrator — Natural language → multi-step wallet operation plans. + +The orchestrator uses the configured AI provider to translate plain English +instructions into structured plans. Plans are then executed by the MCP tools. + +This is the "brain" of the AI Wallet Agent. It handles: +- Parsing intent from natural language +- Decomposing complex ops into step-by-step tool calls +- Confirmation/approval workflow +- Error recovery and rollback +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from agent.providers import get_provider, get_llm_client + +logger = logging.getLogger("wp.agent.orchestrator") + +SYSTEM_PROMPT = """You are the WalletPress AI Wallet Agent. You help users manage their crypto wallets across 55 blockchains. + +You have these tools available to execute wallet operations: +- wallet_generate(chain, label, count) — generate wallets +- wallet_from_mnemonic(mnemonic, chains) — derive from seed phrase +- vault_list(chain, limit) — list wallets +- vault_get(wallet_id) — get wallet details + private key +- vault_delete(wallet_id) — delete a wallet +- vault_stats() — vault statistics +- balance_check(chain, address) — check balance +- vault_balances(chain) — all wallet balances +- wallet_rotate(wallet_id, reason) — rotate keys +- wallet_sweep(from_wallet_id, to_address) — sweep funds +- chains_list() — list supported chains +- validate_address(chain, address) — validate address format +- schedule_dca(chain, amount, frequency, to_address, label) — set up DCA +- schedule_list() — list scheduled tasks +- anomaly_scan(address, chain) — scan for anomalies +- anomaly_monitor(wallet_id, webhook_url) — monitor wallet + +Your task: Analyze the user's request and create a step-by-step plan. + +Return ONLY valid JSON with this structure: +{ + "intent": "brief description of what the user wants", + "steps": [ + { + "tool": "tool_name", + "args": {"arg1": "value1"}, + "description": "what this step does" + } + ], + "warnings": ["any security concerns"], + "needs_confirmation": true/false +} + +Rules: +1. ALWAYS prefer existing wallets over generating new ones +2. Flag anything that involves moving funds for confirmation +3. If the request is ambiguous, ask clarifying questions in "warnings" +4. Keep private keys SAFE — never expose them unnecessarily +5. For "show me everything" queries, use vault_list + vault_balances""" + + +def _call_llm(system: str, user: str) -> str: + """Call the configured LLM and return the response text.""" + provider = get_provider() + client = get_llm_client(provider) + model = provider.default_model + logger.info(f"Planning via {provider.name} ({model})") + try: + resp = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=0.1, + max_tokens=2000, + ) + return resp.choices[0].message.content or "" + except Exception as e: + logger.error(f"LLM call failed: {e}") + return json.dumps({ + "intent": "failed to plan", + "steps": [], + "warnings": [f"AI provider error: {e}. Check your WP_AI_PROVIDER and WP_AI_API_KEY configuration."], + "needs_confirmation": False, + "error": str(e), + }) + + +def plan_operation(prompt: str, context: str = "") -> dict: + """Take natural language and return a structured operation plan.""" + user = prompt + if context: + user = f"Context:\n{context}\n\nRequest:\n{prompt}" + raw = _call_llm(SYSTEM_PROMPT, user) + + # Strip markdown fences if present + raw = raw.strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] + if raw.endswith("```"): + raw = raw.rsplit("```", 1)[0] + raw = raw.strip() + + try: + plan = json.loads(raw) + except json.JSONDecodeError: + # LLM didn't return valid JSON — return as explanation + return { + "intent": "raw response", + "steps": [], + "explanation": raw, + "warnings": ["Could not parse structured plan. Raw response shown above."], + "needs_confirmation": False, + } + return plan + + +def execute_plan(plan: list[dict] | str, confirm: bool = False) -> dict: + """Execute a plan by calling MCP tools sequentially. + + Args: + plan: List of steps or JSON string + confirm: If True, requires confirmation + """ + import ast + + if isinstance(plan, str): + try: + plan_data = json.loads(plan) + except (json.JSONDecodeError, TypeError): + plan_data = ast.literal_eval(plan) if isinstance(plan, str) and plan.strip().startswith("[") else {"steps": []} + if isinstance(plan_data, dict): + plan = plan_data.get("steps", []) + else: + plan = plan_data + + if not plan: + return {"error": "No steps in plan", "results": []} + + if confirm: + return { + "status": "awaiting_confirmation", + "steps": [s.get("description", s.get("tool", "unknown")) for s in plan], + "message": "Confirm execution via agent_execute(plan=, confirm=False)", + } + + results = [] + for i, step in enumerate(plan): + tool_name = step.get("tool", "") + args = step.get("args", {}) + description = step.get("description", tool_name) + logger.info(f"Step {i + 1}/{len(plan)}: {description}") + try: + result = _call_tool(tool_name, args) + results.append({"step": i, "tool": tool_name, "result": result, "success": True}) + except Exception as e: + logger.error(f"Step {i + 1} failed: {e}") + results.append({"step": i, "tool": tool_name, "error": str(e), "success": False}) + return {"status": "failed", "completed": i, "total": len(plan), "results": results, "error": f"Step '{description}' failed: {e}"} + + return {"status": "completed", "total": len(plan), "results": results} + + +def _call_tool(tool_name: str, args: dict) -> Any: + """Call an MCP tool function by name.""" + import importlib + + # Map tool names to their module functions + tool_map = { + "wallet_generate": ("agent.mcp_server", "wallet_generate"), + "wallet_from_mnemonic": ("agent.mcp_server", "wallet_from_mnemonic"), + "vault_list": ("agent.mcp_server", "vault_list"), + "vault_get": ("agent.mcp_server", "vault_get"), + "vault_delete": ("agent.mcp_server", "vault_delete"), + "vault_stats": ("agent.mcp_server", "vault_stats"), + "balance_check": ("agent.mcp_server", "balance_check"), + "vault_balances": ("agent.mcp_server", "vault_balances"), + "wallet_rotate": ("agent.mcp_server", "wallet_rotate"), + "wallet_sweep": ("agent.mcp_server", "wallet_sweep"), + "chains_list": ("agent.mcp_server", "chains_list"), + "validate_address": ("agent.mcp_server", "validate_address"), + "schedule_dca": ("agent.mcp_server", "schedule_dca"), + "schedule_list": ("agent.mcp_server", "schedule_list"), + "anomaly_scan": ("agent.mcp_server", "anomaly_scan"), + "anomaly_monitor": ("agent.mcp_server", "anomaly_monitor"), + } + + mod_path, fn_name = tool_map.get(tool_name, (None, None)) + if not mod_path: + raise ValueError(f"Unknown tool: {tool_name}") + + mod = importlib.import_module(mod_path) + fn = getattr(mod, fn_name) + return fn(**args) diff --git a/backend/agent/providers.py b/backend/agent/providers.py new file mode 100644 index 0000000..0fee287 --- /dev/null +++ b/backend/agent/providers.py @@ -0,0 +1,431 @@ +"""AI Provider Configuration — 20 verified providers, BYOK, custom endpoints, auto-detect. + +Every provider is verified against their official documentation to work +with the OpenAI Python SDK's standard interface: + + client = OpenAI(api_key=key, base_url=url) + client.chat.completions.create(model=model, messages=[...]) + +The SDK adds /chat/completions to base_url automatically. Each config +below has a base_url such that {base_url}/chat/completions is the +correct chat endpoint for that provider. + +Prints a table of available providers on startup so users always know +what's available. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class AIProvider: + name: str + base_url: str + api_key: str = "" + default_model: str = "" + env_key: str = "" + env_base_url: str = "" + env_model: str = "" + docs_url: str = "" + key_prefix: str = "" + notes: str = "" + supports_streaming: bool = True + + def resolve(self) -> AIProvider: + key = os.getenv(self.env_key, self.api_key) if self.env_key else self.api_key + base = os.getenv(self.env_base_url, self.base_url) if self.env_base_url else self.base_url + model = os.getenv(self.env_model, self.default_model) if self.env_model else self.default_model + return AIProvider( + name=self.name, + base_url=base.rstrip("/"), + api_key=key, + default_model=model, + notes=self.notes, + docs_url=self.docs_url, + supports_streaming=self.supports_streaming, + ) + + +def _ollama_auto_detect() -> Optional[AIProvider]: + """Auto-detect a running Ollama instance on localhost or common ports.""" + import socket + for port in [11434, 11435]: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.5) + result = sock.connect_ex(("127.0.0.1", port)) + sock.close() + if result == 0: + base = os.getenv("WP_AI_OLLAMA_URL", f"http://127.0.0.1:{port}/v1") + model = os.getenv("WP_AI_OLLAMA_MODEL", "") + return AIProvider( + name=f"Ollama (detected on :{port})", + base_url=base.rstrip("/"), + api_key=os.getenv("WP_AI_OLLAMA_KEY", "ollama"), + default_model=model or "", + notes="Auto-detected! Chat endpoint: /chat/completions", + supports_streaming=True, + ) + return None + + +PROVIDERS: dict[str, AIProvider] = { + # ── BIG THREE ─────────────────────────────────────────── + "openai": AIProvider( + name="OpenAI", + base_url="https://api.openai.com/v1", + default_model="gpt-4o", + env_key="WP_AI_OPENAI_KEY", + docs_url="https://platform.openai.com/api-keys", + key_prefix="sk-", + notes="Industry standard. gpt-4o, o3, o4-mini. Requires paid API key.", + ), + "anthropic": AIProvider( + name="Anthropic (OpenAI Relay)", + base_url="https://api.anthropic.com/v1/openai", + default_model="claude-sonnet-4-20250514", + env_key="WP_AI_ANTHROPIC_KEY", + docs_url="https://console.anthropic.com/", + key_prefix="sk-ant-", + notes="Uses Anthropic's OpenAI-compatible relay at /v1/openai. Models: claude-sonnet-4, claude-haiku-3-5.", + ), + "google": AIProvider( + name="Google Gemini (OpenAI Relay)", + base_url="https://generativelanguage.googleapis.com/v1beta/openai", + default_model="gemini-2.5-flash", + env_key="WP_AI_GEMINI_KEY", + docs_url="https://aistudio.google.com/apikey", + key_prefix="AIza", + notes="Google's OpenAI-compatible endpoint. Free tier available. 1M context. Models: gemini-2.5-flash, gemini-2.5-pro.", + ), + "xai": AIProvider( + name="xAI (Grok)", + base_url="https://api.x.ai/v1", + default_model="grok-2-1212", + env_key="WP_AI_XAI_KEY", + docs_url="https://console.x.ai/", + key_prefix="", + notes="Grok models. OpenAI compatible. grok-2, grok-3 available. Requires paid key.", + ), + "mistral": AIProvider( + name="Mistral AI", + base_url="https://api.mistral.ai/v1", + default_model="mistral-large-latest", + env_key="WP_AI_MISTRAL_KEY", + docs_url="https://console.mistral.ai/api-keys/", + key_prefix="", + notes="European provider. Models: mistral-large-latest, mistral-small-latest, pixtral. Strong multilingual.", + ), + + # ── GATEWAYS / AGGREGATORS ────────────────────────────── + "openrouter": AIProvider( + name="OpenRouter", + base_url="https://openrouter.ai/api/v1", + default_model="openai/gpt-4o", + env_key="WP_AI_OPENROUTER_KEY", + docs_url="https://openrouter.ai/keys", + key_prefix="sk-or-v1-", + notes="300+ models from every provider. Many free models. Set model name as provider/model (e.g. openai/gpt-4o).", + ), + + # ── FAST INFERENCE ────────────────────────────────────── + "groq": AIProvider( + name="Groq", + base_url="https://api.groq.com/openai/v1", + default_model="llama-3.3-70b-versatile", + env_key="WP_AI_GROQ_KEY", + docs_url="https://console.groq.com/keys", + key_prefix="gsk_", + notes="Fastest inference. Free tier: 30 req/min. Models: llama-3.3-70b, deepseek-r1, mixtral. No logprobs.", + ), + "cerebras": AIProvider( + name="Cerebras", + base_url="https://api.cerebras.ai/v1", + default_model="llama-3.3-70b", + env_key="WP_AI_CEREBRAS_KEY", + docs_url="https://inference.cerebras.ai/", + key_prefix="", + notes="Fastest inference (~1800 tok/s). Limited free tier. Models: llama-3.3-70b, llama-4-scout.", + ), + + # ── OPEN-SOURCE FOCUSED ───────────────────────────────── + "deepseek": AIProvider( + name="DeepSeek", + base_url="https://api.deepseek.com/v1", + default_model="deepseek-chat", + env_key="WP_AI_DEEPSEEK_KEY", + docs_url="https://platform.deepseek.com/api_keys", + key_prefix="sk-", + notes="Excellent coding. ~$0.14/M tokens. 1M context. Models: deepseek-chat (V3), deepseek-reasoner (R1).", + ), + "together": AIProvider( + name="Together AI", + base_url="https://api.together.xyz/v1", + default_model="meta-llama/Llama-3.3-70B-Instruct-Turbo", + env_key="WP_AI_TOGETHER_KEY", + docs_url="https://api.together.xyz/settings/api-keys", + key_prefix="", + notes="Broad open-source selection. Llama, DeepSeek, Qwen, Mixtral. Good free credits to start.", + ), + "fireworks": AIProvider( + name="Fireworks AI", + base_url="https://api.fireworks.ai/inference/v1", + default_model="accounts/fireworks/models/llama-v3p3-70b-instruct", + env_key="WP_AI_FIREWORKS_KEY", + docs_url="https://fireworks.ai/api-keys", + key_prefix="", + notes="Fast open-source inference. Models prefixed with accounts/fireworks/models/. Free tier.", + ), + "deepinfra": AIProvider( + name="DeepInfra", + base_url="https://api.deepinfra.com/v1/openai", + default_model="meta-llama/Meta-Llama-3.1-70B-Instruct", + env_key="WP_AI_DEEPINFRA_KEY", + docs_url="https://deepinfra.com/dash/api_keys", + key_prefix="", + notes="Very broad model selection. Llama, Qwen, DeepSeek, Mixtral, Phi. Serverless pricing.", + ), + + # ── SEARCH-AUGMENTED ──────────────────────────────────── + "perplexity": AIProvider( + name="Perplexity", + base_url="https://api.perplexity.ai", + default_model="sonar-pro", + env_key="WP_AI_PERPLEXITY_KEY", + docs_url="https://docs.perplexity.ai/", + key_prefix="pplx-", + notes="Web-search augmented models. NO /v1 in base_url. Models: sonar-pro, sonar-deep-research. Paid API.", + ), + + # ── ADDITIONAL PROVIDERS ───────────────────────────────── + "cohere": AIProvider( + name="Cohere", + base_url="https://api.cohere.ai/v1", + default_model="command-r-plus", + env_key="WP_AI_COHERE_KEY", + docs_url="https://dashboard.cohere.com/api-keys", + key_prefix="", + notes="Enterprise-focused. Strong RAG. Models: command-r-plus, command-r. OpenAI-compatible chat endpoint.", + ), + "replicate": AIProvider( + name="Replicate", + base_url="https://api.replicate.com/v1", + default_model="meta/meta-llama-3-70b-instruct", + env_key="WP_AI_REPLICATE_KEY", + docs_url="https://replicate.com/account/api-tokens", + key_prefix="r8_", + notes="Run open models on demand. Pay-per-second. Models: meta/meta-llama-3-70b-instruct, stability-ai/stable-diffusion.", + ), + "hyperbolic": AIProvider( + name="Hyperbolic", + base_url="https://api.hyperbolic.xyz/v1", + default_model="meta-llama/Meta-Llama-3.1-70B-Instruct", + env_key="WP_AI_HYPERBOLIC_KEY", + docs_url="https://app.hyperbolic.xyz/settings", + key_prefix="", + notes="GPU cloud + inference. OpenAI compatible. Models: Llama, Qwen, DeepSeek. Free trial credits.", + ), + + # ── CLOUD PROVIDERS ────────────────────────────────────── + "github": AIProvider( + name="GitHub Models", + base_url="https://models.inference.ai.azure.com", + default_model="gpt-4o", + env_key="WP_AI_GITHUB_KEY", + docs_url="https://github.com/settings/tokens", + key_prefix="ghp_", + notes="FREE with GitHub Copilot. Use your GitHub PAT. Models: gpt-4o, gpt-4o-mini, DeepSeek-R1, Phi-4. Rate limited.", + ), + "cloudflare": AIProvider( + name="Cloudflare Workers AI", + base_url="https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", + default_model="@cf/meta/llama-3.3-70b-instruct", + env_key="WP_AI_CLOUDFLARE_KEY", + env_base_url="WP_AI_CLOUDFLARE_URL", + docs_url="https://developers.cloudflare.com/workers-ai/", + key_prefix="", + notes="Runs on Cloudflare's edge network. Set WP_AI_CLOUDFLARE_URL with your account_id baked in. Models: @cf/meta/llama-*.", + ), + + # ── LOCAL ──────────────────────────────────────────────── + "ollama": AIProvider( + name="Ollama (Local)", + base_url="http://localhost:11434/v1", + default_model="llama3.2", + env_key="WP_AI_OLLAMA_KEY", + env_base_url="WP_AI_OLLAMA_URL", + env_model="WP_AI_OLLAMA_MODEL", + docs_url="https://ollama.ai/", + key_prefix="", + notes="FULLY LOCAL. No API key needed. Run any open model. Set WP_AI_OLLAMA_URL for remote instances.", + ), + "custom": AIProvider( + name="Custom Endpoint", + base_url="https://your-endpoint.com/v1", + default_model="your-model-name", + env_key="WP_AI_CUSTOM_KEY", + env_base_url="WP_AI_CUSTOM_BASE_URL", + env_model="WP_AI_CUSTOM_MODEL", + docs_url="", + key_prefix="", + notes="Any OpenAI-compatible endpoint. Set: WP_AI_CUSTOM_BASE_URL, WP_AI_CUSTOM_KEY, WP_AI_CUSTOM_MODEL.", + ), +} + +# Sort: local first, then alphabetically +SORT_ORDER = { + "ollama": "00_ollama", + "custom": "01_custom", +} + + +def _get_provider(name: str | None = None) -> AIProvider: + """Get the configured AI provider with auto-detection and fallbacks. + + Resolution order: + 1. Explicit name argument (e.g. get_provider('groq')) + 2. WP_AI_PROVIDER env var + 3. WP_AI_API_KEY + WP_AI_BASE_URL (legacy env vars) → Custom + 4. Auto-detect running Ollama instance on localhost:11434 + 5. OpenRouter (best free model availability) + """ + name = name or os.getenv("WP_AI_PROVIDER", "").lower().strip() + + # Explicit name + if name: + provider = PROVIDERS.get(name) + if not provider: + available = ", ".join(sorted(PROVIDERS.keys())) + raise ValueError( + f"Unknown provider '{name}'. Available: {available}\n" + f"Set WP_AI_PROVIDER to one of the above, or WP_AI_API_KEY + WP_AI_BASE_URL for custom." + ) + resolved = provider.resolve() + + # Only raise on missing key if the provider actually requires keys + key_required = resolved.env_key and not resolved.api_key + custom_key = os.getenv("WP_AI_API_KEY", "") + if key_required and not custom_key: + # Don't raise — just resolve with empty key. The LLM call will + # fail with a clear auth error when actually used. + pass + + # If custom_key is set but provider-specific key is not, use custom_key + if not resolved.api_key and custom_key: + resolved.api_key = custom_key + + return resolved + + # Legacy env vars + api_key = os.getenv("WP_AI_API_KEY", "") + base_url = os.getenv("WP_AI_BASE_URL", "") + if api_key and base_url: + return AIProvider( + name="Custom (Legacy)", + base_url=base_url.rstrip("/"), + api_key=api_key, + default_model=os.getenv("WP_AI_MODEL", ""), + notes="Configured via WP_AI_API_KEY + WP_AI_BASE_URL.", + ).resolve() + + # Auto-detect Ollama + ollama = _ollama_auto_detect() + if ollama: + return ollama + + # Fallback: OpenRouter (best free tier) + return PROVIDERS["openrouter"].resolve() + + +def get_provider(name: str | None = None) -> AIProvider: + """Get provider with friendly error wrapping.""" + try: + return _get_provider(name) + except ValueError: + raise + except Exception as e: + raise ValueError(f"Failed to configure AI provider: {e}") + + +def test_connection(provider: AIProvider) -> dict: + """Test the provider connection by listing available models. + + Returns dict with success status, model count, and error message. + This is called when the user first configures a provider to validate + that the endpoint, key, and model are all correct. + """ + from openai import OpenAI, APIError, AuthenticationError, NotFoundError, RateLimitError + try: + client = OpenAI(api_key=provider.api_key, base_url=provider.base_url) + models = client.models.list() + model_list = [m.id for m in models][:20] + return { + "connected": True, + "provider": provider.name, + "base_url": provider.base_url, + "models_available": len(model_list), + "model_samples": model_list[:5], + } + except AuthenticationError as e: + return { + "connected": False, + "error": f"Authentication failed. Check your API key.\n Provider: {provider.name}\n URL: {provider.base_url}\n Key prefix: {provider.api_key[:12] if provider.api_key else '(empty)'}...\n Detail: {e}", + } + except NotFoundError: + return { + "connected": False, + "error": f"Endpoint not found. Check base_url.\n URL: {provider.base_url}\n Some providers don't support the /models endpoint but still work for chat.", + } + except RateLimitError as e: + return { + "connected": True, + "warning": f"Connected but rate limited: {e}", + "provider": provider.name, + } + except APIError as e: + return { + "connected": True, + "warning": f"Connected (API responded, status={e.status_code}): {e}", + "provider": provider.name, + } + except Exception as e: + return { + "connected": False, + "error": f"Connection failed: {type(e).__name__}: {e}", + } + + +def list_providers() -> str: + """Return a formatted table of all available providers.""" + lines = [] + lines.append("─" * 78) + lines.append(f" Available AI Providers ({len(PROVIDERS)})") + lines.append("─" * 78) + lines.append(f" {'Name':<14} {'Default Model':<30} {'Key Env Var':<22}") + lines.append("─" * 78) + for pname in sorted(PROVIDERS.keys()): + p = PROVIDERS[pname] + key_var = p.env_key or "(none)" + model = p.default_model[:28] if p.default_model else "(auto)" + lines.append(f" {pname:<14} {model:<30} {key_var:<22}") + lines.append("─" * 78) + lines.append(" Set WP_AI_PROVIDER= and the corresponding key env var.") + lines.append(" Or just WP_AI_API_KEY + WP_AI_BASE_URL for quick custom setup.") + lines.append(" Ollama is auto-detected if running on localhost:11434.") + lines.append("─" * 78) + return "\n".join(lines) + + +def get_llm_client(provider: AIProvider): + """Create an OpenAI-compatible client for the given provider.""" + from openai import OpenAI + return OpenAI(api_key=provider.api_key, base_url=provider.base_url) + + +def get_embedding_client(provider: AIProvider): + """Create an OpenAI-compatible client for embeddings.""" + from openai import OpenAI + return OpenAI(api_key=provider.api_key, base_url=provider.base_url) diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..3501024 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite:///./data/walletpress.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..47f486d --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,56 @@ +"""Alembic migration environment for WalletPress. + +The app uses raw sqlite3 (not SQLAlchemy). Migrations use raw SQL +via op.execute(). This keeps the app free of SQLAlchemy overhead +while still benefiting from Alembic's migration management. + +The sqlalchemy.url in alembic.ini is the fallback. This env.py +resolves cfg.db_path at runtime so migrations hit the same DB +the app uses (respecting WP_DATA_DIR). + +Usage: + alembic revision --autogenerate -m "description" + alembic upgrade head + alembic downgrade -1 +""" + +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import create_engine, pool + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Resolve the actual database path from app config at runtime. +# Fall back to alembic.ini value if import fails (offline/CI). +try: + from core.config import cfg + db_url = f"sqlite:///{cfg.db_path}" +except Exception: + db_url = config.get_main_option("sqlalchemy.url") + + +def run_migrations_offline() -> None: + context.configure(url=db_url, literal_binds=True, dialect_opts={"paramstyle": "named"}) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + # Ensure the parent directory exists (SQLAlchemy won't create it) + db_file = db_url.replace("sqlite:///", "") + Path(db_file).parent.mkdir(parents=True, exist_ok=True) + connectable = create_engine(db_url, poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure(connection=connection) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..48c08eb --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,68 @@ +"""Initial vault schema — wallets, rotations, FTS, version tracking. + +Revises: None (first migration) +Create Date: 2026-06-30 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "001_initial_schema" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS wallets ( + id TEXT PRIMARY KEY, + chain TEXT NOT NULL, + address TEXT NOT NULL, + label TEXT DEFAULT '', + tags TEXT DEFAULT '[]', + wallet_group TEXT DEFAULT '', + created_at REAL NOT NULL, + encrypted_key TEXT DEFAULT '', + public_key TEXT DEFAULT '', + derivation_path TEXT DEFAULT '', + hd_path TEXT DEFAULT '', + mnemonic_encrypted TEXT DEFAULT '', + encrypted INTEGER DEFAULT 0, + balance_usd REAL DEFAULT 0.0, + notes TEXT DEFAULT '', + updated_at REAL DEFAULT 0 + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_wallets_chain ON wallets(chain)") + op.execute("CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address)") + op.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS wallets_fts USING fts5( + id, chain, address, label, notes, + content='wallets', + content_rowid='rowid' + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS rotations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_id TEXT NOT NULL, + new_address TEXT NOT NULL, + rotated_at REAL NOT NULL, + reason TEXT DEFAULT '', + FOREIGN KEY(wallet_id) REFERENCES wallets(id) + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS _schema_version ( + version INTEGER PRIMARY KEY, + applied_at REAL NOT NULL + ) + """) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS rotations") + op.execute("DROP TABLE IF EXISTS wallets_fts") + op.execute("DROP TABLE IF EXISTS wallets") + op.execute("DROP TABLE IF EXISTS _schema_version") diff --git a/backend/cli/verify_receipt.py b/backend/cli/verify_receipt.py new file mode 100644 index 0000000..882a09a --- /dev/null +++ b/backend/cli/verify_receipt.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""WalletPress Receipt Verifier — CLI tool to verify x402 order receipts. + +Usage: + # Verify an order receipt + python3 verify_receipt.py --order-id x402_abc123 \\ + --signature "base64signature..." \\ + --pubkey "ed25519pubkeyhex" + + # Verify a key deletion attestation + python3 verify_receipt.py --order-id x402_abc123 \\ + --deletion-sig "base64signature..." \\ + --pubkey "ed25519pubkeyhex" \\ + --addresses addr1,addr2,addr3 + + # Fetch and verify from a running WalletPress instance + python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010 + +Trust: This tool is open source. It does NOT phone home. It does NOT +send your keys anywhere. It only verifies cryptographic signatures. +""" + +import argparse +import hashlib +import sys +from typing import Any + +try: + import httpx +except ImportError: + httpx = None # type: ignore + +try: + from nacl.bindings import crypto_sign_open +except ImportError: + crypto_sign_open = None # type: ignore + + +def verify_receipt(order_id: str, chain: str, count: int, total_usd: float, + timestamp: float, signature: str, pubkey_hex: str) -> bool: + """Verify an Ed25519-signed order receipt.""" + if crypto_sign_open is None: + print("ERROR: pynacl is required. Install: pip install pynacl") + return False + message = f"walletpress:receipt:{order_id}:{chain}:{count}:{total_usd}:{timestamp}".encode() + import base64 + sig_bytes = base64.b64decode(signature) + try: + crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex)) + return True + except Exception: + return False + + +def verify_key_deletion(order_id: str, chain: str, count: int, + addresses: list[str], signature: str, pubkey_hex: str) -> bool: + """Verify a key deletion attestation — proves keys were wiped.""" + if crypto_sign_open is None: + print("ERROR: pynacl is required. Install: pip install pynacl") + return False + addr_hash = hashlib.sha256("".join(sorted(addresses)).encode()).hexdigest()[:16] + message = f"walletpress:key-deletion:{order_id}:{chain}:{count}:{addr_hash}:{int(timestamp)}".encode() + import base64 + sig_bytes = base64.b64decode(signature) + try: + crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex)) + return True + except Exception: + return False + + +def fetch_and_verify(server_url: str, order_id: str) -> dict[str, Any]: + """Fetch order details from a WalletPress server and verify the receipt.""" + if httpx is None: + return {"error": "httpx is required. Install: pip install httpx"} + try: + resp = httpx.get(f"{server_url}/api/v1/marketplace/verify-receipt", params={ + "order_id": order_id, + }, timeout=10) + if resp.status_code == 404: + return {"error": f"Order {order_id} not found on {server_url}"} + data = resp.json() + return data + except httpx.RequestError as e: + return {"error": f"Failed to connect to {server_url}: {e}"} + + +def main(): + parser = argparse.ArgumentParser( + description="WalletPress Receipt Verifier — prove your order was fulfilled", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Verify a receipt + python3 verify_receipt.py --order-id x402_abc123 --signature "sig" --pubkey "key" + + # Verify key deletion + python3 verify_receipt.py --order-id x402_abc123 --deletion-sig "sig" --pubkey "key" --addresses addr1,addr2 + + # Fetch and verify from server + python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010 + """, + ) + parser.add_argument("--order-id", required=True, help="Order ID from the generate response") + parser.add_argument("--signature", help="Receipt signature (base64)") + parser.add_argument("--deletion-sig", help="Key deletion attestation signature (base64)") + parser.add_argument("--pubkey", help="Server's Ed25519 public key (hex)") + parser.add_argument("--addresses", help="Comma-separated wallet addresses (for deletion verification)") + parser.add_argument("--server", help="WalletPress server URL to fetch order details from") + parser.add_argument("--chain", default="sol", help="Chain the wallets were generated on") + parser.add_argument("--count", type=int, default=1, help="Number of wallets generated") + parser.add_argument("--total-usd", type=float, default=0, help="Total amount paid in USD") + parser.add_argument("--timestamp", type=float, default=0, help="Order timestamp (Unix)") + + args = parser.parse_args() + + if args.server: + result = fetch_and_verify(args.server, args.order_id) + if "error" in result: + print(f"FAIL: {result['error']}") + sys.exit(1) + print(f"Order: {result.get('order_id', '?')}") + print(f"Chain: {result.get('chain', '?')}") + print(f"Count: {result.get('count', '?')}") + print(f"Amount: ${result.get('amount_usd', '?')}") + print(f"Valid: {result.get('valid', '?')}") + print(f"Status: {'VERIFIED' if result.get('valid') else 'INVALID'}") + sys.exit(0 if result.get('valid') else 1) + + if args.signature and args.pubkey: + if not args.timestamp: + print("ERROR: --timestamp is required for receipt verification") + sys.exit(1) + valid = verify_receipt( + args.order_id, args.chain, args.count, args.total_usd, + args.timestamp, args.signature, args.pubkey, + ) + print(f"Receipt: {'VALID' if valid else 'INVALID'}") + print(f" Order: {args.order_id}") + print(f" Chain: {args.chain}") + print(f" Count: {args.count}") + print(f" Amount: ${args.total_usd}") + print(" Algorithm: Ed25519") + if not valid: + sys.exit(1) + + if args.deletion_sig and args.pubkey and args.addresses: + addresses = [a.strip() for a in args.addresses.split(",")] + valid = verify_key_deletion( + args.order_id, args.chain, args.count, addresses, + args.deletion_sig, args.pubkey, + ) + print(f"Key Deletion: {'VERIFIED' if valid else 'FAILED'}") + print(f" Order: {args.order_id}") + print(f" Addresses: {len(addresses)} wallets") + print(" Algorithm: Ed25519") + if not valid: + print(" WARNING: Key deletion attestation is INVALID. The server may have kept your keys.") + sys.exit(1) + print(" Keys were generated in memory and wiped. We did not store them.") + + if not args.signature and not args.deletion_sig and not args.server: + print("ERROR: Provide --signature, --deletion-sig, or --server") + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/core/db_pool.py b/backend/core/db_pool.py new file mode 100644 index 0000000..d8df6fc --- /dev/null +++ b/backend/core/db_pool.py @@ -0,0 +1,133 @@ +"""Shared SQLite connection pool — WAL mode, thread-safe, connection reuse. + +Every module that needs SQLite should use this instead of creating +ad-hoc connections. Fixes the "database is locked" errors from +connection-per-call patterns across agent_safety, smart_wallet, +scheduler, and x402 modules. +""" + +from __future__ import annotations + +import sqlite3 +import threading +import time +from collections import OrderedDict +from pathlib import Path +from typing import Any + + +class _PooledConn: + __slots__ = ("conn", "last_used", "in_use") + + def __init__(self, conn: sqlite3.Connection): + self.conn = conn + self.last_used = time.monotonic() + self.in_use = False + + +class DbPool: + """Thread-safe SQLite connection pool with WAL mode and LRU eviction. + + Usage: + pool = DbPool("/data/myapp.db", max_size=8) + with pool.connect() as conn: + conn.execute("SELECT 1") + """ + + def __init__(self, db_path: Path | str, max_size: int = 8, busy_timeout: int = 5000): + self._path = Path(db_path) + self._max_size = max_size + self._busy_timeout = busy_timeout + self._lock = threading.Lock() + self._pool: OrderedDict[str, _PooledConn] = OrderedDict() + self._path.parent.mkdir(parents=True, exist_ok=True) + + def _new_conn(self) -> sqlite3.Connection: + conn = sqlite3.connect(str(self._path), timeout=self._busy_timeout / 1000, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute(f"PRAGMA busy_timeout={self._busy_timeout}") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + @property + def path(self) -> Path: + return self._path + + def connect(self): + """Context manager that returns a pooled connection.""" + return _DbConnection(self) + + def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor: + with self.connect() as conn: + cur = conn.execute(sql, params) + conn.commit() + return cur + + def executescript(self, sql: str) -> None: + with self.connect() as conn: + conn.executescript(sql) + + def _acquire(self) -> sqlite3.Connection: + with self._lock: + now = time.monotonic() + for key in list(self._pool.keys()): + pc = self._pool[key] + if not pc.in_use: + pc.in_use = True + pc.last_used = now + self._pool.move_to_end(key) + return pc.conn + + if len(self._pool) < self._max_size: + key = f"conn_{len(self._pool)}" + conn = self._new_conn() + pc = _PooledConn(conn) + pc.in_use = True + self._pool[key] = pc + return conn + + oldest_key = next(iter(self._pool.keys())) + pc = self._pool[oldest_key] + try: + pc.conn.close() + except Exception: + pass + conn = self._new_conn() + pc.conn = conn + pc.in_use = True + pc.last_used = now + self._pool.move_to_end(oldest_key) + return conn + + def _release(self, conn: sqlite3.Connection) -> None: + with self._lock: + for pc in self._pool.values(): + if pc.conn is conn: + pc.in_use = False + pc.last_used = time.monotonic() + return + + def close_all(self) -> None: + with self._lock: + for pc in self._pool.values(): + try: + pc.conn.close() + except Exception: + pass + self._pool.clear() + + +class _DbConnection: + def __init__(self, pool: DbPool): + self._pool = pool + self._conn: sqlite3.Connection | None = None + + def __enter__(self) -> sqlite3.Connection: + self._conn = self._pool._acquire() + return self._conn + + def __exit__(self, *args: Any) -> None: + if self._conn is not None: + self._pool._release(self._conn) + self._conn = None diff --git a/backend/core/dependencies.py b/backend/core/dependencies.py new file mode 100644 index 0000000..805d42d --- /dev/null +++ b/backend/core/dependencies.py @@ -0,0 +1,66 @@ +"""FastAPI dependencies for wallet services. + +Enables testability via dependency_overrides — tests can inject mock +vaults, generators, etc. without monkey-patching module-level globals. +""" + +from __future__ import annotations + + +from fastapi import Request + +from core.config import cfg + + +async def get_vault(request: Request): + """Get vault instance from app state (or create if first call).""" + app = request.app + if not hasattr(app.state, "vault") or app.state.vault is None: + from core.vault import Vault + app.state.vault = Vault(cfg.db_path) + return app.state.vault + + +async def get_generator(request: Request): + """Get wallet generator from app state.""" + app = request.app + if not hasattr(app.state, "generator") or app.state.generator is None: + from wallet_engine.generator import WalletGenerator + app.state.generator = WalletGenerator(vault_password=cfg.vault_password) + return app.state.generator + + +async def get_key_store(request: Request): + """Get key store from app state.""" + app = request.app + if not hasattr(app.state, "key_store") or app.state.key_store is None: + from core.auth import KeyStore + app.state.key_store = KeyStore(cfg.keys_path) + return app.state.key_store + + +async def get_audit(request: Request): + """Get audit trail from app state.""" + app = request.app + if not hasattr(app.state, "audit") or app.state.audit is None: + from core.audit import AuditTrail + app.state.audit = AuditTrail(cfg.audit_path) + return app.state.audit + + +async def get_license(request: Request): + """Get license manager from app state.""" + app = request.app + if not hasattr(app.state, "license") or app.state.license is None: + from core.license import LicenseManager + app.state.license = LicenseManager() + return app.state.license + + +async def get_proof(request: Request): + """Get proof of generation from app state.""" + app = request.app + if not hasattr(app.state, "proof") or app.state.proof is None: + from core.proof import ProofOfGeneration + app.state.proof = ProofOfGeneration(cfg.data_dir / "proof.db") + return app.state.proof diff --git a/backend/core/event_bus.py b/backend/core/event_bus.py new file mode 100644 index 0000000..74cdd7a --- /dev/null +++ b/backend/core/event_bus.py @@ -0,0 +1,28 @@ +"""Shared event bus — decouples producers from WebSocket broadcast. + +Modules like x402_marketplace emit events here. main.py's WebSocket +broadcast loop reads from this bus. No circular imports. +""" + +from __future__ import annotations + +import logging +from typing import Callable + +logger = logging.getLogger("wp.event_bus") + +_subscribers: list[Callable[[str, dict], None]] = [] + + +def subscribe(fn: Callable[[str, dict], None]) -> None: + """Register a callback for all events.""" + _subscribers.append(fn) + + +def emit(event_type: str, data: dict) -> None: + """Emit an event to all subscribers (fire-and-forget).""" + for fn in _subscribers: + try: + fn(event_type, data) + except Exception as e: + logger.debug(f"Event subscriber error: {e}") diff --git a/backend/core/response.py b/backend/core/response.py new file mode 100644 index 0000000..8906671 --- /dev/null +++ b/backend/core/response.py @@ -0,0 +1,76 @@ +"""Standardized API response shapes. + +Every response follows:: + + Success: {"data": ..., "meta": {"request_id": "..."}} + Error: {"error": {"code": "...", "message": "...", "details": ...}} + +This module provides helpers that all routers should use instead of +returning raw dicts. Migration is incremental — old endpoints that +return bare dicts still work via FastAPI's auto-serialisation. +""" + +from __future__ import annotations + +import time +import secrets + +from fastapi.responses import JSONResponse + + +def success(data, status_code: int = 200, meta: dict | None = None) -> JSONResponse: + body: dict = {"data": data} + if meta: + body["meta"] = {**meta, "timestamp": time.time()} + else: + body["meta"] = {"timestamp": time.time()} + return JSONResponse(content=body, status_code=status_code) + + +def error(code: str, message: str, status_code: int = 400, details: dict | None = None, request_id: str | None = None) -> JSONResponse: + body: dict = { + "error": { + "code": code, + "message": message, + } + } + if details: + body["error"]["details"] = details + body["meta"] = {"request_id": request_id or f"req_{secrets.token_hex(8)}", "timestamp": time.time()} + return JSONResponse(content=body, status_code=status_code) + + +def created(data) -> JSONResponse: + return success(data, status_code=201) + + +def no_content() -> JSONResponse: + return JSONResponse(content=None, status_code=204) + + +def bad_request(message: str, details: dict | None = None) -> JSONResponse: + return error("bad_request", message, 400, details) + + +def unauthorized(message: str = "Authentication required") -> JSONResponse: + return error("unauthorized", message, 401) + + +def forbidden(message: str = "Access denied") -> JSONResponse: + return error("forbidden", message, 403) + + +def not_found(message: str = "Resource not found") -> JSONResponse: + return error("not_found", message, 404) + + +def conflict(message: str, details: dict | None = None) -> JSONResponse: + return error("conflict", message, 409, details) + + +def too_many_requests(message: str = "Rate limit exceeded") -> JSONResponse: + return error("too_many_requests", message, 429) + + +def server_error(message: str = "Internal server error") -> JSONResponse: + return error("server_error", message, 500) diff --git a/backend/core/smart_wallet.py b/backend/core/smart_wallet.py new file mode 100644 index 0000000..adb256d --- /dev/null +++ b/backend/core/smart_wallet.py @@ -0,0 +1,755 @@ +"""Smart Wallet — Social Recovery, Dead Man's Switch, Time-Locked Transactions. + +NO SMART CONTRACTS NEEDED. Everything is off-chain, self-hosted, and +controlled by the WalletPress backend. The trust model: + + 1. Social Recovery: M-of-N guardians sign a recovery message. + The backend verifies signatures against known guardian addresses. + 2. Dead Man's Switch: If a wallet is inactive for N days, the + designated heir can claim it by proving their ownership. + 3. Time Locks: The backend holds encrypted keys and executes + transactions at the scheduled time. + +This is the feature that makes WalletPress a REAL wallet provider, +not just a key generator. Nobody in the self-hosted space offers this. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import secrets +import sqlite3 +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from core.config import cfg + +logger = logging.getLogger("wp.smart_wallet") + +RECOVERY_MESSAGE_TEMPLATE = ( + "WalletPress Social Recovery\n" + "Wallet: {wallet_id}\n" + "New Key: {new_address}\n" + "Chain: {chain}\n" + "Timestamp: {timestamp}\n" + "This authorizes key rotation for the above wallet." +) + + +@dataclass +class RecoveryConfig: + wallet_id: str + guardians: list[str] = field(default_factory=list) + threshold: int = 2 + created_at: float = 0.0 + + def to_dict(self) -> dict: + return { + "wallet_id": self.wallet_id, + "guardians": self.guardians, + "threshold": self.threshold, + "created_at": self.created_at, + } + + +@dataclass +class Timelock: + id: str + wallet_id: str + to_address: str + chain: str + amount: float + execute_at: float + executed: bool = False + created_at: float = 0.0 + tx_hash: str = "" + + +@dataclass +class DeadmanSwitch: + wallet_id: str + heir_address: str + heir_chain: str + timeout_days: int = 180 + last_activity: float = 0.0 + created_at: float = 0.0 + triggered: bool = False + + +class SmartWalletManager: + """Manages smart wallet features: recovery, deadman, timelocks. + + All data stored in SQLite at cfg.data_dir / smart_wallet.db. + """ + + def __init__(self, db_path: Optional[Path] = None): + self._db_path = db_path or cfg.data_dir / "smart_wallet.db" + self._lock = threading.Lock() + self._init_db() + + def _conn(self): + conn = sqlite3.connect(str(self._db_path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + return conn + + def _init_db(self): + self._db_path.parent.mkdir(parents=True, exist_ok=True) + conn = self._conn() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS recovery_configs ( + wallet_id TEXT PRIMARY KEY, + guardians TEXT NOT NULL, + threshold INTEGER NOT NULL, + created_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS recovery_requests ( + id TEXT PRIMARY KEY, + wallet_id TEXT NOT NULL, + new_address TEXT NOT NULL, + chain TEXT NOT NULL, + guardian_sigs TEXT NOT NULL, + status TEXT DEFAULT 'pending', + created_at REAL NOT NULL, + executed_at REAL DEFAULT 0, + FOREIGN KEY(wallet_id) REFERENCES recovery_configs(wallet_id) + ); + CREATE TABLE IF NOT EXISTS deadman_switches ( + wallet_id TEXT PRIMARY KEY, + heir_address TEXT NOT NULL, + heir_chain TEXT NOT NULL, + timeout_days INTEGER NOT NULL, + last_activity REAL NOT NULL, + created_at REAL NOT NULL, + triggered INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS timelocks ( + id TEXT PRIMARY KEY, + wallet_id TEXT NOT NULL, + to_address TEXT NOT NULL, + chain TEXT NOT NULL, + amount REAL NOT NULL, + execute_at REAL NOT NULL, + executed INTEGER DEFAULT 0, + created_at REAL NOT NULL, + tx_hash TEXT DEFAULT '' + ); + """) + conn.commit() + conn.close() + logger.info("Smart wallet database initialized") + + # ── SOCIAL RECOVERY ───────────────────────────────────── + + def setup_recovery(self, wallet_id: str, guardians: list[str], + threshold: int = 2) -> dict: + """Configure M-of-N social recovery for a wallet. + + Args: + wallet_id: The wallet to protect + guardians: List of guardian wallet addresses (must be >= threshold) + threshold: Number of guardian signatures required (M in M-of-N) + + Returns: + dict with config_id and details + """ + if len(guardians) < threshold: + return {"error": f"Need at least {threshold} guardians, got {len(guardians)}"} + if threshold < 1: + return {"error": "Threshold must be >= 1"} + + config = RecoveryConfig( + wallet_id=wallet_id, + guardians=guardians, + threshold=threshold, + created_at=time.time(), + ) + conn = self._conn() + conn.execute( + """INSERT OR REPLACE INTO recovery_configs + (wallet_id, guardians, threshold, created_at) + VALUES (?, ?, ?, ?)""", + (wallet_id, json.dumps(guardians), threshold, time.time()), + ) + conn.commit() + conn.close() + logger.info(f"Recovery configured for {wallet_id}: {threshold}-of-{len(guardians)}") + return config.to_dict() + + def get_recovery_config(self, wallet_id: str) -> Optional[dict]: + """Get the recovery configuration for a wallet.""" + conn = self._conn() + row = conn.execute( + "SELECT * FROM recovery_configs WHERE wallet_id = ?", (wallet_id,) + ).fetchone() + conn.close() + if not row: + return None + return { + "wallet_id": row["wallet_id"], + "guardians": json.loads(row["guardians"]), + "threshold": row["threshold"], + "created_at": row["created_at"], + } + + def request_recovery(self, wallet_id: str, new_address: str, chain: str, + signatures: list[dict]) -> dict: + """Request social recovery with guardian signatures. + + Each signature must be: + {"address": "0x...", "signature": "0x...", "message": "..."} + + The backend verifies all signatures against the registered + guardians. If M valid signatures are found, recovery proceeds. + + Args: + wallet_id: Wallet to recover + new_address: New address to rotate to + chain: Chain for the new wallet + signatures: List of guardian signature dicts + + Returns: + dict with recovery status + """ + config = self.get_recovery_config(wallet_id) + if not config: + return {"error": "No recovery configuration found for this wallet"} + + if len(signatures) < config["threshold"]: + return { + "error": f"Need {config['threshold']} valid signatures, got {len(signatures)}", + "required": config["threshold"], + "provided": len(signatures), + } + + # Verify each signature against registered guardians + valid_sigs = [] + for sig in signatures: + addr = sig.get("address", "").lower() + sig_data = sig.get("signature", "") + message = sig.get("message", "") + + if addr not in [g.lower() for g in config["guardians"]]: + continue + + if self._verify_signature(addr, message, sig_data, chain): + valid_sigs.append(sig) + + if len(valid_sigs) < config["threshold"]: + return { + "error": f"Only {len(valid_sigs)}/{config['threshold']} valid guardian signatures", + "valid_signatures": len(valid_sigs), + "required": config["threshold"], + } + + # Execute recovery: rotate the wallet key + recovery_id = f"rec_{secrets.token_hex(8)}" + conn = self._conn() + conn.execute( + """INSERT INTO recovery_requests + (id, wallet_id, new_address, chain, guardian_sigs, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (recovery_id, wallet_id, new_address, chain, + json.dumps(valid_sigs), "approved", time.time()), + ) + conn.commit() + conn.close() + + # Perform the actual key rotation + rotate_result = self._execute_recovery(wallet_id, new_address, chain) + if rotate_result.get("error"): + return rotate_result + + conn = self._conn() + conn.execute( + "UPDATE recovery_requests SET status = ?, executed_at = ? WHERE id = ?", + ("executed", time.time(), recovery_id), + ) + conn.commit() + conn.close() + + return { + "recovered": True, + "recovery_id": recovery_id, + "wallet_id": wallet_id, + "new_address": new_address, + "chain": chain, + "guardians_used": len(valid_sigs), + "threshold": config["threshold"], + } + + def _verify_signature(self, address: str, message: str, + signature: str, chain: str) -> bool: + """Verify a signed message against an address. + + Supports: EVM (eth_sign / EIP-191), Solana (ed25519), + Bitcoin-family, and raw secp256k1. + """ + if not address or not signature: + return False + + try: + if chain in ("sol",): + # Solana: ed25519 signature + import base58 + from nacl.bindings import crypto_sign_verify_detached + sig_bytes = bytes.fromhex(signature.replace("0x", "")) + msg_bytes = message.encode() + pub_bytes = base58.b58decode(address) + crypto_sign_verify_detached(sig_bytes, msg_bytes, pub_bytes) + return True + return False + else: + # EVM and everything else: secp256k1 (EIP-191 / eth_sign) + msg_hash = hashlib.sha256(message.encode()).digest() + if len(signature) == 132: # 0x-prefixed hex + sig_bytes = bytes.fromhex(signature[2:]) + else: + sig_bytes = bytes.fromhex(signature) + + if len(sig_bytes) != 65: + return False + + from coincurve import PublicKey + from coincurve.ecdsa import recover as ecdsa_recover + + int.from_bytes(sig_bytes[:32], 'big') + int.from_bytes(sig_bytes[32:64], 'big') + v = sig_bytes[64] + + # Recover public key from signature + pub = PublicKey.from_signature_and_message( + sig_bytes[:64], msg_hash, recovery_id=v - 27 if v in (27, 28) else v + ) + pub_hex = pub.format().hex() + + # Derive address + if pub_hex: + from Crypto.Hash import keccak + k = keccak.new(digest_bits=256) + k.update(bytes.fromhex(pub_hex[2:] if pub_hex.startswith("04") else pub_hex)) + recovered_addr = "0x" + k.digest()[-20:].hex() + return recovered_addr.lower() == address.lower() + return False + except ImportError: + raise RuntimeError( + "coincurve is required for signature verification. " + "Install it: pip install coincurve>=18.0.0" + ) + except Exception as e: + logger.debug(f"Signature verification failed: {e}") + return False + + def _execute_recovery(self, wallet_id: str, new_address: str, chain: str) -> dict: + """Execute recovery by recording a rotation to the provided new_address. + + The new_address is the address the user wants to take control. + We record the rotation so the vault knows control transferred. + """ + try: + from core.vault import get_vault + + vault = get_vault() + old_wallet = vault.get(wallet_id) + if not old_wallet: + return {"error": f"Wallet {wallet_id} not found"} + + vault.record_rotation( + wallet_id, new_address, + reason=f"social_recovery_to_{new_address}", + ) + logger.info(f"Recovery executed: {wallet_id} → {new_address}") + return {"success": True, "new_address": new_address} + except Exception as e: + logger.error(f"Recovery execution failed: {e}") + return {"error": f"Recovery execution failed: {e}"} + + def get_recovery_status(self, wallet_id: str) -> dict: + """Get the recovery status for a wallet.""" + config = self.get_recovery_config(wallet_id) + conn = self._conn() + requests = conn.execute( + "SELECT * FROM recovery_requests WHERE wallet_id = ? ORDER BY created_at DESC", + (wallet_id,), + ).fetchall() + conn.close() + return { + "configured": config is not None, + "config": config, + "requests": [dict(r) for r in requests], + } + + # ── DEAD MAN'S SWITCH ─────────────────────────────────── + + def setup_deadman(self, wallet_id: str, heir_address: str, + heir_chain: str = "eth", + timeout_days: int = 180) -> dict: + """Set up a dead man's switch for a wallet. + + If the wallet has no activity for `timeout_days`, the heir can + trigger recovery and claim the wallet. + + Args: + wallet_id: Wallet to protect + heir_address: Address of the heir + heir_chain: Chain of the heir's address + timeout_days: Days of inactivity before switch triggers + + Returns: + dict with deadman switch status + """ + if timeout_days < 7: + return {"error": "Minimum timeout is 7 days"} + if timeout_days > 730: + return {"error": "Maximum timeout is 730 days (2 years)"} + + conn = self._conn() + conn.execute( + """INSERT OR REPLACE INTO deadman_switches + (wallet_id, heir_address, heir_chain, timeout_days, last_activity, + created_at, triggered) + VALUES (?, ?, ?, ?, ?, ?, 0)""", + (wallet_id, heir_address, heir_chain, timeout_days, + time.time(), time.time()), + ) + conn.commit() + conn.close() + logger.info(f"Dead man's switch for {wallet_id}: {timeout_days} days → {heir_address}") + return { + "activated": True, + "wallet_id": wallet_id, + "heir_address": heir_address, + "heir_chain": heir_chain, + "timeout_days": timeout_days, + "expires_at": time.time() + (timeout_days * 86400), + } + + def check_deadman(self, wallet_id: str) -> dict: + """Check dead man's switch status for a wallet. + + Returns: + dict with status, days remaining, whether triggerable + """ + conn = self._conn() + row = conn.execute( + "SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,) + ).fetchone() + conn.close() + if not row: + return {"active": False} + + elapsed_days = (time.time() - row["last_activity"]) / 86400 + remaining = max(0, row["timeout_days"] - elapsed_days) + expired = elapsed_days >= row["timeout_days"] + + return { + "active": True, + "wallet_id": row["wallet_id"], + "heir_address": row["heir_address"], + "heir_chain": row["heir_chain"], + "timeout_days": row["timeout_days"], + "elapsed_days": round(elapsed_days, 1), + "remaining_days": round(remaining, 1), + "expired": expired, + "triggered": bool(row["triggered"]), + } + + def trigger_deadman(self, wallet_id: str, claimant_address: str) -> dict: + """Trigger a dead man's switch as the heir. + + Only the registered heir can trigger this. The wallet's key + is rotated and the new address is returned. + + Args: + wallet_id: Wallet to reclaim + claimant_address: Address claiming to be the heir + + Returns: + dict with recovery result + """ + conn = self._conn() + row = conn.execute( + "SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,) + ).fetchone() + conn.close() + + if not row: + return {"error": "No dead man's switch configured for this wallet"} + + if row["triggered"]: + return {"error": "Dead man's switch already triggered"} + + if claimant_address.lower() != row["heir_address"].lower(): + return {"error": "Only the registered heir can trigger the dead man's switch"} + + elapsed_days = (time.time() - row["last_activity"]) / 86400 + if elapsed_days < row["timeout_days"]: + remaining = row["timeout_days"] - elapsed_days + return { + "error": f"Dead man's switch not yet expired. {remaining:.0f} days remaining", + "remaining_days": round(remaining, 1), + } + + # Execute recovery: rotate to new key controlled by heir + result = self._execute_recovery( + wallet_id, claimant_address, row["heir_chain"], + ) + if result.get("error"): + return result + + conn = self._conn() + conn.execute( + "UPDATE deadman_switches SET triggered = 1 WHERE wallet_id = ?", + (wallet_id,), + ) + conn.commit() + conn.close() + + return { + "triggered": True, + "wallet_id": wallet_id, + "heir_address": row["heir_address"], + "elapsed_days": round(elapsed_days, 1), + "new_wallet_id": result.get("new_wallet_id"), + "new_address": result.get("new_address"), + } + + def ping_deadman(self, wallet_id: str) -> dict: + """Reset the dead man's switch timer (called on wallet activity). + + Each time the wallet is used (generation, sweep, balance check), + call this to reset the inactivity timer. + """ + conn = self._conn() + conn.execute( + "UPDATE deadman_switches SET last_activity = ? WHERE wallet_id = ?", + (time.time(), wallet_id), + ) + conn.commit() + rows = conn.total_changes + conn.close() + return {"reset": rows > 0, "wallet_id": wallet_id} + + # ── TIME-LOCKED TRANSACTIONS ──────────────────────────── + + def create_timelock(self, wallet_id: str, to_address: str, chain: str, + amount: float, execute_at: float) -> dict: + """Create a time-locked transaction. + + The transaction is stored and executed by the scheduler when + `execute_at` is reached. The vault password must be configured + to enable automatic execution. + + Args: + wallet_id: Source wallet + to_address: Destination address + chain: Blockchain + amount: Amount in native tokens + execute_at: Unix timestamp for execution + + Returns: + dict with timelock details + """ + if execute_at <= time.time(): + return {"error": "Execution time must be in the future"} + + timelock_id = f"tl_{secrets.token_hex(8)}" + conn = self._conn() + conn.execute( + """INSERT INTO timelocks + (id, wallet_id, to_address, chain, amount, execute_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (timelock_id, wallet_id, to_address, chain, amount, execute_at, time.time()), + ) + conn.commit() + conn.close() + + return { + "timelock_id": timelock_id, + "wallet_id": wallet_id, + "to_address": to_address, + "chain": chain, + "amount": amount, + "execute_at": execute_at, + "created_at": time.time(), + } + + def list_timelocks(self, wallet_id: str = "") -> list[dict]: + """List time-locked transactions.""" + conn = self._conn() + if wallet_id: + rows = conn.execute( + "SELECT * FROM timelocks WHERE wallet_id = ? ORDER BY execute_at", + (wallet_id,), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM timelocks ORDER BY execute_at" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + def cancel_timelock(self, timelock_id: str) -> dict: + """Cancel a time-locked transaction before execution.""" + conn = self._conn() + row = conn.execute( + "SELECT * FROM timelocks WHERE id = ? AND executed = 0", + (timelock_id,), + ).fetchone() + if not row: + conn.close() + return {"error": "Timelock not found or already executed"} + conn.execute("DELETE FROM timelocks WHERE id = ?", (timelock_id,)) + conn.commit() + conn.close() + return {"cancelled": True, "timelock_id": timelock_id} + + def execute_due_timelocks(self) -> list[dict]: + """Execute all time-locked transactions that are due. + + Called by the background scheduler. Requires vault password + to be configured for key decryption. + + Returns: + list of execution results + """ + if not cfg.vault_password: + logger.warning("Vault password not configured — cannot execute timelocks") + return [] + + results = [] + now = time.time() + conn = self._conn() + rows = conn.execute( + "SELECT * FROM timelocks WHERE executed = 0 AND execute_at <= ?", + (now,), + ).fetchall() + conn.close() + + for tl in rows: + try: + result = self._send_transaction( + tl["wallet_id"], tl["to_address"], tl["chain"], tl["amount"], + ) + conn = self._conn() + conn.execute( + "UPDATE timelocks SET executed = 1, tx_hash = ? WHERE id = ?", + (result.get("tx_hash", ""), tl["id"]), + ) + conn.commit() + conn.close() + results.append({ + "timelock_id": tl["id"], + "executed": True, + "tx_hash": result.get("tx_hash", ""), + }) + logger.info(f"Timelock {tl['id']} executed") + except Exception as e: + logger.error(f"Timelock {tl['id']} execution failed: {e}") + results.append({ + "timelock_id": tl["id"], + "executed": False, + "error": str(e), + }) + + return results + + def _send_transaction(self, wallet_id: str, to_address: str, + chain: str, amount: float) -> dict: + """Execute an on-chain transaction via configured RPC. + + Supports EVM chains (eth, base, polygon, etc.) via eth_sendRawTransaction. + For other chains, records the intent with instructions. + """ + from core.vault import get_vault + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + raise ValueError(f"Wallet {wallet_id} not found") + + rpc_url = os.getenv(f"WP_RPC_{chain.upper()}", "") + if not rpc_url: + return { + "tx_hash": f"pending_{wallet_id}_{int(time.time())}", + "from": wallet.address, + "to": to_address, + "chain": chain, + "amount": amount, + "note": f"No RPC configured for {chain}. Set WP_RPC_{chain.upper()} env var.", + } + + from wallet_engine.chains import CHAINS + chain_info = CHAINS.get(chain) + if chain_info and chain_info.family.value in ("evm",): + try: + import httpx + resp = httpx.post( + rpc_url, + json={ + "jsonrpc": "2.0", + "method": "eth_sendRawTransaction", + "params": [wallet.encrypted_key], + "id": 1, + }, + timeout=30, + ) + data = resp.json() + tx_hash = data.get("result", "") + if tx_hash: + logger.info(f"Transaction broadcast: {chain} tx={tx_hash[:16]}...") + return { + "tx_hash": tx_hash, + "from": wallet.address, + "to": to_address, + "chain": chain, + "amount": amount, + "note": "Transaction broadcast to mempool.", + } + except Exception as e: + logger.error(f"Transaction broadcast failed: {e}") + + return { + "tx_hash": f"pending_{wallet_id}_{int(time.time())}", + "from": wallet.address, + "to": to_address, + "chain": chain, + "amount": amount, + "note": f"On-chain broadcast requires RPC configuration for {chain}. Set WP_RPC_{chain.upper()}.", + } + + # ── UTILITY ───────────────────────────────────────────── + + def stats(self) -> dict: + """Get smart wallet statistics.""" + conn = self._conn() + recoveries = conn.execute("SELECT COUNT(*) FROM recovery_configs").fetchone()[0] + deadmen = conn.execute("SELECT COUNT(*) FROM deadman_switches").fetchone()[0] + timelocks = conn.execute("SELECT COUNT(*) FROM timelocks").fetchone()[0] + pending_tl = conn.execute( + "SELECT COUNT(*) FROM timelocks WHERE executed = 0 AND execute_at <= ?", + (time.time(),), + ).fetchone()[0] + conn.close() + return { + "recovery_configs": recoveries, + "deadman_switches": deadmen, + "timelocks_total": timelocks, + "timelocks_due": pending_tl, + } + + +_sw: Optional[SmartWalletManager] = None + + +def get_smart_wallet() -> SmartWalletManager: + global _sw + if _sw is None: + _sw = SmartWalletManager() + return _sw diff --git a/backend/core/x402_verify.py b/backend/core/x402_verify.py new file mode 100644 index 0000000..26d314b --- /dev/null +++ b/backend/core/x402_verify.py @@ -0,0 +1,151 @@ +"""x402 payment verification — multi-chain USDC via PayAI facilitator. + +Replicates the RMI backend's payment verification system: + https://facilitator.payai.network/verify + +Supports: Solana, Base, Polygon, Arbitrum, Ethereum — all USDC. +No API key needed. Sends the x402 v2 payload, gets back isValid + tx data. +""" + +from __future__ import annotations + +import json +import logging +import os + +import httpx + +logger = logging.getLogger("wp.x402_verify") + +FACILITATOR_URL = os.getenv("WP_X402_FACILITATOR_URL", "https://facilitator.payai.network/verify") +PAY_TO = os.getenv("WP_PAYMENT_ADDRESS", "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv") + +# Chain config: network identifier, USDC mint/contract, default pay-to address +CHAIN_CONFIG: dict[str, dict[str, str]] = { + "sol": { + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "usdc": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "pay_to": os.getenv("WP_PAYMENT_ADDRESS_SOL", "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv"), + }, + "base": { + "network": "eip155:8453", + "usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "pay_to": os.getenv("WP_PAYMENT_ADDRESS_BASE", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), + }, + "polygon": { + "network": "eip155:137", + "usdc": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "pay_to": os.getenv("WP_PAYMENT_ADDRESS_POLYGON", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), + }, + "arbitrum": { + "network": "eip155:42161", + "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "pay_to": os.getenv("WP_PAYMENT_ADDRESS_ARBITRUM", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), + }, + "eth": { + "network": "eip155:1", + "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "pay_to": os.getenv("WP_PAYMENT_ADDRESS_ETH", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), + }, +} + +_client: httpx.AsyncClient | None = None + + +async def get_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient(timeout=30) + return _client + + +def get_supported_chains() -> list[str]: + return list(CHAIN_CONFIG.keys()) + + +def get_pay_to(chain: str) -> str: + cfg = CHAIN_CONFIG.get(chain) + return cfg["pay_to"] if cfg else PAY_TO + + +async def verify_usdc_payment(chain: str, payment_tx: str, expected_usd: float) -> dict: + """Verify a USDC payment on any supported chain via PayAI facilitator. + + Args: + chain: Chain key ('sol', 'base', 'polygon', 'arbitrum', 'eth') + payment_tx: Transaction signature/hash + expected_usd: Expected USD amount + + Returns: + dict with keys: verified (bool), reason (str), tx_hash (str), + payer (str), amount (float) + """ + if expected_usd <= 0: + return {"verified": True, "reason": "free", "tx_hash": "", "payer": "", "amount": 0} + if not payment_tx: + return {"verified": False, "reason": "No payment tx provided", "tx_hash": "", "payer": "", "amount": 0} + + chain_cfg = CHAIN_CONFIG.get(chain) + if not chain_cfg: + return {"verified": False, "reason": f"Unsupported chain: {chain}", "tx_hash": payment_tx, "payer": "", "amount": 0} + + network = chain_cfg["network"] + usdc_address = chain_cfg["usdc"] + pay_to = chain_cfg["pay_to"] + + payload = { + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": network, + "asset": f"{network}:{usdc_address}" if chain != "sol" else f"solana:{usdc_address}", + "amount": str(expected_usd), + "payTo": pay_to, + "maxTimeoutSeconds": 300, + }, + "paymentTx": payment_tx, + } + + body = { + "x402Version": 2, + "paymentPayload": payload, + "paymentRequirements": { + "x402Version": 2, + "scheme": "exact", + "network": network, + "asset": f"{network}:{usdc_address}" if chain != "sol" else f"solana:{usdc_address}", + "amount": str(expected_usd), + "payTo": pay_to, + "maxTimeoutSeconds": 300, + }, + } + + try: + client = await get_client() + resp = await client.post(FACILITATOR_URL, json=body) + data = resp.json() if resp.content else {} + except httpx.TimeoutException: + logger.error("x402 PayAI verification timed out") + return {"verified": False, "reason": "Verification service timeout", "tx_hash": payment_tx, "payer": "", "amount": 0} + except httpx.RequestError as e: + logger.error(f"x402 PayAI verification failed: {e}") + return {"verified": False, "reason": f"Verification service error: {e}", "tx_hash": payment_tx, "payer": "", "amount": 0} + except json.JSONDecodeError: + logger.error(f"x402 PayAI returned non-JSON: {resp.status_code}") + return {"verified": False, "reason": "Invalid response from verification service", "tx_hash": payment_tx, "payer": "", "amount": 0} + + if data.get("isValid"): + result = { + "verified": True, + "reason": data.get("invalidReason", "verified"), + "tx_hash": data.get("tx_hash", payment_tx), + "payer": data.get("payer", ""), + "amount": float(data.get("amount", expected_usd)), + } + logger.info(f"x402 payment verified: chain={chain} tx={payment_tx[:16]}... amount=${expected_usd}") + return result + + reason = data.get("invalidReason", "unknown") + message = data.get("invalidMessage", "") + logger.warning(f"x402 payment rejected: chain={chain} tx={payment_tx[:16]}... reason={reason}") + return {"verified": False, "reason": f"{reason}: {message}", "tx_hash": payment_tx, "payer": "", "amount": 0} diff --git a/backend/main.py b/backend/main.py index 61d5726..9c8567d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,7 +37,7 @@ from core.rate_limit import RateLimitMiddleware from core.webhooks import get_webhook_deliverer from core.ip_allowlist import IPAllowlistMiddleware from core.license_check import LicenseMiddleware -from routers import chain_vault, wallet_analysis, wallet_memory, test_vectors, metrics as metrics_router +from routers import chain_vault, wallet_admin, wallet_analysis, wallet_memory, test_vectors, metrics as metrics_router 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 @@ -313,9 +313,9 @@ async def global_exception_handler(request: Request, exc: Exception): app.include_router(chain_vault.router) +app.include_router(wallet_admin.router) app.include_router(wallet_analysis.router) app.include_router(wallet_memory.router) -app.include_router(chain_vault.payment_router) app.include_router(test_vectors.router) app.include_router(metrics_router.router) app.include_router(airdrop_router.router) diff --git a/backend/plugins/__init__.py b/backend/plugins/__init__.py new file mode 100644 index 0000000..e48960a --- /dev/null +++ b/backend/plugins/__init__.py @@ -0,0 +1,24 @@ +"""WalletPress Protocol Plugins — extend agent with any blockchain protocol.""" +from plugins.sdk import WalletPressPlugin, register_plugin, list_plugins, get_all_tools, execute_plugin_tool +from plugins.sdk import _plugins as _plugin_registry + +# Auto-discover and register all plugin modules +import importlib +import pkgutil +import plugins + +def _discover_plugins(): + for importer, modname, ispkg in pkgutil.iter_modules(plugins.__path__): + if modname != "sdk" and not ispkg: + importlib.import_module(f"plugins.{modname}") + +_discover_plugins() + +__all__ = [ + "WalletPressPlugin", + "register_plugin", + "list_plugins", + "get_all_tools", + "execute_plugin_tool", + "_plugins", +] diff --git a/backend/plugins/defi.py b/backend/plugins/defi.py new file mode 100644 index 0000000..fc6f193 --- /dev/null +++ b/backend/plugins/defi.py @@ -0,0 +1,378 @@ +"""DeFi Plugins — Revenue-baked swaps, lending, prediction markets. + +Revenue Model: + FREEMIUM (default): Agent uses Rug Munch Media's referral codes on every + swap/trade. We earn 50bps (Jupiter, Hyperliquid) or 35-40% (bots) of fees. + + PAID: Users set WP_REF_REVENUE_MODE=self and configure their own referral + codes/wallets. They keep 100% of the kickback. + + Set WP_REF_REVENUE_MODE=self to switch to user-owned referrals. + Or override individual codes via WP_REF_* env vars. +""" +from __future__ import annotations +import os +import httpx +from plugins.sdk import WalletPressPlugin, register_plugin + +# ── Revenue Mode ───────────────────────────────────────────────────────────── +# "freemium" = our referral codes (we earn kickback) +# "self" = user's own codes (they keep kickback) +REVENUE_MODE = os.getenv("WP_REF_REVENUE_MODE", "freemium").lower() + +# ── REFERRAL SETUP GUIDE ───────────────────────────────────────────────────── +# These are NOT affiliate links — they are wallet-based referral programs. +# +# Jupiter (50bps): Requires a Solana wallet registered as a Jupiter fee account. +# 1. Go to https://jup.ag/referral or contact Jupiter team +# 2. Register your Solana wallet as a fee account +# 3. Set WP_REF_JUPITER_WALLET= +# Revenue: 50bps of every agent swap through Jupiter routed to this wallet. +# +# Hyperliquid (50bps): Requires an HL spot wallet as referrer. +# 1. Go to https://app.hyperliquid.xyz/referrals +# 2. Copy your spot wallet address +# 3. Set WP_REF_HYPERLIQUID_WALLET= +# Revenue: 50bps of every agent trade on Hyperliquid. +# +# Telegram Bots (35-40%): Simple referral codes — no wallet setup needed. +# GMGN: Use code "rugmunch" at gmgn.ai +# OdinBot: Use code "x5pyi0" at t.me/OdinBot +# Banana Gun: Use code "rugmunch" at t.me/BananaGunBot +# Axiom: Use code "@crmuncher" at t.me/AxiomTrade +# Padre: Use code "crm" at t.me/PadreBot +# +# 1. Sign up through the bot with the referral code +# 2. Your users enter the same code when they sign up + # 3. Revenue is auto-credited (35-40% of their fees) + +# ── Our Referral Codes & Wallets ────────────────────────────────────────────── +REF_JUPITER_WALLET = os.getenv("WP_REF_JUPITER_WALLET", "") # Solana wallet for Jupiter fee account + +# Hyperliquid Builder Code — per-trade fee via builder system +REF_HYPERLIQUID_WALLET = os.getenv("WP_REF_HYPERLIQUID_WALLET", "") + +# Polymarket Builder Code — bytes32 from polymarket.com/settings +REF_POLYMARKET_BUILDER = os.getenv("WP_REF_POLYMARKET_BUILDER", "") + +# Myriad Builder Code — whitelisted by team +REF_MYRIAD_BUILDER = os.getenv("WP_REF_MYRIAD_BUILDER", "") + +# Azuro Affiliate Wallet — any EVM wallet +REF_AZURO_AFFILIATE = os.getenv("WP_REF_AZURO_AFFILIATE", "") + +# Thales Referral ID — simple custom string, e.g. "crmuncher" +# https://thalesmarket.io/markets?referrerId=your_id — 0.5% of trading volume +REF_THALES_REFERRAL = os.getenv("WP_REF_THALES_REFERRAL", "") + +# WINR Partners — affiliate portal, up to 80% rev share +# Register at https://winrpartners.com, get your affiliate code +REF_WINR_AFFILIATE = os.getenv("WP_REF_WINR_AFFILIATE", "") + +# Baozi.bet affiliate — Solana prediction markets, has MCP for AI agents +REF_BAOZI_AFFILIATE = os.getenv("WP_REF_BAOZI_AFFILIATE", "") + +# FlashTrade — Solana trading terminal, 2% rebate referral +# Create code at flash.trade > Token > Utility > Create Custom Referral +REF_FLASHTRADE = os.getenv("WP_REF_FLASHTRADE", "") +REF_0X_API_KEY = os.getenv("WP_0X_API_KEY", "") +REF_0X_FEE_RECIPIENT = os.getenv("WP_0X_FEE_RECIPIENT", "") +REF_0X_FEE_BPS = os.getenv("WP_0X_FEE_BPS", "50") + +# Telegram bots — simple referral codes, no wallet setup +REF_GMGN = os.getenv("WP_REF_GMGN", "rugmunch") +REF_ODINBOT = os.getenv("WP_REF_ODINBOT", "x5pyi0") +REF_BANANA = os.getenv("WP_REF_BANANA", "rugmunch") +REF_AXIOM = os.getenv("WP_REF_AXIOM", "@crmuncher") +REF_PADRE = os.getenv("WP_REF_PADRE", "crm") + +REVENUE_TAG = f"[{'FREEMIUM' if REVENUE_MODE == 'freemium' else 'SELF-REFERRAL'}]" + + +class HyperliquidPlugin(WalletPressPlugin): + name = "hyperliquid" + hl_configured = bool(REF_HYPERLIQUID_WALLET) + description = f"{REVENUE_TAG} Perpetual DEX trading ({'BUILDER CODE READY' if hl_configured else 'NEEDS BUILDER SETUP'}, per-trade fee)" + supported_chains = ["evm"] + + def tools(self): + wallet_status = f"builder: {REF_HYPERLIQUID_WALLET[:8]}..." if self.hl_configured else "NEEDS SETUP — set WP_REF_HYPERLIQUID_WALLET" + return [ + {"name": "hl_market_order", "description": f"{REVENUE_TAG} Market order. Revenue: {wallet_status}", "parameters": {"coin": {"type": "string"}, "sz": {"type": "number"}, "is_buy": {"type": "boolean"}}, "required": ["coin", "sz", "is_buy"]}, + {"name": "hl_limit_order", "description": "Limit order", "parameters": {"coin": {"type": "string"}, "sz": {"type": "number"}, "price": {"type": "number"}, "is_buy": {"type": "boolean"}}, "required": ["coin", "sz", "price", "is_buy"]}, + ] + + async def execute(self, tool, params): + return { + "order": params, "status": "simulated", + "revenue": { + "platform": "Hyperliquid", + "type": "BUILDER CODE", + "model": "per-trade fee (up to 0.1% perps, 1% spot)", + "wallet_configured": self.hl_configured, + "builder_wallet": REF_HYPERLIQUID_WALLET if self.hl_configured else "NOT CONFIGURED", + "setup": "1. Fund wallet with 100+ USDC in perps 2. Set WP_REF_HYPERLIQUID_WALLET 3. Users approve builder address once", + "docs": "https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes.md", + }, + "note": "Revenue from every fill. No $10k volume needed. No manual claiming.", + } + + +class JupiterPlugin(WalletPressPlugin): + name = "jupiter" + jup_configured = bool(REF_JUPITER_WALLET) + description = f"{REVENUE_TAG} Solana swaps ({'WALLET READY' if jup_configured else 'NEEDS WALLET SETUP'}, 50bps)" + supported_chains = ["solana"] + + def tools(self): + wallet_status = f"wallet: {REF_JUPITER_WALLET[:8]}..." if self.jup_configured else "NEEDS SETUP — set WP_REF_JUPITER_WALLET" + return [ + {"name": "jupiter_quote", "description": f"{REVENUE_TAG} Quote. Revenue: {wallet_status}", "parameters": {"input_mint": {"type": "string"}, "output_mint": {"type": "string"}, "amount": {"type": "number"}, "slippage_bps": {"type": "integer", "default": 100}}, "required": ["input_mint", "output_mint", "amount"]}, + {"name": "jupiter_swap", "description": f"{REVENUE_TAG} Execute swap. Revenue: {wallet_status}", "parameters": {"wallet_id": {"type": "string"}, "input_mint": {"type": "string"}, "output_mint": {"type": "string"}, "amount": {"type": "number"}, "slippage_bps": {"type": "integer", "default": 100}}, "required": ["wallet_id", "input_mint", "output_mint", "amount"]}, + ] + + async def execute(self, tool, params): + base = {"revenue": {"platform": "Jupiter", "share": "50bps", "wallet_configured": self.jup_configured, "wallet": REF_JUPITER_WALLET if self.jup_configured else "NOT CONFIGURED", "setup": "Set WP_REF_JUPITER_WALLET to your Jupiter fee account", "docs": "https://jup.ag/referral"}} + if tool == "jupiter_quote": + async with httpx.AsyncClient() as c: + r = await c.get(f"https://quote-api.jup.ag/v6/quote?inputMint={params['input_mint']}&outputMint={params['output_mint']}&amount={params['amount']}&slippageBps={params.get('slippage_bps', 100)}") + return {**r.json(), **base} if r.status_code == 200 else {"error": f"Jupiter: {r.status_code}", **base} + if tool == "jupiter_swap": + return {"swap_prepared": True, **base, "note": "Revenue requires WP_REF_JUPITER_WALLET to be set"} + return {"error": f"Unknown: {tool}"} + + +class GMGNPlugin(WalletPressPlugin): + name = "gmgn" + description = f"{REVENUE_TAG} Solana memecoin trading (referral: {REF_GMGN}, 40%)" + supported_chains = ["solana"] + def tools(self): + return [{"name": "gmgn_buy", "description": f"{REVENUE_TAG} Buy token on GMGN (referral: {REF_GMGN})", "parameters": {"token": {"type": "string"}, "sol_amount": {"type": "number"}}, "required": ["token", "sol_amount"]}] + async def execute(self, tool, params): + return {"trade": params, "referral": REF_GMGN, "revenue": "40%", "mode": REVENUE_MODE, "goes_to": "Rug Munch Media LLC" if REVENUE_MODE == "freemium" else "you"} + + +class OdinBotPlugin(WalletPressPlugin): + name = "odinbot" + description = f"{REVENUE_TAG} Solana copy trading (referral: {REF_ODINBOT}, 40%)" + supported_chains = ["solana"] + def tools(self): + return [{"name": "odinbot_copy", "description": f"{REVENUE_TAG} Copy trade via OdinBot (referral: {REF_ODINBOT})", "parameters": {"target_wallet": {"type": "string"}, "max_sol": {"type": "number"}}, "required": ["target_wallet", "max_sol"]}] + async def execute(self, tool, params): + return {"copy_trade": params, "referral": REF_ODINBOT, "revenue": "40%", "mode": REVENUE_MODE} + + +class BananaGunPlugin(WalletPressPlugin): + name = "bananagun" + description = f"{REVENUE_TAG} EVM memecoin sniping (referral: {REF_BANANA}, 40%)" + supported_chains = ["evm"] + def tools(self): + return [{"name": "banana_buy", "description": f"{REVENUE_TAG} Buy via Banana Gun (referral: {REF_BANANA})", "parameters": {"token": {"type": "string"}, "eth_amount": {"type": "number"}}, "required": ["token", "eth_amount"]}] + async def execute(self, tool, params): + return {"trade": params, "referral": REF_BANANA, "revenue": "40%", "mode": REVENUE_MODE} + + +class AxiomPlugin(WalletPressPlugin): + name = "axiom" + description = f"{REVENUE_TAG} Solana trading (referral: {REF_AXIOM}, 30%)" + supported_chains = ["solana"] + def tools(self): + return [{"name": "axiom_trade", "description": f"{REVENUE_TAG} Trade via Axiom (referral: {REF_AXIOM})", "parameters": {"action": {"type": "string", "enum": ["buy", "sell"]}, "token": {"type": "string"}, "amount": {"type": "number"}}, "required": ["action", "token", "amount"]}] + async def execute(self, tool, params): + return {"trade": params, "referral": REF_AXIOM, "revenue": "30%", "mode": REVENUE_MODE} + + +class PadrePlugin(WalletPressPlugin): + name = "padre" + description = f"{REVENUE_TAG} Solana trading (referral: {REF_PADRE}, 35%)" + supported_chains = ["solana"] + def tools(self): + return [{"name": "padre_trade", "description": f"{REVENUE_TAG} Trade via Padre (referral: {REF_PADRE})", "parameters": {"action": {"type": "string", "enum": ["buy", "sell"]}, "token": {"type": "string"}, "amount": {"type": "number"}}, "required": ["action", "token", "amount"]}] + async def execute(self, tool, params): + return {"trade": params, "referral": REF_PADRE, "revenue": "35%", "mode": REVENUE_MODE} + + +class OneInchPlugin(WalletPressPlugin): + name = "1inch" + description = f"{REVENUE_TAG} DEX aggregation on 50+ EVM networks" + supported_chains = ["evm"] + def tools(self): + return [{"name": "inch_quote", "description": "Get swap quote", "parameters": {"src": {"type": "string"}, "dst": {"type": "string"}, "amount": {"type": "string"}}, "required": ["src", "dst", "amount"]}] + async def execute(self, tool, params): + if tool == "inch_quote": + r = await httpx.AsyncClient().get(f"https://api.1inch.dev/swap/v6.0/1/quote?src={params['src']}&dst={params['dst']}&amount={params['amount']}") + return r.json() if r.status_code == 200 else {"error": f"1inch: {r.status_code}"} + return {"error": "unknown"} + + +# ── Non-referral utility plugins ───────────────────────────────────────────── + +class PolymarketPlugin(WalletPressPlugin): + name = "polymarket" + pm_configured = bool(REF_POLYMARKET_BUILDER) + description = f"{REVENUE_TAG} Prediction markets on Polygon ({'BUILDER READY' if pm_configured else 'NEEDS SETUP'}, up to 1%/trade)" + supported_chains = ["evm"] + def tools(self): + bs = f"builder: {REF_POLYMARKET_BUILDER[:12]}..." if self.pm_configured else "NEEDS SETUP — set WP_REF_POLYMARKET_BUILDER" + return [ + {"name": "polymarket_place_order", "description": f"{REVENUE_TAG} Place order on Polymarket. Revenue: {bs}", "parameters": {"market": {"type": "string"}, "side": {"type": "string", "enum": ["buy", "sell"]}, "size": {"type": "number"}, "price": {"type": "number"}}, "required": ["market", "side", "size", "price"]}, + {"name": "polymarket_cancel_order", "description": "Cancel an order", "parameters": {"order_id": {"type": "string"}}, "required": ["order_id"]}, + ] + async def execute(self, tool, params): + return { + "order": params, "status": "simulated", + "revenue": { + "platform": "Polymarket", + "type": "BUILDER CODE", + "model": "up to 100bps taker / 50bps maker + 10% referral", + "configured": self.pm_configured, + "builder_code": REF_POLYMARKET_BUILDER if self.pm_configured else "NOT SET", + "setup": "Register at polymarket.com/settings, set WP_REF_POLYMARKET_BUILDER", + "docs": "https://docs.polymarket.com/builders/overview", + }, + } + + + +class MyriadPlugin(WalletPressPlugin): + name = "myriad" + my_configured = bool(REF_MYRIAD_BUILDER) + supported_chains = ["evm"] + description = f"{REVENUE_TAG} Myriad prediction markets ({'BUILDER READY' if my_configured else 'NEEDS SETUP'}, 33%+1%)" + def tools(self): + s = f"code: {REF_MYRIAD_BUILDER[:12]}..." if self.my_configured else "NEEDS SETUP" + return [{"name":"myriad_buy","description":f"Buy shares. Revenue: {s}","parameters":{"market":{"type":"string"},"outcome":{"type":"string"},"amount":{"type":"number"}},"required":["market","outcome","amount"]}] + async def execute(self,t,p): + return {"trade":p,"revenue":{"platform":"Myriad","type":"BUILDER+REFERRAL","model":"33% referral + ~1% builder","configured":self.my_configured,"code":REF_MYRIAD_BUILDER if self.my_configured else "NOT SET","setup":"Set WP_REF_MYRIAD_BUILDER after Myriad whitelist","docs":"https://docs.myriad.markets/builders/builder-revenue-sharing"}} + + +class AzuroPlugin(WalletPressPlugin): + name = "azuro" + az_configured = bool(REF_AZURO_AFFILIATE) + supported_chains = ["evm"] + description = f"{REVENUE_TAG} Azuro sports betting ({'AFFILIATE READY' if az_configured else 'NEEDS SETUP'}, GGR)" + def tools(self): + s = f"wallet: {REF_AZURO_AFFILIATE[:12]}..." if self.az_configured else "NEEDS SETUP" + return [{"name":"azuro_bet","description":f"Place bet. Revenue: {s}","parameters":{"market":{"type":"string"},"outcome":{"type":"string"},"amount":{"type":"number"}},"required":["market","outcome","amount"]}] + async def execute(self,t,p): + return {"bet":p,"revenue":{"platform":"Azuro","type":"AFFILIATE","model":"GGR share","configured":self.az_configured,"wallet":REF_AZURO_AFFILIATE if self.az_configured else "NOT SET","setup":"Set WP_REF_AZURO_AFFILIATE to any EVM wallet","docs":"https://docs.azuro.org/hub/apps/guides/affiliate-wallet"}} + + + + +class ThalesPlugin(WalletPressPlugin): + name = "thales" + th_configured = bool(REF_THALES_REFERRAL) + supported_chains = ["evm"] + description = f"{REVENUE_TAG} Parimutuel prediction markets ({'REF READY' if th_configured else 'NEEDS SETUP'}, 0.5% of volume)" + def tools(self): + s = f"ref: {REF_THALES_REFERRAL}" if self.th_configured else "NEEDS SETUP" + return [{"name":"thales_buy","description":f"Buy position. Revenue: {s}","parameters":{"market":{"type":"string"},"amount":{"type":"number"}},"required":["market","amount"]}] + async def execute(self,t,p): + return {"trade":p,"revenue":{"platform":"Thales","type":"REFERRAL LINK","model":"0.5% of trading volume","configured":self.th_configured,"ref_id":REF_THALES_REFERRAL if self.th_configured else "NOT SET","setup":"Set WP_REF_THALES_REFERRAL to your custom ID","signup":"https://thalesmarket.io/markets?referrerId=YOUR_ID","docs":"https://docs.thalesmarket.io"}} + + +class WinrPlugin(WalletPressPlugin): + name = "winr" + winr_configured = bool(REF_WINR_AFFILIATE) + supported_chains = ["solana"] + description = f"{REVENUE_TAG} Crypto trading/sports ({'AFFILIATE READY' if winr_configured else 'NEEDS SETUP'}, up to 80% rev share)" + def tools(self): + s = "affiliate: active" if self.winr_configured else "NEEDS SETUP" + return [{"name":"winr_bet","description":f"Place wager. Revenue: {s}","parameters":{"market":{"type":"string"},"amount":{"type":"number"}},"required":["market","amount"]}] + async def execute(self,t,p): + return {"bet":p,"revenue":{"platform":"WINR","type":"AFFILIATE PORTAL","model":"up to 80% revenue share","configured":self.winr_configured,"setup":"Register at winrpartners.com, set WP_REF_WINR_AFFILIATE","signup":"https://winrpartners.com"}} + + +class BaoziPlugin(WalletPressPlugin): + name = "baozi" + bz_configured = bool(REF_BAOZI_AFFILIATE) + supported_chains = ["solana"] + description = f"{REVENUE_TAG} Solana prediction markets ({'AFFILIATE READY' if bz_configured else 'NEEDS SETUP'}, token rev share)" + def tools(self): + s = "affiliate: active" if self.bz_configured else "NEEDS SETUP" + return [{"name":"baozi_bet","description":f"Place prediction. Revenue: {s}","parameters":{"market":{"type":"string"},"amount":{"type":"number"}},"required":["market","amount"]}] + async def execute(self,t,p): + return {"bet":p,"revenue":{"platform":"Baozi.bet","type":"ON-CHAIN AFFILIATE","model":"$BAOZI token rev share","configured":self.bz_configured,"setup":"Set WP_REF_BAOZI_AFFILIATE","signup":"https://baozi.bet"}} + + + +class FlashTradePlugin(WalletPressPlugin): + name = "flashtrade" + ft_configured = bool(REF_FLASHTRADE) + supported_chains = ["solana"] + description = f"{REVENUE_TAG} Solana trading terminal ({'REF READY' if ft_configured else 'NEEDS SETUP'}, 2% rebate)" + def tools(self): + s = "code: active" if self.ft_configured else "NEEDS SETUP" + return [{"name":"flashtrade_swap","description":f"Swap tokens. Revenue: {s}","parameters":{"token_in":{"type":"string"},"token_out":{"type":"string"},"amount":{"type":"number"}},"required":["token_in","token_out","amount"]}] + async def execute(self,t,p): + return {"trade":p,"revenue":{"platform":"FlashTrade","model":"2% rebate","configured":self.ft_configured,"setup":"Create code at flash.trade > Token > Utility","signup":"https://flash.trade"}} + +class LuloPlugin(WalletPressPlugin): + name = "lulo"; description = "Lend USDC on Solana (9% APY)" + supported_chains = ["solana"] + def tools(self): + return [{"name": "lulo_deposit", "description": "Deposit USDC", "parameters": {"amount": {"type": "number"}}, "required": ["amount"]}, {"name": "lulo_withdraw", "description": "Withdraw USDC", "parameters": {"amount": {"type": "number"}}, "required": ["amount"]}] + async def execute(self, tool, params): + return {"action": tool, "amount": params["amount"], "status": "simulated"} + +class TensorPlugin(WalletPressPlugin): + name = "tensor"; description = "NFT marketplace on Solana" + def tools(self): + return [{"name": "tensor_buy", "description": "Buy NFT", "parameters": {"mint": {"type": "string"}, "max_price": {"type": "number"}}, "required": ["mint"]}] + async def execute(self, tool, params): + return {"nft": params["mint"], "status": "simulated"} + +class PumpFunPlugin(WalletPressPlugin): + name = "pumpfun"; description = "Launch tokens on Pump.fun" + def tools(self): + return [{"name": "pumpfun_launch", "description": "Launch token", "parameters": {"name": {"type": "string"}, "symbol": {"type": "string"}, "supply": {"type": "integer"}}, "required": ["name", "symbol"]}] + async def execute(self, tool, params): + return {"token": params["name"], "symbol": params["symbol"], "status": "simulated", "note": "Need SOL for creation fee"} + +class CoinGeckoPlugin(WalletPressPlugin): + name = "coingecko"; description = "Free crypto price data" + def tools(self): + return [{"name": "price", "description": "Get price", "parameters": {"coin_id": {"type": "string"}, "currency": {"type": "string", "default": "usd"}}, "required": ["coin_id"]}] + async def execute(self, tool, params): + r = await httpx.AsyncClient().get(f"https://api.coingecko.com/api/v3/simple/price?ids={params['coin_id']}&vs_currencies={params.get('currency', 'usd')}") + return r.json() if r.status_code == 200 else {"error": f"CoinGecko: {r.status_code}"} + +class StakePlugin(WalletPressPlugin): + name = "staking"; description = "Stake on PoS chains" + def tools(self): + return [{"name": "stake", "description": "Stake tokens", "parameters": {"chain": {"type": "string"}, "amount": {"type": "number"}}, "required": ["chain", "amount"]}, {"name": "unstake", "description": "Unstake", "parameters": {"chain": {"type": "string"}, "amount": {"type": "number"}}, "required": ["chain", "amount"]}] + async def execute(self, tool, params): + return {"action": tool, "chain": params["chain"], "amount": params.get("amount", 0), "status": "simulated"} + +class AirdropPlugin(WalletPressPlugin): + name = "airdrop"; description = "Airdrop tokens to wallets" + def tools(self): + return [{"name": "airdrop_prepare", "description": "Prepare airdrop", "parameters": {"token": {"type": "string"}, "chain": {"type": "string"}, "recipients": {"type": "array", "items": {"type": "object", "properties": {"address": {"type": "string"}, "amount": {"type": "number"}}}}}, "required": ["token", "chain", "recipients"]}] + async def execute(self, tool, params): + return {"prepared": True, "recipients": len(params.get("recipients", [])), "note": "Execute via batch sweep"} + +# ── Register All ───────────────────────────────────────────────────────────── +for p in [HyperliquidPlugin, JupiterPlugin, GMGNPlugin, OdinBotPlugin, BananaGunPlugin, + AxiomPlugin, PadrePlugin, OneInchPlugin, PolymarketPlugin, MyriadPlugin, AzuroPlugin, ThalesPlugin, WinrPlugin, BaoziPlugin, FlashTradePlugin, LuloPlugin, + TensorPlugin, PumpFunPlugin, CoinGeckoPlugin, StakePlugin, AirdropPlugin]: + register_plugin(p()) + + + +class ZeroXPlugin(WalletPressPlugin): + """0x Swap API — affiliate fees on every swap across 150+ EVM sources.""" + name = "0x" + description = f"{REVENUE_TAG} EVM swap aggregator (150+ sources, affiliate fee 0-10%)" + supported_chains = ["evm"] + def tools(self): + return [ + {"name":"0x_quote","description":f"{REVENUE_TAG} Get swap quote with affiliate fee","parameters":{"sell_token":{"type":"string"},"buy_token":{"type":"string"},"sell_amount":{"type":"string"},"chain_id":{"type":"integer","default":1}}, "required":["sell_token","buy_token","sell_amount"]}, + {"name":"0x_swap","description":f"{REVENUE_TAG} Execute swap with affiliate fee","parameters":{"sell_token":{"type":"string"},"buy_token":{"type":"string"},"sell_amount":{"type":"string"},"chain_id":{"type":"integer","default":1}}, "required":["sell_token","buy_token","sell_amount"]}, + ] + async def execute(self, t, p): + return {"swap":p,"revenue":{"platform":"0x","type":"AFFILIATE FEE","model":"swapFeeBps 0-10%, sent to swapFeeRecipient wallet each swap","setup":"Set WP_0X_API_KEY + WP_0X_FEE_RECIPIENT + WP_0X_FEE_BPS","docs":"https://docs.0x.org/evm/0x-swap-api/guides/monetize-your-app-using-swap"}} + + + +register_plugin(ZeroXPlugin()) \ No newline at end of file diff --git a/backend/plugins/sdk.py b/backend/plugins/sdk.py new file mode 100644 index 0000000..b215175 --- /dev/null +++ b/backend/plugins/sdk.py @@ -0,0 +1,131 @@ +"""WalletPress Plugin SDK — Protocol plugins for the AI Wallet Agent. + +Extend the agent with any blockchain protocol. Plugins register MCP-style +tools that the agent can call. Patterns: + - DEX: swap_tokens, add_liquidity, remove_liquidity + - Lending: deposit, withdraw, borrow, repay + - NFT: mint, buy, sell, list + - Bridge: bridge_tokens, get_bridge_quotes + - Prediction Markets: bet, resolve, claim + +Usage: + from plugins.sdk import WalletPressPlugin, register_plugin + + class MyPlugin(WalletPressPlugin): + name = "myplugin" + description = "My custom protocol" + + def tools(self) -> list[dict]: + return [{"name": "my_tool", "description": "...", "parameters": {...}}] + + async def execute(self, tool: str, params: dict) -> dict: + return {"result": "ok"} + + register_plugin(MyPlugin()) +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger("wp.plugins") + + +class WalletPressPlugin: + """Base class for WalletPress protocol plugins. + + Each plugin provides MCP-style tools that the agent can call. + Plugins are auto-discovered and registered at startup. + """ + + name: str = "" + description: str = "" + version: str = "1.0.0" + # Chain families this plugin works with + supported_chains: list[str] = ["evm", "solana"] + + def tools(self) -> list[dict]: + """Return the tools this plugin provides. + + Each tool is a dict with: + name: str — tool name (snake_case) + description: str — what the tool does + parameters: dict — JSON Schema for parameters + required: list[str] — required parameter names + """ + return [] + + async def execute(self, tool: str, params: dict) -> dict: + """Execute a tool with the given parameters. + + Args: + tool: Tool name (must match a tool from tools()) + params: Parameters matching the tool's schema + + Returns: + dict with results + """ + raise NotImplementedError(f"Plugin {self.name} has no execute handler") + + def on_load(self): + """Called when the plugin is loaded. Use for setup.""" + pass + + def on_unload(self): + """Called when the plugin is unloaded. Use for cleanup.""" + pass + + +# ── Plugin Registry ────────────────────────────────────────────────────────── + +_plugins: dict[str, WalletPressPlugin] = {} + + +def register_plugin(plugin: WalletPressPlugin): + """Register a plugin with the agent.""" + if not plugin.name: + raise ValueError("Plugin must have a name") + _plugins[plugin.name] = plugin + plugin.on_load() + logger.info(f"Plugin loaded: {plugin.name} v{plugin.version}") + + +def get_plugin(name: str) -> WalletPressPlugin | None: + """Get a plugin by name.""" + return _plugins.get(name) + + +def list_plugins() -> list[dict]: + """List all registered plugins with their tools.""" + result = [] + for name, plugin in _plugins.items(): + result.append({ + "name": name, + "description": plugin.description, + "version": plugin.version, + "supported_chains": plugin.supported_chains, + "tools": [t["name"] for t in plugin.tools()], + }) + return result + + +def get_all_tools() -> list[dict]: + """Get ALL tools from ALL registered plugins.""" + tools = [] + for plugin in _plugins.values(): + tools.extend(plugin.tools()) + return tools + + +async def execute_plugin_tool(plugin_name: str, tool: str, params: dict) -> dict: + """Execute a tool on a specific plugin.""" + plugin = _plugins.get(plugin_name) + if not plugin: + return {"error": f"Plugin '{plugin_name}' not found"} + try: + return await plugin.execute(tool, params) + except NotImplementedError: + return {"error": f"Tool '{tool}' not implemented by plugin '{plugin_name}'"} + except Exception as e: + logger.error(f"Plugin {plugin_name} tool {tool} failed: {e}") + return {"error": f"Plugin error: {e}"} diff --git a/backend/requirements.lock b/backend/requirements.lock new file mode 100644 index 0000000..cc5d617 --- /dev/null +++ b/backend/requirements.lock @@ -0,0 +1,22 @@ +# WalletPress — pinned dependencies for reproducible builds +# Generated: 2026-06-30 +# Run `python3 -m pip freeze | grep -v walletpress > requirements.lock` to regenerate + +fastapi==0.128.8 +uvicorn==0.49.0 +pydantic==2.12.5 +pydantic-settings==2.14.2 +coincurve==21.0.0 +ecdsa==0.19.2 +base58==2.1.1 +PyNaCl==1.5.0 +cryptography==48.0.1 +httpx==0.28.1 +aiosqlite==0.20.0 +bip-utils==2.12.1 +qrcode==7.4.2 +argon2-cffi==25.1.0 +PyYAML==6.0.3 +alembic==1.13.3 +SQLAlchemy==2.0.36 +APScheduler==3.11.2 diff --git a/backend/routers/_persistent_store.py b/backend/routers/_persistent_store.py new file mode 100644 index 0000000..069978d --- /dev/null +++ b/backend/routers/_persistent_store.py @@ -0,0 +1,94 @@ +"""Persistent store for chain_vault router — webhooks, alerts, payments, etc. + +SQLite-backed key-value store. Each collection is a table with a JSON +blob column. Lazy-initialized on first access. Survives server restarts. + +Extracted from chain_vault.py in Phase 5 refactor to reduce god-file size. +""" + +from __future__ import annotations + +import json +import sqlite3 +import time +from pathlib import Path + +from core.config import cfg + + +class PersistentStore: + """SQLite-backed JSON-blob store for small collections. + + Used by chain_vault router for webhooks, alerts, payments. Each + collection is its own table; rows store JSON-serialized dicts. + + Thread-safe via check_same_thread=False + WAL mode. Use sparingly + — for high-throughput data, use a real DB. + """ + + _db: sqlite3.Connection | None = None + + @classmethod + def _get_db(cls) -> sqlite3.Connection: + if cls._db is None: + db_path: Path = cfg.data_dir / "chain_vault_store.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + cls._db = sqlite3.connect(str(db_path), check_same_thread=False) + cls._db.row_factory = sqlite3.Row + cls._db.execute("PRAGMA journal_mode=WAL") + cls._db.execute("PRAGMA busy_timeout=5000") + cls._db.executescript(""" + CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS alerts ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at REAL NOT NULL + ); + """) + return cls._db + + @classmethod + def all(cls, table: str) -> list[dict]: + db = cls._get_db() + rows = db.execute(f"SELECT data FROM {table} ORDER BY created_at DESC").fetchall() + return [json.loads(r["data"]) for r in rows] + + @classmethod + def get(cls, table: str, item_id: str) -> dict | None: + db = cls._get_db() + row = db.execute(f"SELECT data FROM {table} WHERE id = ?", (item_id,)).fetchone() + return json.loads(row["data"]) if row else None + + @classmethod + def put(cls, table: str, item: dict) -> None: + db = cls._get_db() + db.execute( + f"INSERT OR REPLACE INTO {table} (id, data, created_at) VALUES (?, ?, ?)", + (item["id"], json.dumps(item), item.get("created_at", time.time())), + ) + db.commit() + + @classmethod + def delete(cls, table: str, item_id: str) -> None: + db = cls._get_db() + db.execute(f"DELETE FROM {table} WHERE id = ?", (item_id,)) + db.commit() + + @classmethod + def reload_webhooks(cls) -> None: + """Reload webhooks from DB into the live deliverer on startup.""" + from core.webhooks import get_webhook_deliverer + deliverer = get_webhook_deliverer() + webhooks = cls.all("webhooks") + for wh in webhooks: + if wh.get("active", True): + deliverer.register(wh["id"], wh["url"], wh.get("secret", ""), wh.get("events", [])) \ No newline at end of file diff --git a/backend/routers/chain_vault.py b/backend/routers/chain_vault.py index a1c104d..5a14e54 100644 --- a/backend/routers/chain_vault.py +++ b/backend/routers/chain_vault.py @@ -26,88 +26,30 @@ from core.config import cfg from core.proof import PROOF_VERSION, get_proof from .tx_broadcaster import broadcast as broadcast_tx from core.vault import WalletEntry, get_vault +from ._persistent_store import PersistentStore as _PersistentStore from wallet_engine.chains import CHAINS, CHAIN_GROUPS, get_chains_for_tier from wallet_engine.generator import get_generator logger = logging.getLogger("wp.chain_vault") -class _PersistentStore: - """SQLite-backed store for webhooks, alerts, and payments. - - Survives server restarts. Each collection is a table with a JSON blob column. - Lazy-initialized on first access. - """ - - _db: sqlite3.Connection | None = None - - @classmethod - def _get_db(cls) -> sqlite3.Connection: - if cls._db is None: - db_path: Path = cfg.data_dir / "chain_vault_store.db" - db_path.parent.mkdir(parents=True, exist_ok=True) - cls._db = sqlite3.connect(str(db_path), check_same_thread=False) - cls._db.row_factory = sqlite3.Row - cls._db.execute("PRAGMA journal_mode=WAL") - cls._db.execute("PRAGMA busy_timeout=5000") - cls._db.executescript(""" - CREATE TABLE IF NOT EXISTS webhooks ( - id TEXT PRIMARY KEY, - data TEXT NOT NULL, - created_at REAL NOT NULL - ); - CREATE TABLE IF NOT EXISTS alerts ( - id TEXT PRIMARY KEY, - data TEXT NOT NULL, - created_at REAL NOT NULL - ); - CREATE TABLE IF NOT EXISTS payments ( - id TEXT PRIMARY KEY, - data TEXT NOT NULL, - created_at REAL NOT NULL - ); - """) - return cls._db - - @classmethod - def all(cls, table: str) -> list[dict]: - db = cls._get_db() - rows = db.execute(f"SELECT data FROM {table} ORDER BY created_at DESC").fetchall() - return [json.loads(r["data"]) for r in rows] - - @classmethod - def get(cls, table: str, item_id: str) -> dict | None: - db = cls._get_db() - row = db.execute(f"SELECT data FROM {table} WHERE id = ?", (item_id,)).fetchone() - return json.loads(row["data"]) if row else None - - @classmethod - def put(cls, table: str, item: dict) -> None: - db = cls._get_db() - db.execute( - f"INSERT OR REPLACE INTO {table} (id, data, created_at) VALUES (?, ?, ?)", - (item["id"], json.dumps(item), item.get("created_at", time.time())), - ) - db.commit() - - @classmethod - def delete(cls, table: str, item_id: str) -> None: - db = cls._get_db() - db.execute(f"DELETE FROM {table} WHERE id = ?", (item_id,)) - db.commit() - - @classmethod - def reload_webhooks(cls) -> None: - """Reload webhooks from DB into the live deliverer on startup.""" - from core.webhooks import get_webhook_deliverer - deliverer = get_webhook_deliverer() - webhooks = cls.all("webhooks") - for wh in webhooks: - if wh.get("active", True): - deliverer.register(wh["id"], wh["url"], wh.get("secret", ""), wh.get("events", [])) - router = APIRouter(prefix="/api/v1/chain-vault", tags=["Chain Vault"]) + +def _require_totp(request: Request) -> None: + """Require valid TOTP code for admin operations. + + Shared with wallet_admin.py — kept here so chain_vault.py can use + it independently. If you change the logic, update both. + """ + from core.totp import get_totp_manager + mgr = get_totp_manager() + if not mgr.is_enabled(): + return + code = request.headers.get("X-TOTP-Code", "") + if not mgr.verify(code): + raise HTTPException(status_code=401, detail="TOTP code required or invalid. Set X-TOTP-Code header.") + # ── Models ─────────────────────────────────────────────────────── @@ -227,17 +169,6 @@ class TxBroadcastRequest(BaseModel): signed_tx: str = Field(...) -class APIKeyCreateRequest(BaseModel): - label: str = Field(...) - scopes: list[str] = Field(default=["vault.read"]) - - -class AlertCreateRequest(BaseModel): - wallet_id: str = Field(...) - alert_type: str = Field(default="balance_change") - threshold_usd: float = Field(default=0.0) - channel: str = Field(default="webhook") - config: dict = Field(default_factory=dict) class AlertDeleteRequest(BaseModel): @@ -1537,715 +1468,7 @@ async def tx_broadcast(req: TxBroadcastRequest, request: Request): except Exception as e: return {"broadcast": False, "error": str(e), "chain": req.chain} - -# ═══════════════════════════════════════════════════════════════════ -# API KEYS -# ═══════════════════════════════════════════════════════════════════ - - -@router.post("/api-keys") -async def create_api_key(req: APIKeyCreateRequest, request: Request): - """Create a new API key for programmatic access.""" - _audit("api_key.create", request, detail={"label": req.label, "scopes": req.scopes}) - ks = get_key_store() - key_id, raw_key = ks.create(req.label, req.scopes) - return { - "api_key_id": key_id, - "api_key": raw_key, - "label": req.label, - "scopes": req.scopes, - "warning": "Save this key now. It will not be shown again.", - } - - -@router.get("/api-keys") -async def list_api_keys(request: Request): - """List all API keys (masked, keys not shown).""" - ks = get_key_store() - return {"api_keys": ks.list(), "note": "Full keys only shown once at creation."} - - -@router.post("/api-keys/revoke") -async def revoke_api_key(req: APIKeyCreateRequest, request: Request): - """Revoke an API key immediately.""" - _audit("api_key.revoke", request, resource=req.label) - ks = get_key_store() - ks.revoke(req.label) - return {"revoked": True, "key_id": req.label} - - -# ═══════════════════════════════════════════════════════════════════ -# ALERTS -# ═══════════════════════════════════════════════════════════════════ - - -@router.post("/alerts") -async def create_alert(req: AlertCreateRequest, request: Request): - """Create a wallet alert for balance changes or on-chain events.""" - _audit("alert.create", request, detail={"type": req.alert_type, "wallet": req.wallet_id}) - alert_id = f"alert_{secrets.token_hex(4)}" - alert = { - "id": alert_id, - "wallet_id": req.wallet_id, - "type": req.alert_type, - "threshold_usd": req.threshold_usd, - "channel": req.channel, - "config": req.config, - "created_at": time.time(), - "active": True, - } - _PersistentStore.put("alerts", alert) - return {"alert_id": alert_id, **alert} - - -@router.get("/alerts") -async def list_alerts(): - """List all configured alerts.""" - alerts = _PersistentStore.all("alerts") - return {"alerts": alerts, "total": len(alerts)} - - -@router.post("/alerts/delete") -async def delete_alert(req: AlertDeleteRequest): - """Delete an alert.""" - _PersistentStore.delete("alerts", req.alert_id) - return {"deleted": True, "alert_id": req.alert_id} - - -# ═══════════════════════════════════════════════════════════════════ -# WEBHOOKS -# ═══════════════════════════════════════════════════════════════════ - - -@router.post("/webhooks") -async def create_webhook(req: WebhookCreateRequest, request: Request): - """Create a webhook for wallet events. - - Events: wallet.generated, payment.received, gate.accessed, - alert.triggered, wallet.rotated, wallet.swept - """ - _audit("webhook.create", request, detail={"events": req.events}) - webhook_id = f"wh_{secrets.token_hex(4)}" - wh_secret = req.secret or secrets.token_hex(16) - wh = { - "id": webhook_id, - "url": req.url, - "events": req.events, - "secret": wh_secret, - "created_at": time.time(), - "active": True, - } - _PersistentStore.put("webhooks", wh) - - # Register with webhook deliverer for live delivery - from core.webhooks import get_webhook_deliverer - get_webhook_deliverer().register(webhook_id, req.url, wh_secret, req.events) - - return {"webhook_id": webhook_id, **wh} - - -@router.get("/webhooks") -async def list_webhooks(): - """List all configured webhooks.""" - webhooks = _PersistentStore.all("webhooks") - return {"webhooks": webhooks, "total": len(webhooks)} - - -@router.delete("/webhooks/{webhook_id}") -async def delete_webhook(webhook_id: str): - """Delete a webhook.""" - _PersistentStore.delete("webhooks", webhook_id) - from core.webhooks import get_webhook_deliverer - get_webhook_deliverer().unregister(webhook_id) - return {"deleted": True, "webhook_id": webhook_id} - - -# ── Webhook Delivery Log ────────────────────────────────── - - -@router.get("/webhooks/deliveries") -async def webhook_deliveries(limit: int = 50): - """View recent webhook delivery attempts with status and response codes.""" - from core.webhooks import delivery_log - return {"deliveries": list(reversed(delivery_log))[:limit], "total": len(delivery_log)} - - -@router.post("/webhooks/{webhook_id}/test") -async def test_webhook(webhook_id: str): - """Send a test event to verify webhook endpoint is working.""" - webhooks = _PersistentStore.all("webhooks") - for wh in webhooks: - if wh.get("id") == webhook_id: - from core.webhooks import get_webhook_deliverer - import asyncio - asyncio.ensure_future(get_webhook_deliverer().emit("webhook.test", { - "test": True, - "webhook_id": webhook_id, - "timestamp": time.time(), - })) - return {"sent": True, "note": "Test event sent. Check /webhooks/deliveries for status."} - raise HTTPException(status_code=404, detail="Webhook not found") - - -@router.post("/webhooks/{webhook_id}/retry") -async def retry_webhook(webhook_id: str): - """Retry the last failed delivery for a webhook.""" - webhooks = _PersistentStore.all("webhooks") - for wh in webhooks: - if wh.get("id") == webhook_id: - from core.webhooks import get_webhook_deliverer - import asyncio - asyncio.ensure_future(get_webhook_deliverer().emit("webhook.retry", { - "retry": True, - "webhook_id": webhook_id, - })) - return {"retried": True, "note": "Retry sent. Check /webhooks/deliveries for status."} - raise HTTPException(status_code=404, detail="Webhook not found") - - -# ═══════════════════════════════════════════════════════════════════ -# AUDIT TRAIL -# ═══════════════════════════════════════════════════════════════════ - - -@router.get("/audit-trail") -async def audit_trail(limit: int = 100, action: str = ""): - """Get the immutable audit trail for all wallet operations. - - Trust: The audit log is append-only. Every wallet generation, - key export, rotation, and deletion is logged. Logs cannot be - modified — only new entries can be appended. - """ - audit = get_audit() - entries = audit.query(limit=limit, action=action) - return { - "audit_entries": entries, - "total": len(entries), - "note": "Audit log is append-only. Entries cannot be modified or deleted.", - } - - -# ═══════════════════════════════════════════════════════════════════ -# BULK OPERATIONS -# ═══════════════════════════════════════════════════════════════════ - - -@router.post("/bulk/filter") -async def bulk_filter(req: BulkFilterRequest, request: Request): - """Filter wallets by custom criteria.""" - vault = get_vault() - all_wallets = vault.list(limit=10000) - results = all_wallets - - for key, value in req.filters.items(): - if key == "chain": - results = [w for w in results if w.chain == value] - elif key == "label": - results = [w for w in results if value.lower() in w.label.lower()] - elif key == "tag": - results = [w for w in results if value in w.tags] - elif key == "has_balance": - results = [w for w in results if (w.balance_usd > 0) == value] - - return { - "filtered": len(results), - "filters_applied": req.filters, - "wallets": [{"id": w.id, "chain": w.chain, "address": w.address, "label": w.label, - "balance_usd": w.balance_usd} for w in results], - } - - -@router.post("/bulk/delete") -async def bulk_delete(req: BulkDeleteRequest, request: Request): - """Delete multiple wallets at once.""" - _audit("bulk.delete", request, detail={"count": len(req.ids)}) - vault = get_vault() - deleted = [] - for wid in req.ids: - if vault.delete(wid): - deleted.append(wid) - return { - "deleted": len(deleted), - "total_requested": len(req.ids), - "deleted_ids": deleted, - } - - -@router.post("/bulk/export") -async def bulk_export(req: BulkExportRequest, request: Request): - """Export multiple wallets in various formats.""" - vault = get_vault() - wallets = [] - if req.ids: - for wid in req.ids: - w = vault.get(wid) - if w: - wallets.append(w) - else: - wallets = vault.list(limit=10000) - - if req.format == "csv": - import csv as _csv - import io as _io - buf = _io.StringIO() - writer = _csv.writer(buf) - writer.writerow(["chain", "address", "label", "created_at"]) - for w in wallets: - writer.writerow([w.chain, w.address, w.label, w.created_at]) - data = buf.getvalue() - elif req.format == "env": - lines = ["# WalletPress Export"] - for w in wallets: - lines.append(f"WALLET_{w.chain.upper()}_ADDRESS={w.address}") - data = "\n".join(lines) - else: - data = json.dumps([{"chain": w.chain, "address": w.address, "label": w.label, - "created_at": w.created_at} for w in wallets], indent=2) - - return { - "format": req.format, - "count": len(wallets), - "data": data, - } - - -# ═══════════════════════════════════════════════════════════════════ -# EXPORT (STANDARD) -# ═══════════════════════════════════════════════════════════════════ - - -@router.post("/export") -async def export_wallets(req: ExportRequest, request: Request): - """Export wallet data in multiple formats. - - Formats: json (full data), csv (addresses), env (KEY=VALUE pairs). - Private keys are NEVER included in exports. - """ - return await bulk_export(BulkExportRequest(format=req.format, ids=req.ids), request) - - -# ═══════════════════════════════════════════════════════════════════ -# PAYMENT ROUTER (for crypto payment processing) -# ═══════════════════════════════════════════════════════════════════ - -payment_router = APIRouter(prefix="/api/v1/chain-vault", tags=["Payments"]) - - -class PaymentIntentRequest(BaseModel): - wallet_id: str = Field(...) - amount_usd: float = Field(...) - chain: str = Field(default="solana") - token: str = Field(default="USDC") - description: str = Field(default="") - - -class PaymentVerifyRequest(BaseModel): - payment_id: str = Field(...) - tx_hash: str = Field(default="") - chain: str = Field(default="solana") - - -@payment_router.post("/payments/create") -async def create_payment_intent(req: PaymentIntentRequest, request: Request): - """Create a payment intent for crypto payment collection. - - Returns the wallet address and amount for the user to send funds. - """ - _audit("payment.create", request, detail={"amount_usd": req.amount_usd, "chain": req.chain}) - vault = get_vault() - wallet = vault.get(req.wallet_id) - if not wallet: - raise HTTPException(status_code=404, detail="Wallet not found") - - payment_id = f"pay_{secrets.token_hex(8)}" - payment = { - "id": payment_id, - "payment_id": payment_id, - "wallet_id": req.wallet_id, - "address": wallet.address, - "chain": req.chain, - "amount_usd": req.amount_usd, - "token": req.token, - "description": req.description, - "status": "pending", - "created_at": time.time(), - } - _PersistentStore.put("payments", payment) - return { - "payment": payment, - "qr_data": f"{req.chain}:{wallet.address}?amount={req.amount_usd}", - } - - -@payment_router.post("/payments/verify") -async def verify_payment(req: PaymentVerifyRequest, request: Request): - """Verify a crypto payment has been confirmed on-chain. - - Checks the transaction status using available RPC endpoints. - Without RPC configured, returns the recorded payment status. - """ - payment = _PersistentStore.get("payments", req.payment_id) - if not payment: - raise HTTPException(status_code=404, detail="Payment not found") - payment["status"] = "confirmed" if req.tx_hash else "pending" - payment["tx_hash"] = req.tx_hash or payment.get("tx_hash", "") - payment["verified_at"] = time.time() - _PersistentStore.put("payments", payment) - return {"payment": payment, "verified": bool(req.tx_hash)} - - -@payment_router.get("/payments/list") -async def list_payments(): - """List all payment intents.""" - payments = _PersistentStore.all("payments") - return {"payments": payments, "total": len(payments)} - - -@payment_router.get("/payments/{payment_id}") -async def get_payment(payment_id: str): - """Get payment details by ID.""" - payment = _PersistentStore.get("payments", payment_id) - if not payment: - raise HTTPException(status_code=404, detail="Payment not found") - return {"payment": payment} - - -# ═══════════════════════════════════════════════════════════════════ -# TOTP 2FA -# ═══════════════════════════════════════════════════════════════════ - - -def _require_totp(request: Request): - """Require valid TOTP code for admin operations.""" - from core.totp import get_totp_manager - mgr = get_totp_manager() - if not mgr.is_enabled(): - return - code = request.headers.get("X-TOTP-Code", "") - if not mgr.verify(code): - raise HTTPException(status_code=401, detail="TOTP code required or invalid. Set X-TOTP-Code header.") - - -@router.post("/admin/2fa/setup") -async def setup_2fa(request: Request): - """Generate a new TOTP secret for 2FA. - - Returns a QR URI compatible with Google Authenticator, Authy, - 1Password, Bitwarden, and any TOTP-compliant app. - - Call POST /admin/2fa/verify with the code from your authenticator - app to confirm setup, then call POST /admin/2fa/enable to activate. - """ - from core.totp import get_totp_manager - mgr = get_totp_manager() - result = mgr.setup() - _audit("2fa.setup", request) - return result - - -@router.post("/admin/2fa/verify-setup") -async def verify_2fa_setup(code: str, request: Request): - """Verify a TOTP code to confirm 2FA setup is working.""" - from core.totp import get_totp_manager - mgr = get_totp_manager() - if mgr.verify(code): - mgr.enable() - _audit("2fa.enable", request) - return {"enabled": True, "message": "2FA is now active for all admin operations."} - return {"enabled": False, "error": "Invalid TOTP code. Check your authenticator app."} - - -@router.post("/admin/2fa/disable") -async def disable_2fa(request: Request): - """Disable 2FA.""" - _require_totp(request) - from core.totp import get_totp_manager - mgr = get_totp_manager() - mgr.disable() - _audit("2fa.disable", request) - return {"enabled": False} - - -@router.get("/admin/2fa/status") -async def status_2fa(): - """Check whether 2FA is enabled.""" - from core.totp import get_totp_manager - mgr = get_totp_manager() - return {"enabled": mgr.is_enabled()} - - -# ═══════════════════════════════════════════════════════════════════ -# CONFIGURATION (for WP plugin to read) -# ═══════════════════════════════════════════════════════════════════ - - -@router.get("/config") -async def get_config(): - """Get WalletPress backend configuration and capabilities.""" - return { - "version": cfg.version, - "features": { - "client_side_generation": True, - "server_side_generation": True, - "encrypted_vault": bool(cfg.vault_password), - "rate_limiting": cfg.rate_limit_per_minute > 0, - "audit_logging": True, - "api_key_auth": True, - "telemetry": False, - "open_source": True, - }, - "standards": ["BIP39", "BIP32", "BIP44", "BIP49", "BIP84"], - "encryption": "AES-256-GCM + Argon2id" if cfg.vault_password else "plaintext", - "max_batch_size": cfg.max_wallets_per_request, - "proof_of_generation": True, - } - - -# ═══════════════════════════════════════════════════════════════════ -# PROOF OF GENERATION (Immutable Wallet Attestations) -# ═══════════════════════════════════════════════════════════════════ - - -@router.post("/proof/commit") -async def proof_commit(request: Request, chain: str = "local"): - """Manually commit all pending attestations to a Merkle root. - - Optionally commit to Arweave or Ethereum for permanent on-chain storage. - - This is the Big Idea. Every wallet gets a cryptographic birth - certificate. The Merkle root is committed and can be sealed - on a blockchain for permanent, immutable timestamping. - - Chains: - local — store in SQLite (instant, free) - arweave — commit to Arweave permaweb (~$0.000001, set WP_POF_ARWEAVE_KEY_PATH) - ethereum — commit to Ethereum mainnet (~$5-50 gas, set WP_POF_ETH_RPC + WP_POF_ETH_PRIVATE_KEY) - """ - proof = get_proof() - root_hash, count = proof.compute_merkle_root() - if not root_hash: - return {"committed": False, "reason": "No pending attestations"} - - effective_chain = "local" - if chain in ("arweave", "ethereum"): - effective_chain = chain - - result = proof.commit(root_hash, count, commitment_chain=effective_chain) - _audit("proof.commit", request, detail={"root_hash": root_hash[:16], "count": count, "chain": effective_chain}) - - return { - "committed": True, - "root_hash": root_hash, - "attestation_count": count, - "chain": effective_chain, - "commitment_tx": result.get("commitment_tx", ""), - "verification_url": result.get("verification_url", ""), - "merkle_proofs_saved": True, - "message": f"Merkle root committed to {effective_chain}. {count} attestations sealed." if effective_chain == "local" - else f"Merkle root committed to {effective_chain}. TX: {result.get('commitment_tx', 'pending')}", - } - - -@router.get("/proof/provenance/{wallet_id}") -async def proof_provenance(wallet_id: str): - """Get the complete cryptographic provenance for a wallet. - - Returns the full Merkle proof path so anyone can independently - verify that this wallet was part of a committed root. - - This is the wallet's complete BIRTH CERTIFICATE + PROOF OF INCLUSION. - """ - vault = get_vault() - wallet = vault.get(wallet_id) - if not wallet: - raise HTTPException(status_code=404, detail="Wallet not found") - proof = get_proof() - result = proof.get_proof_by_wallet(wallet_id) - if not result.get("exists"): - return {"wallet_id": wallet_id, "attested": False, "message": "No attestation found"} - return { - "wallet_id": wallet_id, - "address": wallet.address, - "chain": wallet.chain, - "attested": True, - "provenance": { - "created_at": result["created_at"], - "code_version": result["code_version"], - "leaf_hash": result["leaf_hash"], - "merkle_root": result["root_hash"], - "merkle_proof_verified": result["merkle_proof_verified"], - "commitment": result["root"], - }, - "verification_instructions": [ - "To verify: reconstruct the Merkle root using the leaf hash and proof path", - "1. Start with the leaf hash", - "2. For each step in proof_path, hash leaf + sibling (or sibling + leaf) based on is_left", - "3. Compare the result with the committed root_hash", - "4. Check the root was committed to the chain: local/arweave/ethereum", - ], - } - - -@router.get("/proof/verify/{wallet_id}") -async def proof_verify(wallet_id: str, include_key: bool = False): - """Verify a wallet's Proof of Generation attestation. - - Returns the full provenance of the wallet including: - - When it was created - - Which code version generated it - - The public key hash (proves key hasn't changed) - - The Merkle root it was committed under - - Whether the attestation is still valid - - This is the wallet's BIRTH CERTIFICATE. - """ - vault = get_vault() - wallet = vault.get(wallet_id) - if not wallet: - raise HTTPException(status_code=404, detail="Wallet not found") - - proof = get_proof() - result = proof.verify(wallet_id, wallet.address, wallet.public_key) - - if include_key: - result["public_key"] = wallet.public_key - - return { - "wallet_id": wallet_id, - "address": wallet.address, - "chain": wallet.chain, - "verification": result, - "trust_notes": [ - "This attestation proves the wallet existed at a specific time", - f"Code version: {PROOF_VERSION}", - "Public key hash is a one-way function — we don't store the raw key", - "Merkle root can be cross-referenced on-chain for immutable proof", - ], - } - - -@router.get("/proof/roots") -async def proof_roots(limit: int = 10): - """List recent Merkle roots committed for wallet attestations.""" - proof = get_proof() - roots = proof.get_roots(limit) - stats = proof.stats() - return { - "total_attestations": stats["total_attestations"], - "total_roots": stats["merkle_roots"], - "roots": roots, - } - - -@router.get("/proof/stats") -async def proof_stats(): - """Get Proof of Generation statistics.""" - proof = get_proof() - return { - "service": "walletpress-proof-of-generation", - "version": PROOF_VERSION, - "stats": proof.stats(), - "commitment_options": [ - {"chain": "local", "description": "SQLite (always, free)", "status": "active"}, - {"chain": "arweave", "description": "Arweave permaweb (~$0.000001/write)", "status": "active"}, - {"chain": "ethereum", "description": "Ethereum mainnet (~$5-50 gas)", "status": "active"}, - ], - "verification_endpoint": "/api/v1/proof/verify/{wallet_id}", - } - - -# ═══════════════════════════════════════════════════════════════════ -# PAPER WALLET + BIRTH CERTIFICATE PDF -# ═══════════════════════════════════════════════════════════════════ - - -@router.get("/paper-wallet/{wallet_id}/pdf") -async def paper_wallet_pdf(wallet_id: str, request: Request): - """Generate a printable paper wallet PDF. - - Returns a PDF with: - - Wallet address + QR code - - Private key (requires admin scope) - - Public key - - Derivation path - - Proof of Generation hash - - Security instructions - - Print on durable paper. Store in a safe. Never share digitally. - """ - vault = get_vault() - wallet = vault.get(wallet_id) - if not wallet: - raise HTTPException(status_code=404, detail="Wallet not found") - - auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") - include_key = False - if auth: - from core.auth import get_key_store - ks = get_key_store() - k = ks.verify(auth) - include_key = k and ("wallet.admin" in k.scopes or "admin" in k.scopes) - - private_key = "" - if include_key and wallet.encrypted_key: - if wallet.encrypted: - from wallet_engine.generator import get_generator as get_gen - private_key = get_gen().decrypt_key(wallet.encrypted_key) - else: - private_key = wallet.encrypted_key - - from core.pdf import generate_paper_wallet - pdf_bytes = generate_paper_wallet( - address=wallet.address, - chain=wallet.chain, - private_key=private_key, - public_key=wallet.public_key, - derivation_path=wallet.derivation_path, - created_at=wallet.created_at, - label=wallet.label, - ) - - from fastapi.responses import Response as FastResponse - return FastResponse( - content=pdf_bytes or b"No PDF generator available. Install reportlab.", - media_type="application/pdf" if pdf_bytes else "text/plain", - headers={"Content-Disposition": f'attachment; filename="wallet_{wallet_id[:8]}.pdf"'}, - ) - - -@router.get("/wallet/{wallet_id}/birth-certificate") -async def wallet_birth_certificate(wallet_id: str): - """Generate a Wallet Birth Certificate — printable provenance document. - - This is unique to WalletPress. It cryptographically proves when and - how the wallet was created, with a Merkle attestation linking this - document to the immutable audit trail. - - Print it. Store it with your will. Use it for compliance. - """ - vault = get_vault() - wallet = vault.get(wallet_id) - if not wallet: - raise HTTPException(status_code=404, detail="Wallet not found") - - proof = get_proof() - attest = proof.get_attestation(wallet_id) - - from core.pdf import generate_birth_certificate - pdf_bytes = generate_birth_certificate( - wallet_id=wallet_id, - address=wallet.address, - chain=wallet.chain, - public_key=wallet.public_key, - created_at=wallet.created_at, - proof_leaf=attest.get("leaf_hash", "") if attest else "", - root_hash=attest.get("root_hash", "") if attest else "", - committed=attest.get("committed", False) if attest else False, - ) - - from fastapi.responses import Response as FastResponse - return FastResponse( - content=pdf_bytes or b"No PDF generator available. Install reportlab.", - media_type="application/pdf" if pdf_bytes else "text/plain", - headers={"Content-Disposition": f'attachment; filename="birth_certificate_{wallet_id[:8]}.pdf"'}, - ) +# ───────────────────────────────────────────────────────────────────── +# Admin endpoints (api-keys, alerts, webhooks, audit, bulk, 2FA, proof) +# were extracted to routers/wallet_admin.py in Phase 5 refactor. +# ───────────────────────────────────────────────────────────────────── diff --git a/backend/routers/wallet_admin.py b/backend/routers/wallet_admin.py new file mode 100644 index 0000000..cfb2045 --- /dev/null +++ b/backend/routers/wallet_admin.py @@ -0,0 +1,786 @@ +"""Wallet Admin Router — API keys, alerts, webhooks, audit, bulk ops, 2FA, proof. + +Extracted from chain_vault.py in Phase 5 refactor. These endpoints are +administrative (not directly wallet CRUD) — most deal with cross-cutting +concerns like API key management, alerting, webhooks, and proof generation. + +Auth: All endpoints require API key with at least 'operator' role. +""" + +from __future__ import annotations + +import json +import logging +import os +import secrets +import time +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from core.auth import get_key_store +from core.audit import get_audit +from core.config import cfg +from core.proof import PROOF_VERSION, get_proof +from core.vault import WalletEntry, get_vault +from ._persistent_store import PersistentStore as _PersistentStore +from wallet_engine.generator import get_generator + +logger = logging.getLogger("wp.admin") + + +class APIKeyCreateRequest(BaseModel): + label: str = Field(...) + scopes: list[str] = Field(default=["vault.read"]) + + +class AlertCreateRequest(BaseModel): + wallet_id: str = Field(...) + alert_type: str = Field(default="balance_change") + threshold_usd: float = Field(default=0.0) + channel: str = Field(default="webhook") + config: dict = Field(default_factory=dict) + + +class WebhookCreateRequest(BaseModel): + url: str = Field(...) + events: list[str] = Field(default_factory=lambda: ["wallet.generated"]) + secret: str = Field(default="") + active: bool = Field(default=True) + + +class BulkFilterRequest(BaseModel): + chains: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + label_contains: str = Field(default="") + older_than_days: int = Field(default=0, ge=0) + filters: dict = Field(default_factory=dict) # legacy alias for {chains, tags, ...} + + +class BulkDeleteRequest(BaseModel): + wallet_ids: list[str] = Field(...) + + +# Use a fresh router for admin endpoints — mounted under /api/v1/chain-vault +# so the URL paths are unchanged for existing API consumers. +router = APIRouter(prefix="/api/v1/chain-vault", tags=["Chain Vault Admin"]) + + +def _audit(action: str, request: Request, resource: str = "", detail: dict | None = None) -> None: + """Log an admin action to the audit trail.""" + audit = get_audit() + audit.log( + action, + actor=request.headers.get("X-API-Key", "")[:16], + resource=resource, + detail=detail or {}, + ) + + +@router.post("/api-keys") +async def create_api_key(req: APIKeyCreateRequest, request: Request): + """Create a new API key for programmatic access.""" + _audit("api_key.create", request, detail={"label": req.label, "scopes": req.scopes}) + ks = get_key_store() + key_id, raw_key = ks.create(req.label, req.scopes) + return { + "api_key_id": key_id, + "api_key": raw_key, + "label": req.label, + "scopes": req.scopes, + "warning": "Save this key now. It will not be shown again.", + } + + +@router.get("/api-keys") +async def list_api_keys(request: Request): + """List all API keys (masked, keys not shown).""" + ks = get_key_store() + return {"api_keys": ks.list(), "note": "Full keys only shown once at creation."} + + +@router.post("/api-keys/revoke") +async def revoke_api_key(req: APIKeyCreateRequest, request: Request): + """Revoke an API key immediately.""" + _audit("api_key.revoke", request, resource=req.label) + ks = get_key_store() + ks.revoke(req.label) + return {"revoked": True, "key_id": req.label} + + +# ═══════════════════════════════════════════════════════════════════ +# ALERTS +# ═══════════════════════════════════════════════════════════════════ + + +@router.post("/alerts") +async def create_alert(req: AlertCreateRequest, request: Request): + """Create a wallet alert for balance changes or on-chain events.""" + _audit("alert.create", request, detail={"type": req.alert_type, "wallet": req.wallet_id}) + alert_id = f"alert_{secrets.token_hex(4)}" + alert = { + "id": alert_id, + "wallet_id": req.wallet_id, + "type": req.alert_type, + "threshold_usd": req.threshold_usd, + "channel": req.channel, + "config": req.config, + "created_at": time.time(), + "active": True, + } + _PersistentStore.put("alerts", alert) + return {"alert_id": alert_id, **alert} + + +@router.get("/alerts") +async def list_alerts(): + """List all configured alerts.""" + alerts = _PersistentStore.all("alerts") + return {"alerts": alerts, "total": len(alerts)} + + +@router.post("/alerts/delete") +async def delete_alert(req: AlertDeleteRequest): + """Delete an alert.""" + _PersistentStore.delete("alerts", req.alert_id) + return {"deleted": True, "alert_id": req.alert_id} + + +# ═══════════════════════════════════════════════════════════════════ +# WEBHOOKS +# ═══════════════════════════════════════════════════════════════════ + + +@router.post("/webhooks") +async def create_webhook(req: WebhookCreateRequest, request: Request): + """Create a webhook for wallet events. + + Events: wallet.generated, payment.received, gate.accessed, + alert.triggered, wallet.rotated, wallet.swept + """ + _audit("webhook.create", request, detail={"events": req.events}) + webhook_id = f"wh_{secrets.token_hex(4)}" + wh_secret = req.secret or secrets.token_hex(16) + wh = { + "id": webhook_id, + "url": req.url, + "events": req.events, + "secret": wh_secret, + "created_at": time.time(), + "active": True, + } + _PersistentStore.put("webhooks", wh) + + # Register with webhook deliverer for live delivery + from core.webhooks import get_webhook_deliverer + get_webhook_deliverer().register(webhook_id, req.url, wh_secret, req.events) + + return {"webhook_id": webhook_id, **wh} + + +@router.get("/webhooks") +async def list_webhooks(): + """List all configured webhooks.""" + webhooks = _PersistentStore.all("webhooks") + return {"webhooks": webhooks, "total": len(webhooks)} + + +@router.delete("/webhooks/{webhook_id}") +async def delete_webhook(webhook_id: str): + """Delete a webhook.""" + _PersistentStore.delete("webhooks", webhook_id) + from core.webhooks import get_webhook_deliverer + get_webhook_deliverer().unregister(webhook_id) + return {"deleted": True, "webhook_id": webhook_id} + + +# ── Webhook Delivery Log ────────────────────────────────── + + +@router.get("/webhooks/deliveries") +async def webhook_deliveries(limit: int = 50): + """View recent webhook delivery attempts with status and response codes.""" + from core.webhooks import delivery_log + return {"deliveries": list(reversed(delivery_log))[:limit], "total": len(delivery_log)} + + +@router.post("/webhooks/{webhook_id}/test") +async def test_webhook(webhook_id: str): + """Send a test event to verify webhook endpoint is working.""" + webhooks = _PersistentStore.all("webhooks") + for wh in webhooks: + if wh.get("id") == webhook_id: + from core.webhooks import get_webhook_deliverer + import asyncio + asyncio.ensure_future(get_webhook_deliverer().emit("webhook.test", { + "test": True, + "webhook_id": webhook_id, + "timestamp": time.time(), + })) + return {"sent": True, "note": "Test event sent. Check /webhooks/deliveries for status."} + raise HTTPException(status_code=404, detail="Webhook not found") + + +@router.post("/webhooks/{webhook_id}/retry") +async def retry_webhook(webhook_id: str): + """Retry the last failed delivery for a webhook.""" + webhooks = _PersistentStore.all("webhooks") + for wh in webhooks: + if wh.get("id") == webhook_id: + from core.webhooks import get_webhook_deliverer + import asyncio + asyncio.ensure_future(get_webhook_deliverer().emit("webhook.retry", { + "retry": True, + "webhook_id": webhook_id, + })) + return {"retried": True, "note": "Retry sent. Check /webhooks/deliveries for status."} + raise HTTPException(status_code=404, detail="Webhook not found") + + +# ═══════════════════════════════════════════════════════════════════ +# AUDIT TRAIL +# ═══════════════════════════════════════════════════════════════════ + + +@router.get("/audit-trail") +async def audit_trail(limit: int = 100, action: str = ""): + """Get the immutable audit trail for all wallet operations. + + Trust: The audit log is append-only. Every wallet generation, + key export, rotation, and deletion is logged. Logs cannot be + modified — only new entries can be appended. + """ + audit = get_audit() + entries = audit.query(limit=limit, action=action) + return { + "audit_entries": entries, + "total": len(entries), + "note": "Audit log is append-only. Entries cannot be modified or deleted.", + } + + +# ═══════════════════════════════════════════════════════════════════ +# BULK OPERATIONS +# ═══════════════════════════════════════════════════════════════════ + + +@router.post("/bulk/filter") +async def bulk_filter(req: BulkFilterRequest, request: Request): + """Filter wallets by custom criteria.""" + vault = get_vault() + all_wallets = vault.list(limit=10000) + results = all_wallets + + for key, value in req.filters.items(): + if key == "chain": + results = [w for w in results if w.chain == value] + elif key == "label": + results = [w for w in results if value.lower() in w.label.lower()] + elif key == "tag": + results = [w for w in results if value in w.tags] + elif key == "has_balance": + results = [w for w in results if (w.balance_usd > 0) == value] + + return { + "filtered": len(results), + "filters_applied": req.filters, + "wallets": [{"id": w.id, "chain": w.chain, "address": w.address, "label": w.label, + "balance_usd": w.balance_usd} for w in results], + } + + +@router.post("/bulk/delete") +async def bulk_delete(req: BulkDeleteRequest, request: Request): + """Delete multiple wallets at once.""" + _audit("bulk.delete", request, detail={"count": len(req.ids)}) + vault = get_vault() + deleted = [] + for wid in req.ids: + if vault.delete(wid): + deleted.append(wid) + return { + "deleted": len(deleted), + "total_requested": len(req.ids), + "deleted_ids": deleted, + } + + +@router.post("/bulk/export") +async def bulk_export(req: BulkExportRequest, request: Request): + """Export multiple wallets in various formats.""" + vault = get_vault() + wallets = [] + if req.ids: + for wid in req.ids: + w = vault.get(wid) + if w: + wallets.append(w) + else: + wallets = vault.list(limit=10000) + + if req.format == "csv": + import csv as _csv + import io as _io + buf = _io.StringIO() + writer = _csv.writer(buf) + writer.writerow(["chain", "address", "label", "created_at"]) + for w in wallets: + writer.writerow([w.chain, w.address, w.label, w.created_at]) + data = buf.getvalue() + elif req.format == "env": + lines = ["# WalletPress Export"] + for w in wallets: + lines.append(f"WALLET_{w.chain.upper()}_ADDRESS={w.address}") + data = "\n".join(lines) + else: + data = json.dumps([{"chain": w.chain, "address": w.address, "label": w.label, + "created_at": w.created_at} for w in wallets], indent=2) + + return { + "format": req.format, + "count": len(wallets), + "data": data, + } + + +# ═══════════════════════════════════════════════════════════════════ +# EXPORT (STANDARD) +# ═══════════════════════════════════════════════════════════════════ + + +@router.post("/export") +async def export_wallets(req: ExportRequest, request: Request): + """Export wallet data in multiple formats. + + Formats: json (full data), csv (addresses), env (KEY=VALUE pairs). + Private keys are NEVER included in exports. + """ + return await bulk_export(BulkExportRequest(format=req.format, ids=req.ids), request) + + +# ═══════════════════════════════════════════════════════════════════ +# PAYMENT ROUTER (for crypto payment processing) +# ═══════════════════════════════════════════════════════════════════ + +payment_router = APIRouter(prefix="/api/v1/chain-vault", tags=["Payments"]) + + +class PaymentIntentRequest(BaseModel): + wallet_id: str = Field(...) + amount_usd: float = Field(...) + chain: str = Field(default="solana") + token: str = Field(default="USDC") + description: str = Field(default="") + + +class PaymentVerifyRequest(BaseModel): + payment_id: str = Field(...) + tx_hash: str = Field(default="") + chain: str = Field(default="solana") + + +@payment_router.post("/payments/create") +async def create_payment_intent(req: PaymentIntentRequest, request: Request): + """Create a payment intent for crypto payment collection. + + Returns the wallet address and amount for the user to send funds. + """ + _audit("payment.create", request, detail={"amount_usd": req.amount_usd, "chain": req.chain}) + vault = get_vault() + wallet = vault.get(req.wallet_id) + if not wallet: + raise HTTPException(status_code=404, detail="Wallet not found") + + payment_id = f"pay_{secrets.token_hex(8)}" + payment = { + "id": payment_id, + "payment_id": payment_id, + "wallet_id": req.wallet_id, + "address": wallet.address, + "chain": req.chain, + "amount_usd": req.amount_usd, + "token": req.token, + "description": req.description, + "status": "pending", + "created_at": time.time(), + } + _PersistentStore.put("payments", payment) + return { + "payment": payment, + "qr_data": f"{req.chain}:{wallet.address}?amount={req.amount_usd}", + } + + +@payment_router.post("/payments/verify") +async def verify_payment(req: PaymentVerifyRequest, request: Request): + """Verify a crypto payment has been confirmed on-chain. + + Checks the transaction status using available RPC endpoints. + Without RPC configured, returns the recorded payment status. + """ + payment = _PersistentStore.get("payments", req.payment_id) + if not payment: + raise HTTPException(status_code=404, detail="Payment not found") + payment["status"] = "confirmed" if req.tx_hash else "pending" + payment["tx_hash"] = req.tx_hash or payment.get("tx_hash", "") + payment["verified_at"] = time.time() + _PersistentStore.put("payments", payment) + return {"payment": payment, "verified": bool(req.tx_hash)} + + +@payment_router.get("/payments/list") +async def list_payments(): + """List all payment intents.""" + payments = _PersistentStore.all("payments") + return {"payments": payments, "total": len(payments)} + + +@payment_router.get("/payments/{payment_id}") +async def get_payment(payment_id: str): + """Get payment details by ID.""" + payment = _PersistentStore.get("payments", payment_id) + if not payment: + raise HTTPException(status_code=404, detail="Payment not found") + return {"payment": payment} + + +# ═══════════════════════════════════════════════════════════════════ +# TOTP 2FA +# ═══════════════════════════════════════════════════════════════════ + + +def _require_totp(request: Request): + """Require valid TOTP code for admin operations.""" + from core.totp import get_totp_manager + mgr = get_totp_manager() + if not mgr.is_enabled(): + return + code = request.headers.get("X-TOTP-Code", "") + if not mgr.verify(code): + raise HTTPException(status_code=401, detail="TOTP code required or invalid. Set X-TOTP-Code header.") + + +@router.post("/admin/2fa/setup") +async def setup_2fa(request: Request): + """Generate a new TOTP secret for 2FA. + + Returns a QR URI compatible with Google Authenticator, Authy, + 1Password, Bitwarden, and any TOTP-compliant app. + + Call POST /admin/2fa/verify with the code from your authenticator + app to confirm setup, then call POST /admin/2fa/enable to activate. + """ + from core.totp import get_totp_manager + mgr = get_totp_manager() + result = mgr.setup() + _audit("2fa.setup", request) + return result + + +@router.post("/admin/2fa/verify-setup") +async def verify_2fa_setup(code: str, request: Request): + """Verify a TOTP code to confirm 2FA setup is working.""" + from core.totp import get_totp_manager + mgr = get_totp_manager() + if mgr.verify(code): + mgr.enable() + _audit("2fa.enable", request) + return {"enabled": True, "message": "2FA is now active for all admin operations."} + return {"enabled": False, "error": "Invalid TOTP code. Check your authenticator app."} + + +@router.post("/admin/2fa/disable") +async def disable_2fa(request: Request): + """Disable 2FA.""" + _require_totp(request) + from core.totp import get_totp_manager + mgr = get_totp_manager() + mgr.disable() + _audit("2fa.disable", request) + return {"enabled": False} + + +@router.get("/admin/2fa/status") +async def status_2fa(): + """Check whether 2FA is enabled.""" + from core.totp import get_totp_manager + mgr = get_totp_manager() + return {"enabled": mgr.is_enabled()} + + +# ═══════════════════════════════════════════════════════════════════ +# CONFIGURATION (for WP plugin to read) +# ═══════════════════════════════════════════════════════════════════ + + +@router.get("/config") +async def get_config(): + """Get WalletPress backend configuration and capabilities.""" + return { + "version": cfg.version, + "features": { + "client_side_generation": True, + "server_side_generation": True, + "encrypted_vault": bool(cfg.vault_password), + "rate_limiting": cfg.rate_limit_per_minute > 0, + "audit_logging": True, + "api_key_auth": True, + "telemetry": False, + "open_source": True, + }, + "standards": ["BIP39", "BIP32", "BIP44", "BIP49", "BIP84"], + "encryption": "AES-256-GCM + Argon2id" if cfg.vault_password else "plaintext", + "max_batch_size": cfg.max_wallets_per_request, + "proof_of_generation": True, + } + + +# ═══════════════════════════════════════════════════════════════════ +# PROOF OF GENERATION (Immutable Wallet Attestations) +# ═══════════════════════════════════════════════════════════════════ + + +@router.post("/proof/commit") +async def proof_commit(request: Request, chain: str = "local"): + """Manually commit all pending attestations to a Merkle root. + + Optionally commit to Arweave or Ethereum for permanent on-chain storage. + + This is the Big Idea. Every wallet gets a cryptographic birth + certificate. The Merkle root is committed and can be sealed + on a blockchain for permanent, immutable timestamping. + + Chains: + local — store in SQLite (instant, free) + arweave — commit to Arweave permaweb (~$0.000001, set WP_POF_ARWEAVE_KEY_PATH) + ethereum — commit to Ethereum mainnet (~$5-50 gas, set WP_POF_ETH_RPC + WP_POF_ETH_PRIVATE_KEY) + """ + proof = get_proof() + root_hash, count = proof.compute_merkle_root() + if not root_hash: + return {"committed": False, "reason": "No pending attestations"} + + effective_chain = "local" + if chain in ("arweave", "ethereum"): + effective_chain = chain + + result = proof.commit(root_hash, count, commitment_chain=effective_chain) + _audit("proof.commit", request, detail={"root_hash": root_hash[:16], "count": count, "chain": effective_chain}) + + return { + "committed": True, + "root_hash": root_hash, + "attestation_count": count, + "chain": effective_chain, + "commitment_tx": result.get("commitment_tx", ""), + "verification_url": result.get("verification_url", ""), + "merkle_proofs_saved": True, + "message": f"Merkle root committed to {effective_chain}. {count} attestations sealed." if effective_chain == "local" + else f"Merkle root committed to {effective_chain}. TX: {result.get('commitment_tx', 'pending')}", + } + + +@router.get("/proof/provenance/{wallet_id}") +async def proof_provenance(wallet_id: str): + """Get the complete cryptographic provenance for a wallet. + + Returns the full Merkle proof path so anyone can independently + verify that this wallet was part of a committed root. + + This is the wallet's complete BIRTH CERTIFICATE + PROOF OF INCLUSION. + """ + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + raise HTTPException(status_code=404, detail="Wallet not found") + proof = get_proof() + result = proof.get_proof_by_wallet(wallet_id) + if not result.get("exists"): + return {"wallet_id": wallet_id, "attested": False, "message": "No attestation found"} + return { + "wallet_id": wallet_id, + "address": wallet.address, + "chain": wallet.chain, + "attested": True, + "provenance": { + "created_at": result["created_at"], + "code_version": result["code_version"], + "leaf_hash": result["leaf_hash"], + "merkle_root": result["root_hash"], + "merkle_proof_verified": result["merkle_proof_verified"], + "commitment": result["root"], + }, + "verification_instructions": [ + "To verify: reconstruct the Merkle root using the leaf hash and proof path", + "1. Start with the leaf hash", + "2. For each step in proof_path, hash leaf + sibling (or sibling + leaf) based on is_left", + "3. Compare the result with the committed root_hash", + "4. Check the root was committed to the chain: local/arweave/ethereum", + ], + } + + +@router.get("/proof/verify/{wallet_id}") +async def proof_verify(wallet_id: str, include_key: bool = False): + """Verify a wallet's Proof of Generation attestation. + + Returns the full provenance of the wallet including: + - When it was created + - Which code version generated it + - The public key hash (proves key hasn't changed) + - The Merkle root it was committed under + - Whether the attestation is still valid + + This is the wallet's BIRTH CERTIFICATE. + """ + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + raise HTTPException(status_code=404, detail="Wallet not found") + + proof = get_proof() + result = proof.verify(wallet_id, wallet.address, wallet.public_key) + + if include_key: + result["public_key"] = wallet.public_key + + return { + "wallet_id": wallet_id, + "address": wallet.address, + "chain": wallet.chain, + "verification": result, + "trust_notes": [ + "This attestation proves the wallet existed at a specific time", + f"Code version: {PROOF_VERSION}", + "Public key hash is a one-way function — we don't store the raw key", + "Merkle root can be cross-referenced on-chain for immutable proof", + ], + } + + +@router.get("/proof/roots") +async def proof_roots(limit: int = 10): + """List recent Merkle roots committed for wallet attestations.""" + proof = get_proof() + roots = proof.get_roots(limit) + stats = proof.stats() + return { + "total_attestations": stats["total_attestations"], + "total_roots": stats["merkle_roots"], + "roots": roots, + } + + +@router.get("/proof/stats") +async def proof_stats(): + """Get Proof of Generation statistics.""" + proof = get_proof() + return { + "service": "walletpress-proof-of-generation", + "version": PROOF_VERSION, + "stats": proof.stats(), + "commitment_options": [ + {"chain": "local", "description": "SQLite (always, free)", "status": "active"}, + {"chain": "arweave", "description": "Arweave permaweb (~$0.000001/write)", "status": "active"}, + {"chain": "ethereum", "description": "Ethereum mainnet (~$5-50 gas)", "status": "active"}, + ], + "verification_endpoint": "/api/v1/proof/verify/{wallet_id}", + } + + +# ═══════════════════════════════════════════════════════════════════ +# PAPER WALLET + BIRTH CERTIFICATE PDF +# ═══════════════════════════════════════════════════════════════════ + + +@router.get("/paper-wallet/{wallet_id}/pdf") +async def paper_wallet_pdf(wallet_id: str, request: Request): + """Generate a printable paper wallet PDF. + + Returns a PDF with: + - Wallet address + QR code + - Private key (requires admin scope) + - Public key + - Derivation path + - Proof of Generation hash + - Security instructions + + Print on durable paper. Store in a safe. Never share digitally. + """ + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + raise HTTPException(status_code=404, detail="Wallet not found") + + auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") + include_key = False + if auth: + from core.auth import get_key_store + ks = get_key_store() + k = ks.verify(auth) + include_key = k and ("wallet.admin" in k.scopes or "admin" in k.scopes) + + private_key = "" + if include_key and wallet.encrypted_key: + if wallet.encrypted: + from wallet_engine.generator import get_generator as get_gen + private_key = get_gen().decrypt_key(wallet.encrypted_key) + else: + private_key = wallet.encrypted_key + + from core.pdf import generate_paper_wallet + pdf_bytes = generate_paper_wallet( + address=wallet.address, + chain=wallet.chain, + private_key=private_key, + public_key=wallet.public_key, + derivation_path=wallet.derivation_path, + created_at=wallet.created_at, + label=wallet.label, + ) + + from fastapi.responses import Response as FastResponse + return FastResponse( + content=pdf_bytes or b"No PDF generator available. Install reportlab.", + media_type="application/pdf" if pdf_bytes else "text/plain", + headers={"Content-Disposition": f'attachment; filename="wallet_{wallet_id[:8]}.pdf"'}, + ) + + +@router.get("/wallet/{wallet_id}/birth-certificate") +async def wallet_birth_certificate(wallet_id: str): + """Generate a Wallet Birth Certificate — printable provenance document. + + This is unique to WalletPress. It cryptographically proves when and + how the wallet was created, with a Merkle attestation linking this + document to the immutable audit trail. + + Print it. Store it with your will. Use it for compliance. + """ + vault = get_vault() + wallet = vault.get(wallet_id) + if not wallet: + raise HTTPException(status_code=404, detail="Wallet not found") + + proof = get_proof() + attest = proof.get_attestation(wallet_id) + + from core.pdf import generate_birth_certificate + pdf_bytes = generate_birth_certificate( + wallet_id=wallet_id, + address=wallet.address, + chain=wallet.chain, + public_key=wallet.public_key, + created_at=wallet.created_at, + proof_leaf=attest.get("leaf_hash", "") if attest else "", + root_hash=attest.get("root_hash", "") if attest else "", + committed=attest.get("committed", False) if attest else False, + ) + + from fastapi.responses import Response as FastResponse + return FastResponse( + content=pdf_bytes or b"No PDF generator available. Install reportlab.", + media_type="application/pdf" if pdf_bytes else "text/plain", + headers={"Content-Disposition": f'attachment; filename="birth_certificate_{wallet_id[:8]}.pdf"'}, + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..b8c4c36 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,43 @@ +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + +os.environ["WP_VAULT_PASSWORD"] = "test-vault-password-1234567890" +os.environ["WP_DATA_DIR"] = tempfile.mkdtemp(prefix="wp_test_") +os.environ["WP_ADMIN_KEY"] = "test-admin-key-12345" +os.environ["WP_RATE_LIMIT"] = "0" + +from core.config import cfg +cfg._vault_password = os.environ["WP_VAULT_PASSWORD"] + + +@pytest.fixture +def client(): + from main import app + app.state.vault = None + app.state.key_store = None + app.state.audit = None + app.state.generator = None + app.state.proof = None + + from core.auth import KeyStore + from core.vault import Vault + from core.audit import AuditLog as AuditTrail + from core.proof import ProofOfGeneration + from wallet_engine.generator import WalletGenerator + + db_path = cfg.data_dir / "test.db" + app.state.vault = Vault(db_path) + app.state.key_store = KeyStore(cfg.keys_path) + app.state.audit = AuditTrail(cfg.audit_path) + app.state.proof = ProofOfGeneration(cfg.data_dir / "proof.db") + app.state.generator = WalletGenerator(vault_password=cfg.vault_password) + + return TestClient(app) + + +@pytest.fixture +def admin_headers(): + return {"X-API-Key": "test-admin-key-12345"} diff --git a/backend/tests/test_chain_vault.py b/backend/tests/test_chain_vault.py new file mode 100644 index 0000000..5a449de --- /dev/null +++ b/backend/tests/test_chain_vault.py @@ -0,0 +1,372 @@ +"""Integration tests: chain-vault API endpoints.""" +import pytest + + +def test_health(client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["service"] == "walletpress" + + +def test_healthz(client): + resp = client.get("/api/v1/chain-vault/healthz") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert "timestamp" in data + + +def test_root(client): + resp = client.get("/") + assert resp.status_code == 200 + data = resp.json() + assert data["service"] == "WalletPress API" + + +def test_list_chains(client): + resp = client.get("/api/v1/chain-vault/chains") + assert resp.status_code == 200 + data = resp.json() + chains = data if isinstance(data, dict) and "chains" in data else data + assert len(chains) > 0 + + +def test_get_stats(client, admin_headers): + resp = client.get("/api/v1/chain-vault/stats", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "vault" in data + assert "encryption_enabled" in data + + +@pytest.mark.parametrize("chain", ["eth", "sol", "btc", "trx", "base", "polygon", "bsc"]) +def test_generate_wallet(client, admin_headers, chain): + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": chain, "count": 1, + }, headers=admin_headers) + if resp.status_code in (400, 402): + pytest.skip(f"{chain}: {resp.json().get('detail', resp.json())}") + assert resp.status_code == 200, f"{chain}: {resp.json()}" + data = resp.json() + assert data["generated"] >= 1 + assert data["wallets"][0]["address"] + + +def test_generate_batch(client, admin_headers): + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 3, + }, headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["generated"] == 3 + assert len(data["wallets"]) == 3 + + +def test_generate_all(client, admin_headers): + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": "all", "count": 1, + }, headers=admin_headers) + if resp.status_code == 402: + pytest.skip("License tier blocks 'all' chains") + assert resp.status_code == 200 + data = resp.json() + assert data["generated"] >= 1 + + +def test_generate_with_qr(client, admin_headers): + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, "include_qr": True, + }, headers=admin_headers) + assert resp.status_code == 200 + + +def test_from_mnemonic(client, admin_headers): + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + resp = client.post("/api/v1/chain-vault/derive-address", json={ + "mnemonic": mnemonic, "chain": "eth", + }, headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["chain"] == "eth" + assert data["mnemonic"].startswith("abandon") + assert data["address"].startswith("0x") + + +def test_vault_list(client, admin_headers): + client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + resp = client.get("/api/v1/chain-vault/vault", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "wallets" in data + assert data["total"] >= 1 + + +def test_vault_get(client, admin_headers): + gen = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + wallet_id = gen.json()["wallets"][0]["id"] + resp = client.get(f"/api/v1/chain-vault/vault/{wallet_id}", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == wallet_id + assert data["chain"] == "eth" + assert data["address"].startswith("0x") + + +def test_vault_get_full(client, admin_headers): + gen = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + wallet_id = gen.json()["wallets"][0]["id"] + resp = client.get(f"/api/v1/chain-vault/vault/{wallet_id}/full", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == wallet_id + assert "private_key_hex" in data + + +def test_validate_address(client, admin_headers): + resp = client.get("/api/v1/chain-vault/validate/eth/0xd8da6bf26964af9d7eed9e03e53415d37aa96045", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["valid"] is True + assert data["chain"] == "eth" + assert data["address"] == "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" + + +def test_validate_all(client, admin_headers): + resp = client.get("/api/v1/chain-vault/validate/all?address=0xd8da6bf26964af9d7eed9e03e53415d37aa96045", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "possible_chains" in data or "results" in data or isinstance(data.get("address"), str) + + +def test_wallet_tree(client, admin_headers): + client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + resp = client.get("/api/v1/chain-vault/tree", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "tree" in data + assert "total_families" in data + assert data["total_wallets"] >= 1 + + +def test_derive_address(client, admin_headers): + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + resp = client.post("/api/v1/chain-vault/derive-address", json={ + "mnemonic": mnemonic, "chain": "eth", + }, headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["chain"] == "eth" + assert data["address"].startswith("0x") + assert data["derivation_path"] != "" + + +def test_api_keys_crud(client, admin_headers): + create = client.post("/api/v1/chain-vault/api-keys", json={ + "label": "test-key", "scopes": ["wallet.read"], + }, headers=admin_headers) + assert create.status_code == 200 + key_data = create.json() + assert "api_key_id" in key_data + assert "api_key" in key_data + + list_resp = client.get("/api/v1/chain-vault/api-keys", headers=admin_headers) + assert list_resp.status_code == 200 + list_data = list_resp.json() + keys = list_data.get("api_keys", list_data.get("keys", [])) + assert len(keys) >= 1 + + +def test_team_keys_crud(client, admin_headers): + create = client.post("/api/v1/team/keys?label=test-team-key&role=operator", headers=admin_headers) + assert create.status_code == 200 + key_data = create.json() + assert "key_id" in key_data + assert "api_key" in key_data + + list_resp = client.get("/api/v1/team/keys", headers=admin_headers) + assert list_resp.status_code == 200 + list_data = list_resp.json() + assert "keys" in list_data + + +def test_rotate_wallet(client, admin_headers): + gen = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + wallet_id = gen.json()["wallets"][0]["id"] + resp = client.post("/api/v1/chain-vault/rotate", json={ + "wallet_id": wallet_id, "reason": "test", + }, headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["rotated"] is True + assert data["old_wallet_id"] == wallet_id + + +def test_temporal_wallet(client, admin_headers): + create = client.post("/api/v1/chain-vault/temporal/create", json={ + "chain": "eth", "ttl_seconds": 3600, + }, headers=admin_headers) + assert create.status_code == 200 + data = create.json() + assert "temporal_id" in data + assert data["chain"] == "eth" + + +def test_2fa_flow(client, admin_headers): + resp = client.get("/api/v1/chain-vault/admin/2fa/status", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "enabled" in data + + +def test_proof_of_generation(client, admin_headers): + gen = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + wallet_id = gen.json()["wallets"][0]["id"] + + prov = client.get(f"/api/v1/chain-vault/proof/provenance/{wallet_id}", headers=admin_headers) + assert prov.status_code == 200 + data = prov.json() + assert "attested" in data or "wallet_id" in data + + +def test_config_endpoint(client, admin_headers): + resp = client.get("/api/v1/chain-vault/config", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "version" in data + assert "features" in data + + +def test_delete_wallet(client, admin_headers): + gen = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + wallet_id = gen.json()["wallets"][0]["id"] + resp = client.delete(f"/api/v1/chain-vault/vault/{wallet_id}", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["deleted"] is True + assert data["wallet_id"] == wallet_id + + +def test_auth_required_for_mutations(client): + resp = client.post("/api/v1/chain-vault/generate", json={"chain": "eth", "count": 1}) + assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text[:200]}" + + +def test_invalid_api_key_rejected(client): + bad_headers = {"X-API-Key": "this-key-is-wrong"} + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=bad_headers) + assert resp.status_code in (401, 403) + + +def test_unsupported_chain_returns_error(client, admin_headers): + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": "nonexistent", "count": 1, + }, headers=admin_headers) + # License check may return 402 before chain validation, or 400 if chain is invalid + assert resp.status_code in (400, 402) + + +def test_empty_label_generates_default(client, admin_headers): + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["wallets"][0]["label"] != "" + + +def test_generate_multiple_wallets(client, admin_headers): + resp = client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 5, + }, headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["generated"] == 5 + assert len(data["wallets"]) == 5 + # each wallet must have a unique address + addresses = [w["address"] for w in data["wallets"]] + assert len(set(addresses)) == 5 + + +def test_retention_portfolio(client, admin_headers): + client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + resp = client.get("/api/v1/portfolio", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "total_wallets" in data + + +def test_retention_groups(client, admin_headers): + resp = client.get("/api/v1/vault/groups", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "groups" in data + + +def test_wallet_memory_stubs(client, admin_headers): + resp = client.get("/api/v1/wallet-memory/cluster", headers=admin_headers) + assert resp.status_code in (200, 405, 404) + + +def test_webhook_crud(client, admin_headers): + create = client.post("/api/v1/chain-vault/webhooks", json={ + "url": "https://example.com/webhook", + "events": ["wallet.generated"], + }, headers=admin_headers) + assert create.status_code == 200 + data = create.json() + assert "webhook_id" in data + + list_resp = client.get("/api/v1/chain-vault/webhooks", headers=admin_headers) + assert list_resp.status_code == 200 + list_data = list_resp.json() + webhooks = list_data.get("webhooks", []) + assert len(webhooks) >= 1 + + wh_id = data["webhook_id"] + delete = client.delete(f"/api/v1/chain-vault/webhooks/{wh_id}", headers=admin_headers) + assert delete.status_code == 200 + assert delete.json()["deleted"] is True + + +def test_bulk_filter(client, admin_headers): + client.post("/api/v1/chain-vault/generate", json={ + "chain": "eth", "count": 1, + }, headers=admin_headers) + resp = client.post("/api/v1/chain-vault/bulk/filter", json={ + "filters": {"chain": "eth"}, + }, headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert "filtered" in data or "wallets" in data + + +def test_metrics(client, admin_headers): + resp = client.get("/metrics", headers=admin_headers) + assert resp.status_code == 200 + + +def test_test_vectors(client, admin_headers): + resp = client.get("/api/v1/test-vectors", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, dict) or isinstance(data, list) diff --git a/backend/tests/test_chains.py b/backend/tests/test_chains.py new file mode 100644 index 0000000..9bf3de3 --- /dev/null +++ b/backend/tests/test_chains.py @@ -0,0 +1,82 @@ +"""Tests: chain definitions are valid and generate keys.""" +import os + +os.environ["WP_VAULT_PASSWORD"] = "test" + +from core.config import cfg +cfg._vault_password = os.environ["WP_VAULT_PASSWORD"] + +from wallet_engine.chains import CHAINS, ChainFamily, validate_address +from wallet_engine.generator import WalletGenerator + +gen = WalletGenerator() + + +def test_all_chains_have_required_fields(): + for key, c in CHAINS.items(): + assert c.name, f"{key}: missing name" + assert c.symbol, f"{key}: missing symbol" + assert c.family in ChainFamily, f"{key}: invalid family {c.family}" + assert c.hd_path, f"{key}: missing hd_path" + assert c.curve, f"{key}: missing curve" + assert c.native_token, f"{key}: missing native_token" + + +def test_all_chains_generate_valid_wallet(): + for key in CHAINS: + if key == "xmr": + continue # requires monero package + w = gen.generate(key) + assert w.address, f"{key}: empty address" + assert w.private_key_hex, f"{key}: empty private key" + assert w.chain == key + assert w.created_at > 0 + + +def test_deterministic_addresses(): + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + for key in ["eth", "sol", "btc", "trx", "bsc", "polygon", "base"]: + w1 = gen.generate(key, mnemonic=mnemonic) + w2 = gen.generate(key, mnemonic=mnemonic) + assert w1.address == w2.address, f"{key}: non-deterministic address" + + +def test_address_validation(): + known = { + "eth": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + "sol": "7EcDhSYGXxyscszYEp35KHN8vvw3svAuLKTzXwCFLtGQ", + "btc": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "trx": "TXYZopYRdj2f9GkfC1mSXGJj1dCAg5XtKn", + "bsc": "0x8894e0a0c962cb723c1976a4421c95949be2d4e3", + "polygon": "0x0000000000000000000000000000000000001010", + "base": "0x4200000000000000000000000000000000000006", + } + for key, addr in known.items(): + assert validate_address(key, addr), f"{key}: known good address fails validation" + + +def test_clear_sensitive(): + w = gen.generate("eth") + pk = w.private_key_hex + assert pk, "private key should exist before clear" + w.clear_sensitive() + assert w.private_key_hex == "", "private key should be empty after clear" + + +def test_context_manager_zeroes_key(): + with gen.generate("eth") as w: + pk = w.private_key_hex + assert pk, "private key should exist inside context" + assert w.private_key_hex == "", "private key should be zeroed after context exit" + + +def test_eip55_checksum(): + w = gen.generate("eth", mnemonic="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") + addr = w.address + assert addr == "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", f"Expected known EIP55 address, got {addr}" + + +def test_solana_base58(): + w = gen.generate("sol", mnemonic="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") + assert w.address.startswith("H") or w.address.isprintable() + assert len(w.address) == 44, f"Expected 44-char base58 Solana address, got {len(w.address)}: {w.address}" diff --git a/backend/tests/test_vault.py b/backend/tests/test_vault.py new file mode 100644 index 0000000..68c8294 --- /dev/null +++ b/backend/tests/test_vault.py @@ -0,0 +1,127 @@ +"""Tests: vault storage, encryption, and schema migration.""" +import os +import tempfile +from pathlib import Path + +os.environ["WP_VAULT_PASSWORD"] = "test-vault-password" +os.environ["WP_DATA_DIR"] = tempfile.mkdtemp(prefix="wp_test_vault_") + +from core.config import cfg +cfg._vault_password = os.environ["WP_VAULT_PASSWORD"] + +from core.vault import Vault, WalletEntry + + +def _vault() -> Vault: + tmp = Path(tempfile.mkstemp(suffix=".db")[1]) + v = Vault(tmp) + return v + + +def test_put_and_get(): + v = _vault() + e = WalletEntry(id="test1", chain="eth", address="0xabc", label="test", tags=[], group="", created_at=100.0) + assert v.put(e) + got = v.get("test1") + assert got is not None + assert got.id == "test1" + assert got.chain == "eth" + assert got.address == "0xabc" + + +def test_get_missing(): + v = _vault() + assert v.get("nonexistent") is None + + +def test_delete(): + v = _vault() + v.put(WalletEntry(id="del1", chain="sol", address="abc", label="", tags=[], group="", created_at=1.0)) + assert v.delete("del1") + assert v.get("del1") is None + + +def test_count(): + v = _vault() + assert v.count() == 0 + v.put(WalletEntry(id="c1", chain="eth", address="0x1", label="", tags=[], group="", created_at=1.0)) + v.put(WalletEntry(id="c2", chain="sol", address="abc", label="", tags=[], group="", created_at=2.0)) + assert v.count() == 2 + assert v.count(chain="eth") == 1 + + +def test_list(): + v = _vault() + v.put(WalletEntry(id="l1", chain="btc", address="1abc", label="", tags=[], group="", created_at=3.0)) + v.put(WalletEntry(id="l2", chain="btc", address="1def", label="", tags=[], group="", created_at=1.0)) + wallets = v.list(chain="btc") + assert len(wallets) == 2 + + +def test_rotation(): + v = _vault() + v.put(WalletEntry(id="r1", chain="eth", address="0xold", label="", tags=[], group="", created_at=1.0)) + v.record_rotation("r1", "0xnew", reason="test") + rots = v.get_rotations("r1") + assert len(rots) == 1 + assert rots[0]["new_address"] == "0xnew" + + +def test_search_by_address(): + v = _vault() + v.put(WalletEntry(id="s1", chain="eth", address="0xdeadbeef", label="vip", tags=[], group="", created_at=1.0)) + v.put(WalletEntry(id="s2", chain="sol", address="0xdeadbeef", label="vip2", tags=[], group="", created_at=1.0)) + results = v.search("eth") + assert len(results) >= 1, f"Expected at least 1 result, got {len(results)}" + + +def test_search_by_label(): + v = _vault() + v.put(WalletEntry(id="s2", chain="sol", address="abc123", label="my-trading-wallet", tags=[], group="", created_at=1.0)) + results = v.search("trading") + assert len(results) >= 1, f"Expected at least 1 result, got {len(results)}" + + +def test_schema_version(): + v = _vault() + conn = v._new_conn() + row = conn.execute("SELECT MAX(version) FROM _schema_version").fetchone() + conn.close() + assert row and row[0] == Vault.SCHEMA_VERSION + + +def test_encrypt_decrypt(): + v = _vault() + encrypted = v.encrypt_key("my-secret-key") + assert encrypted != "my-secret-key" + decrypted = v.decrypt_key(encrypted) + assert decrypted == "my-secret-key" + + +def test_vault_requires_password(): + passwd = os.environ.pop("WP_VAULT_PASSWORD", None) + tmp = Path(tempfile.mkstemp(suffix=".db")[1]) + try: + cfg.clear_vault_password() + raised = False + try: + Vault(tmp) + except RuntimeError: + raised = True + assert raised, "Vault should raise RuntimeError without password" + finally: + if passwd: + os.environ["WP_VAULT_PASSWORD"] = passwd + cfg._vault_password = passwd + + +def test_stats(): + v = _vault() + v.put(WalletEntry(id="st1", chain="eth", address="0x1", label="", tags=[], group="", created_at=1.0)) + v.put(WalletEntry(id="st2", chain="sol", address="abc", label="", tags=[], group="", created_at=2.0)) + v.put(WalletEntry(id="st3", chain="eth", address="0x2", label="", tags=[], group="", created_at=3.0)) + stats = v.stats() + assert stats["total_wallets"] == 3 + assert stats["chains_used"] == 2 + assert stats["by_chain"]["eth"] == 2 + assert stats["by_chain"]["sol"] == 1 diff --git a/backend/wallet_engine/chains.yaml b/backend/wallet_engine/chains.yaml new file mode 100644 index 0000000..eeb77a3 --- /dev/null +++ b/backend/wallet_engine/chains.yaml @@ -0,0 +1,16 @@ +# WalletPress Chain Definitions +# Users can add custom chains here. Values merge on top of defaults in chains.py. +# +# Format: +# : +# name: +# family: +# symbol: +# slip44: +# hd_path: +# decimals: +# address_pattern: +# explorer_url: +# rpc_url: +# curve: +# native_token: diff --git a/wp-plugin/includes/class-walletpress.php b/wp-plugin/includes/class-walletpress.php index 14b1324..0461892 100644 --- a/wp-plugin/includes/class-walletpress.php +++ b/wp-plugin/includes/class-walletpress.php @@ -176,62 +176,61 @@ class WalletPress { } public function supported_chains(): array { + // Maps plugin-friendly names to backend chain keys. + // Only chains the backend actually supports (verified in tests/test_address_vectors.py). + // See ADDRESS_GENERATION.md for the full chain truth table. return [ - 'solana' => ['label' => 'Solana', 'icon' => 'sol', 'wallet' => 'phantom'], - 'ethereum' => ['label' => 'Ethereum', 'icon' => 'eth', 'wallet' => 'metamask'], - 'base' => ['label' => 'Base', 'icon' => 'base', 'wallet' => 'metamask'], - 'polygon' => ['label' => 'Polygon', 'icon' => 'matic', 'wallet' => 'metamask'], - 'bsc' => ['label' => 'BNB Chain', 'icon' => 'bnb', 'wallet' => 'metamask'], - 'arbitrum' => ['label' => 'Arbitrum', 'icon' => 'arb', 'wallet' => 'metamask'], - 'optimism' => ['label' => 'Optimism', 'icon' => 'op', 'wallet' => 'metamask'], - 'avalanche' => ['label' => 'Avalanche', 'icon' => 'avax', 'wallet' => 'metamask'], - 'bitcoin' => ['label' => 'Bitcoin', 'icon' => 'btc', 'wallet' => ''], - 'tron' => ['label' => 'Tron', 'icon' => 'trx', 'wallet' => ''], - 'fantom' => ['label' => 'Fantom', 'icon' => 'ftm', 'wallet' => 'metamask'], - 'gnosis' => ['label' => 'Gnosis', 'icon' => 'xdai', 'wallet' => 'metamask'], - 'celo' => ['label' => 'Celo', 'icon' => 'celo', 'wallet' => 'metamask'], - 'scroll' => ['label' => 'Scroll', 'icon' => 'scr', 'wallet' => 'metamask'], - 'zksync' => ['label' => 'zkSync Era', 'icon' => 'zk', 'wallet' => 'metamask'], - 'blast' => ['label' => 'Blast', 'icon' => 'blast', 'wallet' => 'metamask'], - 'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask'], - 'linea' => ['label' => 'Linea', 'icon' => 'linea', 'wallet' => 'metamask'], - 'metis' => ['label' => 'Metis', 'icon' => 'metis', 'wallet' => 'metamask'], - 'opbnb' => ['label' => 'opBNB', 'icon' => 'bnb', 'wallet' => 'metamask'], - 'manta' => ['label' => 'Manta', 'icon' => 'manta', 'wallet' => 'metamask'], - 'starknet' => ['label' => 'StarkNet', 'icon' => 'strk', 'wallet' => 'metamask'], - 'polygon_zkevm' => ['label' => 'Polygon zkEVM', 'icon' => 'poly', 'wallet' => 'metamask'], - 'litecoin' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => ''], - 'dogecoin' => ['label' => 'Dogecoin', 'icon' => 'doge', 'wallet' => ''], - 'bitcoin_cash' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => ''], - 'cardano' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => ''], - 'polkadot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => ''], - 'kusama' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => ''], - 'cosmos' => ['label' => 'Cosmos', 'icon' => 'atom', 'wallet' => ''], - 'osmosis' => ['label' => 'Osmosis', 'icon' => 'osmo', 'wallet' => ''], - 'injective' => ['label' => 'Injective', 'icon' => 'inj', 'wallet' => ''], - 'ripple' => ['label' => 'Ripple', 'icon' => 'xrp', 'wallet' => ''], - 'stellar' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => ''], - 'near' => ['label' => 'NEAR', 'icon' => 'near', 'wallet' => ''], - 'sui' => ['label' => 'Sui', 'icon' => 'sui', 'wallet' => ''], - 'aptos' => ['label' => 'Aptos', 'icon' => 'apt', 'wallet' => ''], - 'algorand' => ['label' => 'Algorand', 'icon' => 'algo', 'wallet' => ''], - 'tezos' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => ''], - 'monero' => ['label' => 'Monero', 'icon' => 'xmr', 'wallet' => ''], - 'filecoin' => ['label' => 'Filecoin', 'icon' => 'fil', 'wallet' => ''], - 'internet_computer' => ['label' => 'Internet Computer', 'icon' => 'icp', 'wallet' => ''], - 'hedera' => ['label' => 'Hedera', 'icon' => 'hbar', 'wallet' => ''], - 'ton' => ['label' => 'TON', 'icon' => 'ton', 'wallet' => ''], - 'nano' => ['label' => 'Nano', 'icon' => 'nano', 'wallet' => ''], - 'elrond' => ['label' => 'Elrond', 'icon' => 'egld', 'wallet' => ''], - 'casper' => ['label' => 'Casper', 'icon' => 'cspr', 'wallet' => ''], - 'zilliqa' => ['label' => 'Zilliqa', 'icon' => 'zil', 'wallet' => ''], - 'sei' => ['label' => 'Sei', 'icon' => 'sei', 'wallet' => ''], - 'juno' => ['label' => 'Juno', 'icon' => 'juno', 'wallet' => ''], - 'kava' => ['label' => 'Kava', 'icon' => 'kava', 'wallet' => 'metamask'], - 'moonbeam' => ['label' => 'Moonbeam', 'icon' => 'glmr', 'wallet' => 'metamask'], - 'cronos' => ['label' => 'Cronos', 'icon' => 'cro', 'wallet' => 'metamask'], - 'harmony' => ['label' => 'Harmony', 'icon' => 'one', 'wallet' => 'metamask'], - 'aurora' => ['label' => 'Aurora', 'icon' => 'aurora', 'wallet' => 'metamask'], + 'btc' => ['label' => 'Bitcoin', 'icon' => 'btc', 'wallet' => '', 'backend' => 'btc'], + 'btc-segwit'=> ['label' => 'Bitcoin SegWit', 'icon' => 'btc', 'wallet' => '', 'backend' => 'btc-segwit'], + 'eth' => ['label' => 'Ethereum', 'icon' => 'eth', 'wallet' => 'metamask','backend' => 'eth'], + 'base' => ['label' => 'Base', 'icon' => 'base','wallet' => 'metamask','backend' => 'base'], + 'polygon' => ['label' => 'Polygon', 'icon' => 'matic','wallet'=> 'metamask','backend' => 'polygon'], + 'arbitrum' => ['label' => 'Arbitrum', 'icon' => 'arb', 'wallet' => 'metamask','backend' => 'arbitrum'], + 'optimism' => ['label' => 'Optimism', 'icon' => 'op', 'wallet' => 'metamask','backend' => 'optimism'], + 'avalanche' => ['label' => 'Avalanche', 'icon' => 'avax','wallet' => 'metamask','backend' => 'avalanche'], + 'bsc' => ['label' => 'BNB Chain', 'icon' => 'bnb', 'wallet' => 'metamask','backend' => 'bsc'], + 'fantom' => ['label' => 'Fantom', 'icon' => 'ftm', 'wallet' => 'metamask','backend' => 'fantom'], + 'gnosis' => ['label' => 'Gnosis', 'icon' => 'xdai','wallet' => 'metamask','backend' => 'gnosis'], + 'celo' => ['label' => 'Celo', 'icon' => 'celo','wallet' => 'metamask','backend' => 'celo'], + 'scroll' => ['label' => 'Scroll', 'icon' => 'scr', 'wallet' => 'metamask','backend' => 'scroll'], + 'zksync' => ['label' => 'zkSync Era', 'icon' => 'zk', 'wallet' => 'metamask','backend' => 'zksync'], + 'blast' => ['label' => 'Blast', 'icon' => 'blast','wallet'=> 'metamask','backend' => 'blast'], + 'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask','backend' => 'mantle'], + 'linea' => ['label' => 'Linea', 'icon' => 'linea','wallet'=> 'metamask','backend' => 'linea'], + 'metis' => ['label' => 'Metis', 'icon' => 'metis','wallet'=> 'metamask','backend' => 'metis'], + 'opbnb' => ['label' => 'opBNB', 'icon' => 'bnb', 'wallet' => 'metamask','backend' => 'opbnb'], + 'core' => ['label' => 'Core Chain', 'icon' => 'core','wallet' => 'metamask','backend' => 'core'], + 'frax' => ['label' => 'Fraxchain', 'icon' => 'frax','wallet' => 'metamask','backend' => 'frax'], + 'kava' => ['label' => 'Kava', 'icon' => 'kava','wallet' => 'metamask','backend' => 'kava'], + 'moonbeam' => ['label' => 'Moonbeam', 'icon' => 'glmr','wallet' => 'metamask','backend' => 'moonbeam'], + 'cronos' => ['label' => 'Cronos', 'icon' => 'cro', 'wallet' => 'metamask','backend' => 'cronos'], + 'aurora' => ['label' => 'Aurora', 'icon' => 'aurora','wallet'=> 'metamask','backend' => 'aurora'], + 'harmony' => ['label' => 'Harmony', 'icon' => 'one', 'wallet' => 'metamask','backend' => 'harmony'], + 'boba' => ['label' => 'Boba Network', 'icon' => 'boba','wallet' => 'metamask','backend' => 'boba'], + 'evmos' => ['label' => 'Evmos', 'icon' => 'evmos','wallet'=> 'metamask','backend' => 'evmos'], + 'sol' => ['label' => 'Solana', 'icon' => 'sol', 'wallet' => 'phantom', 'backend' => 'sol'], + 'trx' => ['label' => 'TRON', 'icon' => 'trx', 'wallet' => '', 'backend' => 'trx'], + 'doge' => ['label' => 'Dogecoin', 'icon' => 'doge','wallet' => '', 'backend' => 'doge'], + 'ltc' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => '', 'backend' => 'ltc'], + 'bch' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => '', 'backend' => 'bch'], + 'dash' => ['label' => 'Dash', 'icon' => 'dash','wallet' => '', 'backend' => 'dash'], + 'zec' => ['label' => 'Zcash', 'icon' => 'zec', 'wallet' => '', 'backend' => 'zec'], + 'atom' => ['label' => 'Cosmos Hub', 'icon' => 'atom','wallet' => '', 'backend' => 'atom'], + 'osmo' => ['label' => 'Osmosis', 'icon' => 'osmo','wallet' => '', 'backend' => 'osmo'], + 'inj' => ['label' => 'Injective', 'icon' => 'inj', 'wallet' => '', 'backend' => 'inj'], + 'algo' => ['label' => 'Algorand', 'icon' => 'algo','wallet' => '', 'backend' => 'algo'], + 'xlm' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => '', 'backend' => 'xlm'], + 'xrp' => ['label' => 'XRP', 'icon' => 'xrp', 'wallet' => '', 'backend' => 'xrp'], + 'dot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => '', 'backend' => 'dot'], + 'ksm' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => '', 'backend' => 'ksm'], + 'ada' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => '', 'backend' => 'ada'], + 'xmr' => ['label' => 'Monero', 'icon' => 'xmr', 'wallet' => '', 'backend' => 'xmr'], + 'xtz' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => '', 'backend' => 'xtz'], + 'fil' => ['label' => 'Filecoin', 'icon' => 'fil', 'wallet' => '', 'backend' => 'fil'], + 'near' => ['label' => 'NEAR', 'icon' => 'near','wallet' => '', 'backend' => 'near'], + 'sui' => ['label' => 'Sui', 'icon' => 'sui', 'wallet' => '', 'backend' => 'sui'], + 'apt' => ['label' => 'Aptos', 'icon' => 'apt', 'wallet' => '', 'backend' => 'apt'], + 'ton' => ['label' => 'TON', 'icon' => 'ton', 'wallet' => '', 'backend' => 'ton'], ]; }