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