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:
parent
b88212878a
commit
85d8ef5eac
34 changed files with 4713 additions and 854 deletions
23
backend/adapters/crewai.py
Normal file
23
backend/adapters/crewai.py
Normal 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
28
backend/adapters/eliza.py
Normal 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"}
|
||||
35
backend/adapters/langchain.py
Normal file
35
backend/adapters/langchain.py
Normal 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()]
|
||||
21
backend/adapters/openai_agents.py
Normal file
21
backend/adapters/openai_agents.py
Normal 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
|
||||
16
backend/adapters/vercel_ai.py
Normal file
16
backend/adapters/vercel_ai.py
Normal 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
|
||||
1
backend/agent/__init__.py
Normal file
1
backend/agent/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""WalletPress AI Wallet Agent — MCP-native, multi-provider, self-hosted."""
|
||||
245
backend/agent/detector.py
Normal file
245
backend/agent/detector.py
Normal 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
|
||||
200
backend/agent/orchestrator.py
Normal file
200
backend/agent/orchestrator.py
Normal 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
431
backend/agent/providers.py
Normal 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
36
backend/alembic.ini
Normal 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
56
backend/alembic/env.py
Normal 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()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
68
backend/alembic/versions/001_initial_schema.py
Normal file
68
backend/alembic/versions/001_initial_schema.py
Normal 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")
|
||||
169
backend/cli/verify_receipt.py
Normal file
169
backend/cli/verify_receipt.py
Normal 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
133
backend/core/db_pool.py
Normal 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
|
||||
66
backend/core/dependencies.py
Normal file
66
backend/core/dependencies.py
Normal 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
28
backend/core/event_bus.py
Normal 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
76
backend/core/response.py
Normal 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)
|
||||
755
backend/core/smart_wallet.py
Normal file
755
backend/core/smart_wallet.py
Normal 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
151
backend/core/x402_verify.py
Normal 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}
|
||||
|
|
@ -37,7 +37,7 @@ from core.rate_limit import RateLimitMiddleware
|
|||
from core.webhooks import get_webhook_deliverer
|
||||
from core.ip_allowlist import IPAllowlistMiddleware
|
||||
from core.license_check import LicenseMiddleware
|
||||
from routers import chain_vault, wallet_analysis, wallet_memory, test_vectors, metrics as metrics_router
|
||||
from routers import chain_vault, wallet_admin, wallet_analysis, wallet_memory, test_vectors, metrics as metrics_router
|
||||
from routers import airdrop as airdrop_router, health_monitor as health_router
|
||||
from routers import hosting as hosting_router, license_router as license_router
|
||||
from routers import retention as retention_router
|
||||
|
|
@ -313,9 +313,9 @@ async def global_exception_handler(request: Request, exc: Exception):
|
|||
|
||||
|
||||
app.include_router(chain_vault.router)
|
||||
app.include_router(wallet_admin.router)
|
||||
app.include_router(wallet_analysis.router)
|
||||
app.include_router(wallet_memory.router)
|
||||
app.include_router(chain_vault.payment_router)
|
||||
app.include_router(test_vectors.router)
|
||||
app.include_router(metrics_router.router)
|
||||
app.include_router(airdrop_router.router)
|
||||
|
|
|
|||
24
backend/plugins/__init__.py
Normal file
24
backend/plugins/__init__.py
Normal 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
378
backend/plugins/defi.py
Normal 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
131
backend/plugins/sdk.py
Normal 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
22
backend/requirements.lock
Normal 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
|
||||
94
backend/routers/_persistent_store.py
Normal file
94
backend/routers/_persistent_store.py
Normal 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", []))
|
||||
|
|
@ -26,88 +26,30 @@ from core.config import cfg
|
|||
from core.proof import PROOF_VERSION, get_proof
|
||||
from .tx_broadcaster import broadcast as broadcast_tx
|
||||
from core.vault import WalletEntry, get_vault
|
||||
from ._persistent_store import PersistentStore as _PersistentStore
|
||||
from wallet_engine.chains import CHAINS, CHAIN_GROUPS, get_chains_for_tier
|
||||
from wallet_engine.generator import get_generator
|
||||
|
||||
logger = logging.getLogger("wp.chain_vault")
|
||||
|
||||
|
||||
class _PersistentStore:
|
||||
"""SQLite-backed store for webhooks, alerts, and payments.
|
||||
|
||||
Survives server restarts. Each collection is a table with a JSON blob column.
|
||||
Lazy-initialized on first access.
|
||||
"""
|
||||
|
||||
_db: sqlite3.Connection | None = None
|
||||
|
||||
@classmethod
|
||||
def _get_db(cls) -> sqlite3.Connection:
|
||||
if cls._db is None:
|
||||
db_path: Path = cfg.data_dir / "chain_vault_store.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cls._db = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
cls._db.row_factory = sqlite3.Row
|
||||
cls._db.execute("PRAGMA journal_mode=WAL")
|
||||
cls._db.execute("PRAGMA busy_timeout=5000")
|
||||
cls._db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
return cls._db
|
||||
|
||||
@classmethod
|
||||
def all(cls, table: str) -> list[dict]:
|
||||
db = cls._get_db()
|
||||
rows = db.execute(f"SELECT data FROM {table} ORDER BY created_at DESC").fetchall()
|
||||
return [json.loads(r["data"]) for r in rows]
|
||||
|
||||
@classmethod
|
||||
def get(cls, table: str, item_id: str) -> dict | None:
|
||||
db = cls._get_db()
|
||||
row = db.execute(f"SELECT data FROM {table} WHERE id = ?", (item_id,)).fetchone()
|
||||
return json.loads(row["data"]) if row else None
|
||||
|
||||
@classmethod
|
||||
def put(cls, table: str, item: dict) -> None:
|
||||
db = cls._get_db()
|
||||
db.execute(
|
||||
f"INSERT OR REPLACE INTO {table} (id, data, created_at) VALUES (?, ?, ?)",
|
||||
(item["id"], json.dumps(item), item.get("created_at", time.time())),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@classmethod
|
||||
def delete(cls, table: str, item_id: str) -> None:
|
||||
db = cls._get_db()
|
||||
db.execute(f"DELETE FROM {table} WHERE id = ?", (item_id,))
|
||||
db.commit()
|
||||
|
||||
@classmethod
|
||||
def reload_webhooks(cls) -> None:
|
||||
"""Reload webhooks from DB into the live deliverer on startup."""
|
||||
from core.webhooks import get_webhook_deliverer
|
||||
deliverer = get_webhook_deliverer()
|
||||
webhooks = cls.all("webhooks")
|
||||
for wh in webhooks:
|
||||
if wh.get("active", True):
|
||||
deliverer.register(wh["id"], wh["url"], wh.get("secret", ""), wh.get("events", []))
|
||||
|
||||
router = APIRouter(prefix="/api/v1/chain-vault", tags=["Chain Vault"])
|
||||
|
||||
|
||||
def _require_totp(request: Request) -> None:
|
||||
"""Require valid TOTP code for admin operations.
|
||||
|
||||
Shared with wallet_admin.py — kept here so chain_vault.py can use
|
||||
it independently. If you change the logic, update both.
|
||||
"""
|
||||
from core.totp import get_totp_manager
|
||||
mgr = get_totp_manager()
|
||||
if not mgr.is_enabled():
|
||||
return
|
||||
code = request.headers.get("X-TOTP-Code", "")
|
||||
if not mgr.verify(code):
|
||||
raise HTTPException(status_code=401, detail="TOTP code required or invalid. Set X-TOTP-Code header.")
|
||||
|
||||
# ── Models ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -227,17 +169,6 @@ class TxBroadcastRequest(BaseModel):
|
|||
signed_tx: str = Field(...)
|
||||
|
||||
|
||||
class APIKeyCreateRequest(BaseModel):
|
||||
label: str = Field(...)
|
||||
scopes: list[str] = Field(default=["vault.read"])
|
||||
|
||||
|
||||
class AlertCreateRequest(BaseModel):
|
||||
wallet_id: str = Field(...)
|
||||
alert_type: str = Field(default="balance_change")
|
||||
threshold_usd: float = Field(default=0.0)
|
||||
channel: str = Field(default="webhook")
|
||||
config: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AlertDeleteRequest(BaseModel):
|
||||
|
|
@ -1537,715 +1468,7 @@ async def tx_broadcast(req: TxBroadcastRequest, request: Request):
|
|||
except Exception as e:
|
||||
return {"broadcast": False, "error": str(e), "chain": req.chain}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# API KEYS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/api-keys")
|
||||
async def create_api_key(req: APIKeyCreateRequest, request: Request):
|
||||
"""Create a new API key for programmatic access."""
|
||||
_audit("api_key.create", request, detail={"label": req.label, "scopes": req.scopes})
|
||||
ks = get_key_store()
|
||||
key_id, raw_key = ks.create(req.label, req.scopes)
|
||||
return {
|
||||
"api_key_id": key_id,
|
||||
"api_key": raw_key,
|
||||
"label": req.label,
|
||||
"scopes": req.scopes,
|
||||
"warning": "Save this key now. It will not be shown again.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api-keys")
|
||||
async def list_api_keys(request: Request):
|
||||
"""List all API keys (masked, keys not shown)."""
|
||||
ks = get_key_store()
|
||||
return {"api_keys": ks.list(), "note": "Full keys only shown once at creation."}
|
||||
|
||||
|
||||
@router.post("/api-keys/revoke")
|
||||
async def revoke_api_key(req: APIKeyCreateRequest, request: Request):
|
||||
"""Revoke an API key immediately."""
|
||||
_audit("api_key.revoke", request, resource=req.label)
|
||||
ks = get_key_store()
|
||||
ks.revoke(req.label)
|
||||
return {"revoked": True, "key_id": req.label}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# ALERTS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/alerts")
|
||||
async def create_alert(req: AlertCreateRequest, request: Request):
|
||||
"""Create a wallet alert for balance changes or on-chain events."""
|
||||
_audit("alert.create", request, detail={"type": req.alert_type, "wallet": req.wallet_id})
|
||||
alert_id = f"alert_{secrets.token_hex(4)}"
|
||||
alert = {
|
||||
"id": alert_id,
|
||||
"wallet_id": req.wallet_id,
|
||||
"type": req.alert_type,
|
||||
"threshold_usd": req.threshold_usd,
|
||||
"channel": req.channel,
|
||||
"config": req.config,
|
||||
"created_at": time.time(),
|
||||
"active": True,
|
||||
}
|
||||
_PersistentStore.put("alerts", alert)
|
||||
return {"alert_id": alert_id, **alert}
|
||||
|
||||
|
||||
@router.get("/alerts")
|
||||
async def list_alerts():
|
||||
"""List all configured alerts."""
|
||||
alerts = _PersistentStore.all("alerts")
|
||||
return {"alerts": alerts, "total": len(alerts)}
|
||||
|
||||
|
||||
@router.post("/alerts/delete")
|
||||
async def delete_alert(req: AlertDeleteRequest):
|
||||
"""Delete an alert."""
|
||||
_PersistentStore.delete("alerts", req.alert_id)
|
||||
return {"deleted": True, "alert_id": req.alert_id}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# WEBHOOKS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(req: WebhookCreateRequest, request: Request):
|
||||
"""Create a webhook for wallet events.
|
||||
|
||||
Events: wallet.generated, payment.received, gate.accessed,
|
||||
alert.triggered, wallet.rotated, wallet.swept
|
||||
"""
|
||||
_audit("webhook.create", request, detail={"events": req.events})
|
||||
webhook_id = f"wh_{secrets.token_hex(4)}"
|
||||
wh_secret = req.secret or secrets.token_hex(16)
|
||||
wh = {
|
||||
"id": webhook_id,
|
||||
"url": req.url,
|
||||
"events": req.events,
|
||||
"secret": wh_secret,
|
||||
"created_at": time.time(),
|
||||
"active": True,
|
||||
}
|
||||
_PersistentStore.put("webhooks", wh)
|
||||
|
||||
# Register with webhook deliverer for live delivery
|
||||
from core.webhooks import get_webhook_deliverer
|
||||
get_webhook_deliverer().register(webhook_id, req.url, wh_secret, req.events)
|
||||
|
||||
return {"webhook_id": webhook_id, **wh}
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks():
|
||||
"""List all configured webhooks."""
|
||||
webhooks = _PersistentStore.all("webhooks")
|
||||
return {"webhooks": webhooks, "total": len(webhooks)}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
async def delete_webhook(webhook_id: str):
|
||||
"""Delete a webhook."""
|
||||
_PersistentStore.delete("webhooks", webhook_id)
|
||||
from core.webhooks import get_webhook_deliverer
|
||||
get_webhook_deliverer().unregister(webhook_id)
|
||||
return {"deleted": True, "webhook_id": webhook_id}
|
||||
|
||||
|
||||
# ── Webhook Delivery Log ──────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/webhooks/deliveries")
|
||||
async def webhook_deliveries(limit: int = 50):
|
||||
"""View recent webhook delivery attempts with status and response codes."""
|
||||
from core.webhooks import delivery_log
|
||||
return {"deliveries": list(reversed(delivery_log))[:limit], "total": len(delivery_log)}
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook(webhook_id: str):
|
||||
"""Send a test event to verify webhook endpoint is working."""
|
||||
webhooks = _PersistentStore.all("webhooks")
|
||||
for wh in webhooks:
|
||||
if wh.get("id") == webhook_id:
|
||||
from core.webhooks import get_webhook_deliverer
|
||||
import asyncio
|
||||
asyncio.ensure_future(get_webhook_deliverer().emit("webhook.test", {
|
||||
"test": True,
|
||||
"webhook_id": webhook_id,
|
||||
"timestamp": time.time(),
|
||||
}))
|
||||
return {"sent": True, "note": "Test event sent. Check /webhooks/deliveries for status."}
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/retry")
|
||||
async def retry_webhook(webhook_id: str):
|
||||
"""Retry the last failed delivery for a webhook."""
|
||||
webhooks = _PersistentStore.all("webhooks")
|
||||
for wh in webhooks:
|
||||
if wh.get("id") == webhook_id:
|
||||
from core.webhooks import get_webhook_deliverer
|
||||
import asyncio
|
||||
asyncio.ensure_future(get_webhook_deliverer().emit("webhook.retry", {
|
||||
"retry": True,
|
||||
"webhook_id": webhook_id,
|
||||
}))
|
||||
return {"retried": True, "note": "Retry sent. Check /webhooks/deliveries for status."}
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# AUDIT TRAIL
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/audit-trail")
|
||||
async def audit_trail(limit: int = 100, action: str = ""):
|
||||
"""Get the immutable audit trail for all wallet operations.
|
||||
|
||||
Trust: The audit log is append-only. Every wallet generation,
|
||||
key export, rotation, and deletion is logged. Logs cannot be
|
||||
modified — only new entries can be appended.
|
||||
"""
|
||||
audit = get_audit()
|
||||
entries = audit.query(limit=limit, action=action)
|
||||
return {
|
||||
"audit_entries": entries,
|
||||
"total": len(entries),
|
||||
"note": "Audit log is append-only. Entries cannot be modified or deleted.",
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# BULK OPERATIONS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/bulk/filter")
|
||||
async def bulk_filter(req: BulkFilterRequest, request: Request):
|
||||
"""Filter wallets by custom criteria."""
|
||||
vault = get_vault()
|
||||
all_wallets = vault.list(limit=10000)
|
||||
results = all_wallets
|
||||
|
||||
for key, value in req.filters.items():
|
||||
if key == "chain":
|
||||
results = [w for w in results if w.chain == value]
|
||||
elif key == "label":
|
||||
results = [w for w in results if value.lower() in w.label.lower()]
|
||||
elif key == "tag":
|
||||
results = [w for w in results if value in w.tags]
|
||||
elif key == "has_balance":
|
||||
results = [w for w in results if (w.balance_usd > 0) == value]
|
||||
|
||||
return {
|
||||
"filtered": len(results),
|
||||
"filters_applied": req.filters,
|
||||
"wallets": [{"id": w.id, "chain": w.chain, "address": w.address, "label": w.label,
|
||||
"balance_usd": w.balance_usd} for w in results],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bulk/delete")
|
||||
async def bulk_delete(req: BulkDeleteRequest, request: Request):
|
||||
"""Delete multiple wallets at once."""
|
||||
_audit("bulk.delete", request, detail={"count": len(req.ids)})
|
||||
vault = get_vault()
|
||||
deleted = []
|
||||
for wid in req.ids:
|
||||
if vault.delete(wid):
|
||||
deleted.append(wid)
|
||||
return {
|
||||
"deleted": len(deleted),
|
||||
"total_requested": len(req.ids),
|
||||
"deleted_ids": deleted,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bulk/export")
|
||||
async def bulk_export(req: BulkExportRequest, request: Request):
|
||||
"""Export multiple wallets in various formats."""
|
||||
vault = get_vault()
|
||||
wallets = []
|
||||
if req.ids:
|
||||
for wid in req.ids:
|
||||
w = vault.get(wid)
|
||||
if w:
|
||||
wallets.append(w)
|
||||
else:
|
||||
wallets = vault.list(limit=10000)
|
||||
|
||||
if req.format == "csv":
|
||||
import csv as _csv
|
||||
import io as _io
|
||||
buf = _io.StringIO()
|
||||
writer = _csv.writer(buf)
|
||||
writer.writerow(["chain", "address", "label", "created_at"])
|
||||
for w in wallets:
|
||||
writer.writerow([w.chain, w.address, w.label, w.created_at])
|
||||
data = buf.getvalue()
|
||||
elif req.format == "env":
|
||||
lines = ["# WalletPress Export"]
|
||||
for w in wallets:
|
||||
lines.append(f"WALLET_{w.chain.upper()}_ADDRESS={w.address}")
|
||||
data = "\n".join(lines)
|
||||
else:
|
||||
data = json.dumps([{"chain": w.chain, "address": w.address, "label": w.label,
|
||||
"created_at": w.created_at} for w in wallets], indent=2)
|
||||
|
||||
return {
|
||||
"format": req.format,
|
||||
"count": len(wallets),
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# EXPORT (STANDARD)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
async def export_wallets(req: ExportRequest, request: Request):
|
||||
"""Export wallet data in multiple formats.
|
||||
|
||||
Formats: json (full data), csv (addresses), env (KEY=VALUE pairs).
|
||||
Private keys are NEVER included in exports.
|
||||
"""
|
||||
return await bulk_export(BulkExportRequest(format=req.format, ids=req.ids), request)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PAYMENT ROUTER (for crypto payment processing)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
payment_router = APIRouter(prefix="/api/v1/chain-vault", tags=["Payments"])
|
||||
|
||||
|
||||
class PaymentIntentRequest(BaseModel):
|
||||
wallet_id: str = Field(...)
|
||||
amount_usd: float = Field(...)
|
||||
chain: str = Field(default="solana")
|
||||
token: str = Field(default="USDC")
|
||||
description: str = Field(default="")
|
||||
|
||||
|
||||
class PaymentVerifyRequest(BaseModel):
|
||||
payment_id: str = Field(...)
|
||||
tx_hash: str = Field(default="")
|
||||
chain: str = Field(default="solana")
|
||||
|
||||
|
||||
@payment_router.post("/payments/create")
|
||||
async def create_payment_intent(req: PaymentIntentRequest, request: Request):
|
||||
"""Create a payment intent for crypto payment collection.
|
||||
|
||||
Returns the wallet address and amount for the user to send funds.
|
||||
"""
|
||||
_audit("payment.create", request, detail={"amount_usd": req.amount_usd, "chain": req.chain})
|
||||
vault = get_vault()
|
||||
wallet = vault.get(req.wallet_id)
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=404, detail="Wallet not found")
|
||||
|
||||
payment_id = f"pay_{secrets.token_hex(8)}"
|
||||
payment = {
|
||||
"id": payment_id,
|
||||
"payment_id": payment_id,
|
||||
"wallet_id": req.wallet_id,
|
||||
"address": wallet.address,
|
||||
"chain": req.chain,
|
||||
"amount_usd": req.amount_usd,
|
||||
"token": req.token,
|
||||
"description": req.description,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
}
|
||||
_PersistentStore.put("payments", payment)
|
||||
return {
|
||||
"payment": payment,
|
||||
"qr_data": f"{req.chain}:{wallet.address}?amount={req.amount_usd}",
|
||||
}
|
||||
|
||||
|
||||
@payment_router.post("/payments/verify")
|
||||
async def verify_payment(req: PaymentVerifyRequest, request: Request):
|
||||
"""Verify a crypto payment has been confirmed on-chain.
|
||||
|
||||
Checks the transaction status using available RPC endpoints.
|
||||
Without RPC configured, returns the recorded payment status.
|
||||
"""
|
||||
payment = _PersistentStore.get("payments", req.payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
payment["status"] = "confirmed" if req.tx_hash else "pending"
|
||||
payment["tx_hash"] = req.tx_hash or payment.get("tx_hash", "")
|
||||
payment["verified_at"] = time.time()
|
||||
_PersistentStore.put("payments", payment)
|
||||
return {"payment": payment, "verified": bool(req.tx_hash)}
|
||||
|
||||
|
||||
@payment_router.get("/payments/list")
|
||||
async def list_payments():
|
||||
"""List all payment intents."""
|
||||
payments = _PersistentStore.all("payments")
|
||||
return {"payments": payments, "total": len(payments)}
|
||||
|
||||
|
||||
@payment_router.get("/payments/{payment_id}")
|
||||
async def get_payment(payment_id: str):
|
||||
"""Get payment details by ID."""
|
||||
payment = _PersistentStore.get("payments", payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
return {"payment": payment}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# TOTP 2FA
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _require_totp(request: Request):
|
||||
"""Require valid TOTP code for admin operations."""
|
||||
from core.totp import get_totp_manager
|
||||
mgr = get_totp_manager()
|
||||
if not mgr.is_enabled():
|
||||
return
|
||||
code = request.headers.get("X-TOTP-Code", "")
|
||||
if not mgr.verify(code):
|
||||
raise HTTPException(status_code=401, detail="TOTP code required or invalid. Set X-TOTP-Code header.")
|
||||
|
||||
|
||||
@router.post("/admin/2fa/setup")
|
||||
async def setup_2fa(request: Request):
|
||||
"""Generate a new TOTP secret for 2FA.
|
||||
|
||||
Returns a QR URI compatible with Google Authenticator, Authy,
|
||||
1Password, Bitwarden, and any TOTP-compliant app.
|
||||
|
||||
Call POST /admin/2fa/verify with the code from your authenticator
|
||||
app to confirm setup, then call POST /admin/2fa/enable to activate.
|
||||
"""
|
||||
from core.totp import get_totp_manager
|
||||
mgr = get_totp_manager()
|
||||
result = mgr.setup()
|
||||
_audit("2fa.setup", request)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/admin/2fa/verify-setup")
|
||||
async def verify_2fa_setup(code: str, request: Request):
|
||||
"""Verify a TOTP code to confirm 2FA setup is working."""
|
||||
from core.totp import get_totp_manager
|
||||
mgr = get_totp_manager()
|
||||
if mgr.verify(code):
|
||||
mgr.enable()
|
||||
_audit("2fa.enable", request)
|
||||
return {"enabled": True, "message": "2FA is now active for all admin operations."}
|
||||
return {"enabled": False, "error": "Invalid TOTP code. Check your authenticator app."}
|
||||
|
||||
|
||||
@router.post("/admin/2fa/disable")
|
||||
async def disable_2fa(request: Request):
|
||||
"""Disable 2FA."""
|
||||
_require_totp(request)
|
||||
from core.totp import get_totp_manager
|
||||
mgr = get_totp_manager()
|
||||
mgr.disable()
|
||||
_audit("2fa.disable", request)
|
||||
return {"enabled": False}
|
||||
|
||||
|
||||
@router.get("/admin/2fa/status")
|
||||
async def status_2fa():
|
||||
"""Check whether 2FA is enabled."""
|
||||
from core.totp import get_totp_manager
|
||||
mgr = get_totp_manager()
|
||||
return {"enabled": mgr.is_enabled()}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# CONFIGURATION (for WP plugin to read)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config():
|
||||
"""Get WalletPress backend configuration and capabilities."""
|
||||
return {
|
||||
"version": cfg.version,
|
||||
"features": {
|
||||
"client_side_generation": True,
|
||||
"server_side_generation": True,
|
||||
"encrypted_vault": bool(cfg.vault_password),
|
||||
"rate_limiting": cfg.rate_limit_per_minute > 0,
|
||||
"audit_logging": True,
|
||||
"api_key_auth": True,
|
||||
"telemetry": False,
|
||||
"open_source": True,
|
||||
},
|
||||
"standards": ["BIP39", "BIP32", "BIP44", "BIP49", "BIP84"],
|
||||
"encryption": "AES-256-GCM + Argon2id" if cfg.vault_password else "plaintext",
|
||||
"max_batch_size": cfg.max_wallets_per_request,
|
||||
"proof_of_generation": True,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PROOF OF GENERATION (Immutable Wallet Attestations)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/proof/commit")
|
||||
async def proof_commit(request: Request, chain: str = "local"):
|
||||
"""Manually commit all pending attestations to a Merkle root.
|
||||
|
||||
Optionally commit to Arweave or Ethereum for permanent on-chain storage.
|
||||
|
||||
This is the Big Idea. Every wallet gets a cryptographic birth
|
||||
certificate. The Merkle root is committed and can be sealed
|
||||
on a blockchain for permanent, immutable timestamping.
|
||||
|
||||
Chains:
|
||||
local — store in SQLite (instant, free)
|
||||
arweave — commit to Arweave permaweb (~$0.000001, set WP_POF_ARWEAVE_KEY_PATH)
|
||||
ethereum — commit to Ethereum mainnet (~$5-50 gas, set WP_POF_ETH_RPC + WP_POF_ETH_PRIVATE_KEY)
|
||||
"""
|
||||
proof = get_proof()
|
||||
root_hash, count = proof.compute_merkle_root()
|
||||
if not root_hash:
|
||||
return {"committed": False, "reason": "No pending attestations"}
|
||||
|
||||
effective_chain = "local"
|
||||
if chain in ("arweave", "ethereum"):
|
||||
effective_chain = chain
|
||||
|
||||
result = proof.commit(root_hash, count, commitment_chain=effective_chain)
|
||||
_audit("proof.commit", request, detail={"root_hash": root_hash[:16], "count": count, "chain": effective_chain})
|
||||
|
||||
return {
|
||||
"committed": True,
|
||||
"root_hash": root_hash,
|
||||
"attestation_count": count,
|
||||
"chain": effective_chain,
|
||||
"commitment_tx": result.get("commitment_tx", ""),
|
||||
"verification_url": result.get("verification_url", ""),
|
||||
"merkle_proofs_saved": True,
|
||||
"message": f"Merkle root committed to {effective_chain}. {count} attestations sealed." if effective_chain == "local"
|
||||
else f"Merkle root committed to {effective_chain}. TX: {result.get('commitment_tx', 'pending')}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proof/provenance/{wallet_id}")
|
||||
async def proof_provenance(wallet_id: str):
|
||||
"""Get the complete cryptographic provenance for a wallet.
|
||||
|
||||
Returns the full Merkle proof path so anyone can independently
|
||||
verify that this wallet was part of a committed root.
|
||||
|
||||
This is the wallet's complete BIRTH CERTIFICATE + PROOF OF INCLUSION.
|
||||
"""
|
||||
vault = get_vault()
|
||||
wallet = vault.get(wallet_id)
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=404, detail="Wallet not found")
|
||||
proof = get_proof()
|
||||
result = proof.get_proof_by_wallet(wallet_id)
|
||||
if not result.get("exists"):
|
||||
return {"wallet_id": wallet_id, "attested": False, "message": "No attestation found"}
|
||||
return {
|
||||
"wallet_id": wallet_id,
|
||||
"address": wallet.address,
|
||||
"chain": wallet.chain,
|
||||
"attested": True,
|
||||
"provenance": {
|
||||
"created_at": result["created_at"],
|
||||
"code_version": result["code_version"],
|
||||
"leaf_hash": result["leaf_hash"],
|
||||
"merkle_root": result["root_hash"],
|
||||
"merkle_proof_verified": result["merkle_proof_verified"],
|
||||
"commitment": result["root"],
|
||||
},
|
||||
"verification_instructions": [
|
||||
"To verify: reconstruct the Merkle root using the leaf hash and proof path",
|
||||
"1. Start with the leaf hash",
|
||||
"2. For each step in proof_path, hash leaf + sibling (or sibling + leaf) based on is_left",
|
||||
"3. Compare the result with the committed root_hash",
|
||||
"4. Check the root was committed to the chain: local/arweave/ethereum",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proof/verify/{wallet_id}")
|
||||
async def proof_verify(wallet_id: str, include_key: bool = False):
|
||||
"""Verify a wallet's Proof of Generation attestation.
|
||||
|
||||
Returns the full provenance of the wallet including:
|
||||
- When it was created
|
||||
- Which code version generated it
|
||||
- The public key hash (proves key hasn't changed)
|
||||
- The Merkle root it was committed under
|
||||
- Whether the attestation is still valid
|
||||
|
||||
This is the wallet's BIRTH CERTIFICATE.
|
||||
"""
|
||||
vault = get_vault()
|
||||
wallet = vault.get(wallet_id)
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=404, detail="Wallet not found")
|
||||
|
||||
proof = get_proof()
|
||||
result = proof.verify(wallet_id, wallet.address, wallet.public_key)
|
||||
|
||||
if include_key:
|
||||
result["public_key"] = wallet.public_key
|
||||
|
||||
return {
|
||||
"wallet_id": wallet_id,
|
||||
"address": wallet.address,
|
||||
"chain": wallet.chain,
|
||||
"verification": result,
|
||||
"trust_notes": [
|
||||
"This attestation proves the wallet existed at a specific time",
|
||||
f"Code version: {PROOF_VERSION}",
|
||||
"Public key hash is a one-way function — we don't store the raw key",
|
||||
"Merkle root can be cross-referenced on-chain for immutable proof",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proof/roots")
|
||||
async def proof_roots(limit: int = 10):
|
||||
"""List recent Merkle roots committed for wallet attestations."""
|
||||
proof = get_proof()
|
||||
roots = proof.get_roots(limit)
|
||||
stats = proof.stats()
|
||||
return {
|
||||
"total_attestations": stats["total_attestations"],
|
||||
"total_roots": stats["merkle_roots"],
|
||||
"roots": roots,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proof/stats")
|
||||
async def proof_stats():
|
||||
"""Get Proof of Generation statistics."""
|
||||
proof = get_proof()
|
||||
return {
|
||||
"service": "walletpress-proof-of-generation",
|
||||
"version": PROOF_VERSION,
|
||||
"stats": proof.stats(),
|
||||
"commitment_options": [
|
||||
{"chain": "local", "description": "SQLite (always, free)", "status": "active"},
|
||||
{"chain": "arweave", "description": "Arweave permaweb (~$0.000001/write)", "status": "active"},
|
||||
{"chain": "ethereum", "description": "Ethereum mainnet (~$5-50 gas)", "status": "active"},
|
||||
],
|
||||
"verification_endpoint": "/api/v1/proof/verify/{wallet_id}",
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PAPER WALLET + BIRTH CERTIFICATE PDF
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/paper-wallet/{wallet_id}/pdf")
|
||||
async def paper_wallet_pdf(wallet_id: str, request: Request):
|
||||
"""Generate a printable paper wallet PDF.
|
||||
|
||||
Returns a PDF with:
|
||||
- Wallet address + QR code
|
||||
- Private key (requires admin scope)
|
||||
- Public key
|
||||
- Derivation path
|
||||
- Proof of Generation hash
|
||||
- Security instructions
|
||||
|
||||
Print on durable paper. Store in a safe. Never share digitally.
|
||||
"""
|
||||
vault = get_vault()
|
||||
wallet = vault.get(wallet_id)
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=404, detail="Wallet not found")
|
||||
|
||||
auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "")
|
||||
include_key = False
|
||||
if auth:
|
||||
from core.auth import get_key_store
|
||||
ks = get_key_store()
|
||||
k = ks.verify(auth)
|
||||
include_key = k and ("wallet.admin" in k.scopes or "admin" in k.scopes)
|
||||
|
||||
private_key = ""
|
||||
if include_key and wallet.encrypted_key:
|
||||
if wallet.encrypted:
|
||||
from wallet_engine.generator import get_generator as get_gen
|
||||
private_key = get_gen().decrypt_key(wallet.encrypted_key)
|
||||
else:
|
||||
private_key = wallet.encrypted_key
|
||||
|
||||
from core.pdf import generate_paper_wallet
|
||||
pdf_bytes = generate_paper_wallet(
|
||||
address=wallet.address,
|
||||
chain=wallet.chain,
|
||||
private_key=private_key,
|
||||
public_key=wallet.public_key,
|
||||
derivation_path=wallet.derivation_path,
|
||||
created_at=wallet.created_at,
|
||||
label=wallet.label,
|
||||
)
|
||||
|
||||
from fastapi.responses import Response as FastResponse
|
||||
return FastResponse(
|
||||
content=pdf_bytes or b"No PDF generator available. Install reportlab.",
|
||||
media_type="application/pdf" if pdf_bytes else "text/plain",
|
||||
headers={"Content-Disposition": f'attachment; filename="wallet_{wallet_id[:8]}.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wallet/{wallet_id}/birth-certificate")
|
||||
async def wallet_birth_certificate(wallet_id: str):
|
||||
"""Generate a Wallet Birth Certificate — printable provenance document.
|
||||
|
||||
This is unique to WalletPress. It cryptographically proves when and
|
||||
how the wallet was created, with a Merkle attestation linking this
|
||||
document to the immutable audit trail.
|
||||
|
||||
Print it. Store it with your will. Use it for compliance.
|
||||
"""
|
||||
vault = get_vault()
|
||||
wallet = vault.get(wallet_id)
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=404, detail="Wallet not found")
|
||||
|
||||
proof = get_proof()
|
||||
attest = proof.get_attestation(wallet_id)
|
||||
|
||||
from core.pdf import generate_birth_certificate
|
||||
pdf_bytes = generate_birth_certificate(
|
||||
wallet_id=wallet_id,
|
||||
address=wallet.address,
|
||||
chain=wallet.chain,
|
||||
public_key=wallet.public_key,
|
||||
created_at=wallet.created_at,
|
||||
proof_leaf=attest.get("leaf_hash", "") if attest else "",
|
||||
root_hash=attest.get("root_hash", "") if attest else "",
|
||||
committed=attest.get("committed", False) if attest else False,
|
||||
)
|
||||
|
||||
from fastapi.responses import Response as FastResponse
|
||||
return FastResponse(
|
||||
content=pdf_bytes or b"No PDF generator available. Install reportlab.",
|
||||
media_type="application/pdf" if pdf_bytes else "text/plain",
|
||||
headers={"Content-Disposition": f'attachment; filename="birth_certificate_{wallet_id[:8]}.pdf"'},
|
||||
)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Admin endpoints (api-keys, alerts, webhooks, audit, bulk, 2FA, proof)
|
||||
# were extracted to routers/wallet_admin.py in Phase 5 refactor.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
786
backend/routers/wallet_admin.py
Normal file
786
backend/routers/wallet_admin.py
Normal 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
43
backend/tests/conftest.py
Normal 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"}
|
||||
372
backend/tests/test_chain_vault.py
Normal file
372
backend/tests/test_chain_vault.py
Normal 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)
|
||||
82
backend/tests/test_chains.py
Normal file
82
backend/tests/test_chains.py
Normal 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
127
backend/tests/test_vault.py
Normal 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
|
||||
16
backend/wallet_engine/chains.yaml
Normal file
16
backend/wallet_engine/chains.yaml
Normal 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>
|
||||
|
|
@ -176,62 +176,61 @@ class WalletPress {
|
|||
}
|
||||
|
||||
public function supported_chains(): array {
|
||||
// Maps plugin-friendly names to backend chain keys.
|
||||
// Only chains the backend actually supports (verified in tests/test_address_vectors.py).
|
||||
// See ADDRESS_GENERATION.md for the full chain truth table.
|
||||
return [
|
||||
'solana' => ['label' => 'Solana', 'icon' => 'sol', 'wallet' => 'phantom'],
|
||||
'ethereum' => ['label' => 'Ethereum', 'icon' => 'eth', 'wallet' => 'metamask'],
|
||||
'base' => ['label' => 'Base', 'icon' => 'base', 'wallet' => 'metamask'],
|
||||
'polygon' => ['label' => 'Polygon', 'icon' => 'matic', 'wallet' => 'metamask'],
|
||||
'bsc' => ['label' => 'BNB Chain', 'icon' => 'bnb', 'wallet' => 'metamask'],
|
||||
'arbitrum' => ['label' => 'Arbitrum', 'icon' => 'arb', 'wallet' => 'metamask'],
|
||||
'optimism' => ['label' => 'Optimism', 'icon' => 'op', 'wallet' => 'metamask'],
|
||||
'avalanche' => ['label' => 'Avalanche', 'icon' => 'avax', 'wallet' => 'metamask'],
|
||||
'bitcoin' => ['label' => 'Bitcoin', 'icon' => 'btc', 'wallet' => ''],
|
||||
'tron' => ['label' => 'Tron', 'icon' => 'trx', 'wallet' => ''],
|
||||
'fantom' => ['label' => 'Fantom', 'icon' => 'ftm', 'wallet' => 'metamask'],
|
||||
'gnosis' => ['label' => 'Gnosis', 'icon' => 'xdai', 'wallet' => 'metamask'],
|
||||
'celo' => ['label' => 'Celo', 'icon' => 'celo', 'wallet' => 'metamask'],
|
||||
'scroll' => ['label' => 'Scroll', 'icon' => 'scr', 'wallet' => 'metamask'],
|
||||
'zksync' => ['label' => 'zkSync Era', 'icon' => 'zk', 'wallet' => 'metamask'],
|
||||
'blast' => ['label' => 'Blast', 'icon' => 'blast', 'wallet' => 'metamask'],
|
||||
'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask'],
|
||||
'linea' => ['label' => 'Linea', 'icon' => 'linea', 'wallet' => 'metamask'],
|
||||
'metis' => ['label' => 'Metis', 'icon' => 'metis', 'wallet' => 'metamask'],
|
||||
'opbnb' => ['label' => 'opBNB', 'icon' => 'bnb', 'wallet' => 'metamask'],
|
||||
'manta' => ['label' => 'Manta', 'icon' => 'manta', 'wallet' => 'metamask'],
|
||||
'starknet' => ['label' => 'StarkNet', 'icon' => 'strk', 'wallet' => 'metamask'],
|
||||
'polygon_zkevm' => ['label' => 'Polygon zkEVM', 'icon' => 'poly', 'wallet' => 'metamask'],
|
||||
'litecoin' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => ''],
|
||||
'dogecoin' => ['label' => 'Dogecoin', 'icon' => 'doge', 'wallet' => ''],
|
||||
'bitcoin_cash' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => ''],
|
||||
'cardano' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => ''],
|
||||
'polkadot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => ''],
|
||||
'kusama' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => ''],
|
||||
'cosmos' => ['label' => 'Cosmos', 'icon' => 'atom', 'wallet' => ''],
|
||||
'osmosis' => ['label' => 'Osmosis', 'icon' => 'osmo', 'wallet' => ''],
|
||||
'injective' => ['label' => 'Injective', 'icon' => 'inj', 'wallet' => ''],
|
||||
'ripple' => ['label' => 'Ripple', 'icon' => 'xrp', 'wallet' => ''],
|
||||
'stellar' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => ''],
|
||||
'near' => ['label' => 'NEAR', 'icon' => 'near', 'wallet' => ''],
|
||||
'sui' => ['label' => 'Sui', 'icon' => 'sui', 'wallet' => ''],
|
||||
'aptos' => ['label' => 'Aptos', 'icon' => 'apt', 'wallet' => ''],
|
||||
'algorand' => ['label' => 'Algorand', 'icon' => 'algo', 'wallet' => ''],
|
||||
'tezos' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => ''],
|
||||
'monero' => ['label' => 'Monero', 'icon' => 'xmr', 'wallet' => ''],
|
||||
'filecoin' => ['label' => 'Filecoin', 'icon' => 'fil', 'wallet' => ''],
|
||||
'internet_computer' => ['label' => 'Internet Computer', 'icon' => 'icp', 'wallet' => ''],
|
||||
'hedera' => ['label' => 'Hedera', 'icon' => 'hbar', 'wallet' => ''],
|
||||
'ton' => ['label' => 'TON', 'icon' => 'ton', 'wallet' => ''],
|
||||
'nano' => ['label' => 'Nano', 'icon' => 'nano', 'wallet' => ''],
|
||||
'elrond' => ['label' => 'Elrond', 'icon' => 'egld', 'wallet' => ''],
|
||||
'casper' => ['label' => 'Casper', 'icon' => 'cspr', 'wallet' => ''],
|
||||
'zilliqa' => ['label' => 'Zilliqa', 'icon' => 'zil', 'wallet' => ''],
|
||||
'sei' => ['label' => 'Sei', 'icon' => 'sei', 'wallet' => ''],
|
||||
'juno' => ['label' => 'Juno', 'icon' => 'juno', 'wallet' => ''],
|
||||
'kava' => ['label' => 'Kava', 'icon' => 'kava', 'wallet' => 'metamask'],
|
||||
'moonbeam' => ['label' => 'Moonbeam', 'icon' => 'glmr', 'wallet' => 'metamask'],
|
||||
'cronos' => ['label' => 'Cronos', 'icon' => 'cro', 'wallet' => 'metamask'],
|
||||
'harmony' => ['label' => 'Harmony', 'icon' => 'one', 'wallet' => 'metamask'],
|
||||
'aurora' => ['label' => 'Aurora', 'icon' => 'aurora', 'wallet' => 'metamask'],
|
||||
'btc' => ['label' => 'Bitcoin', 'icon' => 'btc', 'wallet' => '', 'backend' => 'btc'],
|
||||
'btc-segwit'=> ['label' => 'Bitcoin SegWit', 'icon' => 'btc', 'wallet' => '', 'backend' => 'btc-segwit'],
|
||||
'eth' => ['label' => 'Ethereum', 'icon' => 'eth', 'wallet' => 'metamask','backend' => 'eth'],
|
||||
'base' => ['label' => 'Base', 'icon' => 'base','wallet' => 'metamask','backend' => 'base'],
|
||||
'polygon' => ['label' => 'Polygon', 'icon' => 'matic','wallet'=> 'metamask','backend' => 'polygon'],
|
||||
'arbitrum' => ['label' => 'Arbitrum', 'icon' => 'arb', 'wallet' => 'metamask','backend' => 'arbitrum'],
|
||||
'optimism' => ['label' => 'Optimism', 'icon' => 'op', 'wallet' => 'metamask','backend' => 'optimism'],
|
||||
'avalanche' => ['label' => 'Avalanche', 'icon' => 'avax','wallet' => 'metamask','backend' => 'avalanche'],
|
||||
'bsc' => ['label' => 'BNB Chain', 'icon' => 'bnb', 'wallet' => 'metamask','backend' => 'bsc'],
|
||||
'fantom' => ['label' => 'Fantom', 'icon' => 'ftm', 'wallet' => 'metamask','backend' => 'fantom'],
|
||||
'gnosis' => ['label' => 'Gnosis', 'icon' => 'xdai','wallet' => 'metamask','backend' => 'gnosis'],
|
||||
'celo' => ['label' => 'Celo', 'icon' => 'celo','wallet' => 'metamask','backend' => 'celo'],
|
||||
'scroll' => ['label' => 'Scroll', 'icon' => 'scr', 'wallet' => 'metamask','backend' => 'scroll'],
|
||||
'zksync' => ['label' => 'zkSync Era', 'icon' => 'zk', 'wallet' => 'metamask','backend' => 'zksync'],
|
||||
'blast' => ['label' => 'Blast', 'icon' => 'blast','wallet'=> 'metamask','backend' => 'blast'],
|
||||
'mantle' => ['label' => 'Mantle', 'icon' => 'mnt', 'wallet' => 'metamask','backend' => 'mantle'],
|
||||
'linea' => ['label' => 'Linea', 'icon' => 'linea','wallet'=> 'metamask','backend' => 'linea'],
|
||||
'metis' => ['label' => 'Metis', 'icon' => 'metis','wallet'=> 'metamask','backend' => 'metis'],
|
||||
'opbnb' => ['label' => 'opBNB', 'icon' => 'bnb', 'wallet' => 'metamask','backend' => 'opbnb'],
|
||||
'core' => ['label' => 'Core Chain', 'icon' => 'core','wallet' => 'metamask','backend' => 'core'],
|
||||
'frax' => ['label' => 'Fraxchain', 'icon' => 'frax','wallet' => 'metamask','backend' => 'frax'],
|
||||
'kava' => ['label' => 'Kava', 'icon' => 'kava','wallet' => 'metamask','backend' => 'kava'],
|
||||
'moonbeam' => ['label' => 'Moonbeam', 'icon' => 'glmr','wallet' => 'metamask','backend' => 'moonbeam'],
|
||||
'cronos' => ['label' => 'Cronos', 'icon' => 'cro', 'wallet' => 'metamask','backend' => 'cronos'],
|
||||
'aurora' => ['label' => 'Aurora', 'icon' => 'aurora','wallet'=> 'metamask','backend' => 'aurora'],
|
||||
'harmony' => ['label' => 'Harmony', 'icon' => 'one', 'wallet' => 'metamask','backend' => 'harmony'],
|
||||
'boba' => ['label' => 'Boba Network', 'icon' => 'boba','wallet' => 'metamask','backend' => 'boba'],
|
||||
'evmos' => ['label' => 'Evmos', 'icon' => 'evmos','wallet'=> 'metamask','backend' => 'evmos'],
|
||||
'sol' => ['label' => 'Solana', 'icon' => 'sol', 'wallet' => 'phantom', 'backend' => 'sol'],
|
||||
'trx' => ['label' => 'TRON', 'icon' => 'trx', 'wallet' => '', 'backend' => 'trx'],
|
||||
'doge' => ['label' => 'Dogecoin', 'icon' => 'doge','wallet' => '', 'backend' => 'doge'],
|
||||
'ltc' => ['label' => 'Litecoin', 'icon' => 'ltc', 'wallet' => '', 'backend' => 'ltc'],
|
||||
'bch' => ['label' => 'Bitcoin Cash', 'icon' => 'bch', 'wallet' => '', 'backend' => 'bch'],
|
||||
'dash' => ['label' => 'Dash', 'icon' => 'dash','wallet' => '', 'backend' => 'dash'],
|
||||
'zec' => ['label' => 'Zcash', 'icon' => 'zec', 'wallet' => '', 'backend' => 'zec'],
|
||||
'atom' => ['label' => 'Cosmos Hub', 'icon' => 'atom','wallet' => '', 'backend' => 'atom'],
|
||||
'osmo' => ['label' => 'Osmosis', 'icon' => 'osmo','wallet' => '', 'backend' => 'osmo'],
|
||||
'inj' => ['label' => 'Injective', 'icon' => 'inj', 'wallet' => '', 'backend' => 'inj'],
|
||||
'algo' => ['label' => 'Algorand', 'icon' => 'algo','wallet' => '', 'backend' => 'algo'],
|
||||
'xlm' => ['label' => 'Stellar', 'icon' => 'xlm', 'wallet' => '', 'backend' => 'xlm'],
|
||||
'xrp' => ['label' => 'XRP', 'icon' => 'xrp', 'wallet' => '', 'backend' => 'xrp'],
|
||||
'dot' => ['label' => 'Polkadot', 'icon' => 'dot', 'wallet' => '', 'backend' => 'dot'],
|
||||
'ksm' => ['label' => 'Kusama', 'icon' => 'ksm', 'wallet' => '', 'backend' => 'ksm'],
|
||||
'ada' => ['label' => 'Cardano', 'icon' => 'ada', 'wallet' => '', 'backend' => 'ada'],
|
||||
'xmr' => ['label' => 'Monero', 'icon' => 'xmr', 'wallet' => '', 'backend' => 'xmr'],
|
||||
'xtz' => ['label' => 'Tezos', 'icon' => 'xtz', 'wallet' => '', 'backend' => 'xtz'],
|
||||
'fil' => ['label' => 'Filecoin', 'icon' => 'fil', 'wallet' => '', 'backend' => 'fil'],
|
||||
'near' => ['label' => 'NEAR', 'icon' => 'near','wallet' => '', 'backend' => 'near'],
|
||||
'sui' => ['label' => 'Sui', 'icon' => 'sui', 'wallet' => '', 'backend' => 'sui'],
|
||||
'apt' => ['label' => 'Aptos', 'icon' => 'apt', 'wallet' => '', 'backend' => 'apt'],
|
||||
'ton' => ['label' => 'TON', 'icon' => 'ton', 'wallet' => '', 'backend' => 'ton'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue