refactor(routers): split chain_vault.py god router — WP-085..WP-087

Extracted admin endpoints from chain_vault.py (2,178 lines) into
wallet_admin.py (768 lines). chain_vault.py is now 1,469 lines.

What moved to wallet_admin.py (29 routes):
- API keys: /api-keys, /api-keys/revoke
- Alerts: /alerts, /alerts/delete
- Webhooks: /webhooks, /webhooks/{id}, /webhooks/{id}/test,
  /webhooks/{id}/retry, /webhooks/deliveries
- Audit trail: /audit-trail
- Bulk ops: /bulk/filter, /bulk/delete, /bulk/export, /export
- 2FA: /admin/2fa/{setup,verify-setup,disable,status}
- Config: /config
- Proof of Generation: /proof/{commit,provenance,verify,roots,stats}

What stayed in chain_vault.py (32 routes):
- Chain metadata: /chains, /stats, /healthz, /health-score
- RPC config: /rpc-chains
- Wallet gen: /generate, /generate/batch, /generate/all,
  /import, /from-mnemonic, /derive-address, /hd-wallet
- Vault CRUD: /vault, /vault/{id}, /vault/{id}/full, DELETE
- Wallet ops: /tree, /cluster, /rotate, /rotate-sweep, /rotations,
  /distribute, /sweep, /escrow, /escrow/release, /paper-wallet, /tx
- Validation: /validate/{chain}/{address}, /validate/all
- Balances: /balances, /balances/snapshot
- PDF: /paper-wallet/{id}/pdf, /wallet/{id}/birth-certificate

Helpers extracted to _persistent_store.py:
- _PersistentStore class (webhooks/alerts/payments SQLite store)
  P3-7 fix: removed dead _webhooks global references in test/retry
  endpoints — now uses _PersistentStore.all('webhooks')

Added to wallet_admin.py:
- _require_totp() helper (also kept in chain_vault.py for the
  /vault/{id}/full endpoint that needs it)
- WebhookCreateRequest, BulkFilterRequest, BulkDeleteRequest models
  (these were inlined in original chain_vault.py body — now in
  the request schemas section)

P3-10 — WP plugin supported_chains() rewritten
  Plugin used to advertise chains the backend doesn't support
  ('bitcoin' vs backend 'btc', 'ethereum' vs 'eth', etc.). Rewrote
  to use correct backend keys + added a 'backend' field for clarity.
  Now matches ADDRESS_GENERATION.md truth table.

main.py: updated import + include_router for wallet_admin.router.

Test results: 80 passed, 5 skipped (no regressions).

Refs: AUDIT.md P2-16, P3-7, P3-10, P3-17
This commit is contained in:
Crypto Rug Munch 2026-06-30 21:40:45 +07:00
parent b88212878a
commit 85d8ef5eac
No known key found for this signature in database
GPG key ID: 58D4300721937626
34 changed files with 4713 additions and 854 deletions

View file

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

28
backend/adapters/eliza.py Normal file
View file

@ -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"}

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
"""WalletPress AI Wallet Agent — MCP-native, multi-provider, self-hosted."""

245
backend/agent/detector.py Normal file
View file

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

View file

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

431
backend/agent/providers.py Normal file
View file

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

36
backend/alembic.ini Normal file
View file

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

56
backend/alembic/env.py Normal file
View file

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

View file

@ -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"}

View file

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

View file

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

133
backend/core/db_pool.py Normal file
View file

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

View file

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

28
backend/core/event_bus.py Normal file
View file

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

76
backend/core/response.py Normal file
View file

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

View file

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

151
backend/core/x402_verify.py Normal file
View file

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

View file

@ -37,7 +37,7 @@ from core.rate_limit import RateLimitMiddleware
from core.webhooks import get_webhook_deliverer from core.webhooks import get_webhook_deliverer
from core.ip_allowlist import IPAllowlistMiddleware from core.ip_allowlist import IPAllowlistMiddleware
from core.license_check import LicenseMiddleware 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 airdrop as airdrop_router, health_monitor as health_router
from routers import hosting as hosting_router, license_router as license_router from routers import hosting as hosting_router, license_router as license_router
from routers import retention as retention_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(chain_vault.router)
app.include_router(wallet_admin.router)
app.include_router(wallet_analysis.router) app.include_router(wallet_analysis.router)
app.include_router(wallet_memory.router) app.include_router(wallet_memory.router)
app.include_router(chain_vault.payment_router)
app.include_router(test_vectors.router) app.include_router(test_vectors.router)
app.include_router(metrics_router.router) app.include_router(metrics_router.router)
app.include_router(airdrop_router.router) app.include_router(airdrop_router.router)

View file

@ -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",
]

378
backend/plugins/defi.py Normal file
View file

@ -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=<your_solana_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=<your_hl_spot_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())

131
backend/plugins/sdk.py Normal file
View file

@ -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}"}

22
backend/requirements.lock Normal file
View file

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

View file

@ -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", []))

View file

@ -26,88 +26,30 @@ from core.config import cfg
from core.proof import PROOF_VERSION, get_proof from core.proof import PROOF_VERSION, get_proof
from .tx_broadcaster import broadcast as broadcast_tx from .tx_broadcaster import broadcast as broadcast_tx
from core.vault import WalletEntry, get_vault 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.chains import CHAINS, CHAIN_GROUPS, get_chains_for_tier
from wallet_engine.generator import get_generator from wallet_engine.generator import get_generator
logger = logging.getLogger("wp.chain_vault") 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"]) 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 ─────────────────────────────────────────────────────── # ── Models ───────────────────────────────────────────────────────
@ -227,17 +169,6 @@ class TxBroadcastRequest(BaseModel):
signed_tx: str = Field(...) 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): class AlertDeleteRequest(BaseModel):
@ -1537,715 +1468,7 @@ async def tx_broadcast(req: TxBroadcastRequest, request: Request):
except Exception as e: except Exception as e:
return {"broadcast": False, "error": str(e), "chain": req.chain} return {"broadcast": False, "error": str(e), "chain": req.chain}
# ─────────────────────────────────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════ # Admin endpoints (api-keys, alerts, webhooks, audit, bulk, 2FA, proof)
# API KEYS # were extracted to routers/wallet_admin.py in Phase 5 refactor.
# ═══════════════════════════════════════════════════════════════════ # ─────────────────────────────────────────────────────────────────────
@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"'},
)

View file

@ -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"'},
)

43
backend/tests/conftest.py Normal file
View file

@ -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"}

View file

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

View file

@ -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}"

127
backend/tests/test_vault.py Normal file
View file

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

View file

@ -0,0 +1,16 @@
# WalletPress Chain Definitions
# Users can add custom chains here. Values merge on top of defaults in chains.py.
#
# Format:
# <key>:
# name: <display name>
# family: <evm|bitcoin|solana|tron|ed25519|secp256k1_alt|ripple|substrate|cosmos|algorand|tezos|monero|stellar|ton|nano|filecoin>
# symbol: <ticker>
# slip44: <BIP44 coin type>
# hd_path: <BIP44 derivation path>
# decimals: <decimal places>
# address_pattern: <regex for validation>
# explorer_url: <block explorer URL>
# rpc_url: <RPC endpoint>
# curve: <secp256k1|ed25519|sr25519>
# native_token: <native currency symbol>

View file

@ -176,62 +176,61 @@ class WalletPress {
} }
public function supported_chains(): array { 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 [ return [
'solana' => ['label' => 'Solana', 'icon' => 'sol', 'wallet' => 'phantom'], 'btc' => ['label' => 'Bitcoin', 'icon' => 'btc', 'wallet' => '', 'backend' => 'btc'],
'ethereum' => ['label' => 'Ethereum', 'icon' => 'eth', 'wallet' => 'metamask'], 'btc-segwit'=> ['label' => 'Bitcoin SegWit', 'icon' => 'btc', 'wallet' => '', 'backend' => 'btc-segwit'],
'base' => ['label' => 'Base', 'icon' => 'base', 'wallet' => 'metamask'], 'eth' => ['label' => 'Ethereum', 'icon' => 'eth', 'wallet' => 'metamask','backend' => 'eth'],
'polygon' => ['label' => 'Polygon', 'icon' => 'matic', 'wallet' => 'metamask'], 'base' => ['label' => 'Base', 'icon' => 'base','wallet' => 'metamask','backend' => 'base'],
'bsc' => ['label' => 'BNB Chain', 'icon' => 'bnb', 'wallet' => 'metamask'], 'polygon' => ['label' => 'Polygon', 'icon' => 'matic','wallet'=> 'metamask','backend' => 'polygon'],
'arbitrum' => ['label' => 'Arbitrum', 'icon' => 'arb', 'wallet' => 'metamask'], 'arbitrum' => ['label' => 'Arbitrum', 'icon' => 'arb', 'wallet' => 'metamask','backend' => 'arbitrum'],
'optimism' => ['label' => 'Optimism', 'icon' => 'op', 'wallet' => 'metamask'], 'optimism' => ['label' => 'Optimism', 'icon' => 'op', 'wallet' => 'metamask','backend' => 'optimism'],
'avalanche' => ['label' => 'Avalanche', 'icon' => 'avax', 'wallet' => 'metamask'], 'avalanche' => ['label' => 'Avalanche', 'icon' => 'avax','wallet' => 'metamask','backend' => 'avalanche'],
'bitcoin' => ['label' => 'Bitcoin', 'icon' => 'btc', 'wallet' => ''], 'bsc' => ['label' => 'BNB Chain', 'icon' => 'bnb', 'wallet' => 'metamask','backend' => 'bsc'],
'tron' => ['label' => 'Tron', 'icon' => 'trx', 'wallet' => ''], 'fantom' => ['label' => 'Fantom', 'icon' => 'ftm', 'wallet' => 'metamask','backend' => 'fantom'],
'fantom' => ['label' => 'Fantom', 'icon' => 'ftm', 'wallet' => 'metamask'], 'gnosis' => ['label' => 'Gnosis', 'icon' => 'xdai','wallet' => 'metamask','backend' => 'gnosis'],
'gnosis' => ['label' => 'Gnosis', 'icon' => 'xdai', 'wallet' => 'metamask'], 'celo' => ['label' => 'Celo', 'icon' => 'celo','wallet' => 'metamask','backend' => 'celo'],
'celo' => ['label' => 'Celo', 'icon' => 'celo', 'wallet' => 'metamask'], 'scroll' => ['label' => 'Scroll', 'icon' => 'scr', 'wallet' => 'metamask','backend' => 'scroll'],
'scroll' => ['label' => 'Scroll', 'icon' => 'scr', 'wallet' => 'metamask'], 'zksync' => ['label' => 'zkSync Era', 'icon' => 'zk', 'wallet' => 'metamask','backend' => 'zksync'],
'zksync' => ['label' => 'zkSync Era', 'icon' => 'zk', 'wallet' => 'metamask'], 'blast' => ['label' => 'Blast', 'icon' => 'blast','wallet'=> 'metamask','backend' => 'blast'],
'blast' => ['label' => 'Blast', 'icon' => 'blast', 'wallet' => 'metamask'], 'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask','backend' => 'mantle'],
'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask'], 'linea' => ['label' => 'Linea', 'icon' => 'linea','wallet'=> 'metamask','backend' => 'linea'],
'linea' => ['label' => 'Linea', 'icon' => 'linea', 'wallet' => 'metamask'], 'metis' => ['label' => 'Metis', 'icon' => 'metis','wallet'=> 'metamask','backend' => 'metis'],
'metis' => ['label' => 'Metis', 'icon' => 'metis', 'wallet' => 'metamask'], 'opbnb' => ['label' => 'opBNB', 'icon' => 'bnb', 'wallet' => 'metamask','backend' => 'opbnb'],
'opbnb' => ['label' => 'opBNB', 'icon' => 'bnb', 'wallet' => 'metamask'], 'core' => ['label' => 'Core Chain', 'icon' => 'core','wallet' => 'metamask','backend' => 'core'],
'manta' => ['label' => 'Manta', 'icon' => 'manta', 'wallet' => 'metamask'], 'frax' => ['label' => 'Fraxchain', 'icon' => 'frax','wallet' => 'metamask','backend' => 'frax'],
'starknet' => ['label' => 'StarkNet', 'icon' => 'strk', 'wallet' => 'metamask'], 'kava' => ['label' => 'Kava', 'icon' => 'kava','wallet' => 'metamask','backend' => 'kava'],
'polygon_zkevm' => ['label' => 'Polygon zkEVM', 'icon' => 'poly', 'wallet' => 'metamask'], 'moonbeam' => ['label' => 'Moonbeam', 'icon' => 'glmr','wallet' => 'metamask','backend' => 'moonbeam'],
'litecoin' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => ''], 'cronos' => ['label' => 'Cronos', 'icon' => 'cro', 'wallet' => 'metamask','backend' => 'cronos'],
'dogecoin' => ['label' => 'Dogecoin', 'icon' => 'doge', 'wallet' => ''], 'aurora' => ['label' => 'Aurora', 'icon' => 'aurora','wallet'=> 'metamask','backend' => 'aurora'],
'bitcoin_cash' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => ''], 'harmony' => ['label' => 'Harmony', 'icon' => 'one', 'wallet' => 'metamask','backend' => 'harmony'],
'cardano' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => ''], 'boba' => ['label' => 'Boba Network', 'icon' => 'boba','wallet' => 'metamask','backend' => 'boba'],
'polkadot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => ''], 'evmos' => ['label' => 'Evmos', 'icon' => 'evmos','wallet'=> 'metamask','backend' => 'evmos'],
'kusama' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => ''], 'sol' => ['label' => 'Solana', 'icon' => 'sol', 'wallet' => 'phantom', 'backend' => 'sol'],
'cosmos' => ['label' => 'Cosmos', 'icon' => 'atom', 'wallet' => ''], 'trx' => ['label' => 'TRON', 'icon' => 'trx', 'wallet' => '', 'backend' => 'trx'],
'osmosis' => ['label' => 'Osmosis', 'icon' => 'osmo', 'wallet' => ''], 'doge' => ['label' => 'Dogecoin', 'icon' => 'doge','wallet' => '', 'backend' => 'doge'],
'injective' => ['label' => 'Injective', 'icon' => 'inj', 'wallet' => ''], 'ltc' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => '', 'backend' => 'ltc'],
'ripple' => ['label' => 'Ripple', 'icon' => 'xrp', 'wallet' => ''], 'bch' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => '', 'backend' => 'bch'],
'stellar' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => ''], 'dash' => ['label' => 'Dash', 'icon' => 'dash','wallet' => '', 'backend' => 'dash'],
'near' => ['label' => 'NEAR', 'icon' => 'near', 'wallet' => ''], 'zec' => ['label' => 'Zcash', 'icon' => 'zec', 'wallet' => '', 'backend' => 'zec'],
'sui' => ['label' => 'Sui', 'icon' => 'sui', 'wallet' => ''], 'atom' => ['label' => 'Cosmos Hub', 'icon' => 'atom','wallet' => '', 'backend' => 'atom'],
'aptos' => ['label' => 'Aptos', 'icon' => 'apt', 'wallet' => ''], 'osmo' => ['label' => 'Osmosis', 'icon' => 'osmo','wallet' => '', 'backend' => 'osmo'],
'algorand' => ['label' => 'Algorand', 'icon' => 'algo', 'wallet' => ''], 'inj' => ['label' => 'Injective', 'icon' => 'inj', 'wallet' => '', 'backend' => 'inj'],
'tezos' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => ''], 'algo' => ['label' => 'Algorand', 'icon' => 'algo','wallet' => '', 'backend' => 'algo'],
'monero' => ['label' => 'Monero', 'icon' => 'xmr', 'wallet' => ''], 'xlm' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => '', 'backend' => 'xlm'],
'filecoin' => ['label' => 'Filecoin', 'icon' => 'fil', 'wallet' => ''], 'xrp' => ['label' => 'XRP', 'icon' => 'xrp', 'wallet' => '', 'backend' => 'xrp'],
'internet_computer' => ['label' => 'Internet Computer', 'icon' => 'icp', 'wallet' => ''], 'dot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => '', 'backend' => 'dot'],
'hedera' => ['label' => 'Hedera', 'icon' => 'hbar', 'wallet' => ''], 'ksm' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => '', 'backend' => 'ksm'],
'ton' => ['label' => 'TON', 'icon' => 'ton', 'wallet' => ''], 'ada' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => '', 'backend' => 'ada'],
'nano' => ['label' => 'Nano', 'icon' => 'nano', 'wallet' => ''], 'xmr' => ['label' => 'Monero', 'icon' => 'xmr', 'wallet' => '', 'backend' => 'xmr'],
'elrond' => ['label' => 'Elrond', 'icon' => 'egld', 'wallet' => ''], 'xtz' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => '', 'backend' => 'xtz'],
'casper' => ['label' => 'Casper', 'icon' => 'cspr', 'wallet' => ''], 'fil' => ['label' => 'Filecoin', 'icon' => 'fil', 'wallet' => '', 'backend' => 'fil'],
'zilliqa' => ['label' => 'Zilliqa', 'icon' => 'zil', 'wallet' => ''], 'near' => ['label' => 'NEAR', 'icon' => 'near','wallet' => '', 'backend' => 'near'],
'sei' => ['label' => 'Sei', 'icon' => 'sei', 'wallet' => ''], 'sui' => ['label' => 'Sui', 'icon' => 'sui', 'wallet' => '', 'backend' => 'sui'],
'juno' => ['label' => 'Juno', 'icon' => 'juno', 'wallet' => ''], 'apt' => ['label' => 'Aptos', 'icon' => 'apt', 'wallet' => '', 'backend' => 'apt'],
'kava' => ['label' => 'Kava', 'icon' => 'kava', 'wallet' => 'metamask'], 'ton' => ['label' => 'TON', 'icon' => 'ton', 'wallet' => '', 'backend' => 'ton'],
'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'],
]; ];
} }