docs: apply fleet-template (16-artifact scaffold)
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / security (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / license (push) Waiting to run
CI / ai-review (push) Waiting to run

Adds missing standard artifacts:
- README.md (if missing)
- AGENTS.md (AI agent contract)
- PLAN.md (current sprint)
- STATUS.md (where we are)
- DEVELOPMENT.md (dev workflow)
- DEPLOYMENT.md (deploy procedure)
- TESTING.md (test strategy)
- DECISIONS.md (ADR index + templates)
- .github/CODEOWNERS
- .github/workflows/ci.yml

Preserves all existing artifacts.

Refs: RugMunchMedia/fleet-template
This commit is contained in:
Crypto Rug Munch 2026-07-02 02:07:06 +07:00
commit e13bd4d774
203 changed files with 31140 additions and 0 deletions

126
backend/.env.example Normal file
View file

@ -0,0 +1,126 @@
# WalletPress Backend Configuration
# Copy to .env and customize before deploying to production
# Server
WP_HOST=0.0.0.0
WP_PORT=8010
# Data directory (persistent storage)
WP_DATA_DIR=/data
# Security (REQUIRED for production)
WP_ADMIN_KEY=change-me-to-a-random-string-at-least-32-chars
WP_VAULT_PASSWORD=change-me-to-a-strong-password-min-20-chars
# CORS (comma-separated origins, e.g. http://localhost:8010,https://example.com)
# WARNING: Setting * allows ANY website to call your API
WP_CORS_ORIGINS=http://localhost:8010
# Rate limiting
WP_RATE_LIMIT=60
WP_MAX_BATCH=100
# RPC endpoints (optional overrides — defaults are public nodes)
# WP_RPC_ETH=https://eth.llamarpc.com
# WP_RPC_SOL=https://api.mainnet-beta.solana.com
# WP_RPC_TRX=https://api.trongrid.io
# WP_RPC_BTC=https://blockchain.info
# Set WP_RPC_{CHAIN_UPPER} to override any default RPC URL
# Proof of Generation (PoF) — On-Chain Attestations
# WP_POF_AUTO_COMMIT=1 # Enable auto-commit (default: on)
# WP_POF_COMMIT_INTERVAL=3600 # Seconds between commits (default: 1 hour)
# WP_POF_ARWEAVE_KEY_PATH=/path/to/arweave-key.json # Arweave wallet for permanent storage
# WP_POF_ETH_RPC=https://eth.llamarpc.com # Ethereum RPC for on-chain anchoring
# WP_POF_ETH_PRIVATE_KEY=0x... # ETH private key (has ETH for gas)
# ── AI Wallet Agent ────────────────────────────────────────
# 20 providers supported. Set provider name + its API key.
# Check startup logs for full provider table.
#
# Quick start (pick ONE):
# WP_AI_PROVIDER=openrouter
# WP_AI_API_KEY=sk-or-v1-...
#
# Or use a provider-specific key (injected automatically):
# WP_AI_PROVIDER=openai # WP_AI_OPENAI_KEY
# WP_AI_PROVIDER=anthropic # WP_AI_ANTHROPIC_KEY
# WP_AI_PROVIDER=google # WP_AI_GEMINI_KEY
# WP_AI_PROVIDER=groq # WP_AI_GROQ_KEY
# WP_AI_PROVIDER=deepseek # WP_AI_DEEPSEEK_KEY
# WP_AI_PROVIDER=perplexity # WP_AI_PERPLEXITY_KEY
# WP_AI_PROVIDER=ollama # No key needed (local)
# WP_AI_PROVIDER=github # WP_AI_GITHUB_KEY (free with GitHub Copilot)
# WP_AI_PROVIDER=custom # WP_AI_CUSTOM_BASE_URL + WP_AI_CUSTOM_KEY
#
# Available: openai, anthropic, google, xai, mistral, openrouter, groq,
# cerebras, deepseek, together, fireworks, deepinfra, perplexity,
# cohere, replicate, hyperbolic, github, cloudflare, ollama, custom
WP_AI_PROVIDER=openrouter
WP_AI_API_KEY=
WP_AI_MODEL= # Optional: override default model for provider
# Provider-specific API keys (alternative to WP_AI_API_KEY):
# WP_AI_OPENAI_KEY=sk-...
# WP_AI_ANTHROPIC_KEY=sk-ant-...
# WP_AI_GEMINI_KEY=AIza...
# WP_AI_XAI_KEY=...
# WP_AI_MISTRAL_KEY=...
# WP_AI_OPENROUTER_KEY=sk-or-v1-...
# WP_AI_GROQ_KEY=gsk_...
# WP_AI_CEREBRAS_KEY=...
# WP_AI_DEEPSEEK_KEY=sk-...
# WP_AI_TOGETHER_KEY=...
# WP_AI_FIREWORKS_KEY=...
# WP_AI_DEEPINFRA_KEY=...
# WP_AI_PERPLEXITY_KEY=pplx-...
# WP_AI_COHERE_KEY=...
# WP_AI_REPLICATE_KEY=r8_...
# WP_AI_HYPERBOLIC_KEY=...
# WP_AI_GITHUB_KEY=ghp_... (GitHub PAT, free with Copilot)
# WP_AI_CLOUDFLARE_KEY=...
# WP_AI_CLOUDFLARE_URL=... (https://api.cloudflare.com/client/v4/accounts/{id}/ai/v1)
# WP_AI_OLLAMA_URL=http://localhost:11434/v1 (auto-detected if running)
# WP_AI_OLLAMA_MODEL=llama3.2
# WP_AI_CUSTOM_BASE_URL=https://your-endpoint.com/v1
# WP_AI_CUSTOM_MODEL=your-model
# Standalone MCP Server (walletpress-mcp package)
# WP_API_URL=http://localhost:8010
# WP_API_KEY=wp_...
# WALLETPRESS_BACKEND=/path/to/walletpress/backend
# Referral Revenue (baked into swap/defi plugins)
# WP_REF_JUPITER=rugmunch
# WP_REF_HYPERLIQUID=rugmunch
# WP_REF_GMGN=rugmunch
# WP_REF_ODINBOT=x5pyi0
# WP_REF_BANANA=rugmunch
# WP_REF_AXIOM=@crmuncher
# WP_REF_PADRE=crm
# Revenue Model — defaults to freemium (we earn referral kickback)
# Set to "self" for paid/enterprise users who keep their own referral revenue
# WP_REF_REVENUE_MODE=freemium
# Jupiter fee wallet (Solana address that receives 50bps referral fees)
# Set this to your fee collection wallet for Jupiter swap revenue
# WP_REF_JUPITER_WALLET=
# Prediction Market Revenue
# Polymarket builder code (bytes32) — register at polymarket.com/settings
# Revenue: up to 100bps taker / 50bps maker per trade
# WP_REF_POLYMARKET_BUILDER=
# Myriad builder code — contact Myriad team for whitelisting
# Revenue: up to 33% referral + ~1% distributor fee
# WP_REF_MYRIAD_BUILDER=
# Azuro affiliate wallet — any EVM wallet address
# Revenue: GGR (Gross Gaming Revenue) share on every bet
# WP_REF_AZURO_AFFILIATE=
# 0x Swap API — affiliate fees on every EVM swap (150+ sources)
# WP_0X_API_KEY=your-0x-api-key
# WP_0X_FEE_RECIPIENT=your-wallet-for-fees
# WP_0X_FEE_BPS=50 # 0-1000 bps (0-10%). Default 50 = 0.5%

58
backend/.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
WP_ADMIN_KEY: ci-admin-key-for-testing
WP_VAULT_PASSWORD: ci-vault-password-for-testing
WP_DATA_DIR: /tmp/wp_ci_data
WP_RATE_LIMIT: "0"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.lock
pip install ruff mypy
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.lock
- name: Run tests
run: python3 -m pytest tests/ -v --tb=short --timeout=120
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install bandit gitleaks
- name: Bandit
run: bandit -r . -x tests
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

44
backend/Dockerfile Normal file
View file

@ -0,0 +1,44 @@
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libffi-dev libssl-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim AS runner
WORKDIR /app
# Install runtime deps (no gcc needed)
RUN apt-get update && apt-get install -y --no-install-recommends libssl-dev && rm -rf /var/lib/apt/lists/*
# Copy Python deps from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy app code
COPY . .
# Create data dir + set perms (as root)
RUN mkdir -p /data && chmod 755 /data
# Add entrypoint (as root) that fixes data perms then drops to walletpress user
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create non-root user
RUN groupadd -r walletpress && useradd -r -g walletpress -d /app -s /sbin/nologin walletpress
# Chown app + data dirs to walletpress
RUN chown -R walletpress:walletpress /app /data
USER walletpress
EXPOSE 8010
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8010/health')" || exit 1
ENTRYPOINT ["/entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8010"]

60
backend/README.md Normal file
View file

@ -0,0 +1,60 @@
# WalletPress
Multi-chain wallet generation engine supporting 55 blockchains.
MIT open source. BIP39/BIP32/BIP44 compatible.
## What It Does
Generates cryptographically secure wallets on 55 chains from a single API.
Each wallet is a full keypair — private key, public key, address — on your
choice of blockchain. Keys are derived from CSPRNG entropy per BIP39 standards
and can optionally be derived deterministically from a mnemonic phrase.
## Requirements
- Python 3.10+ (3.12 recommended)
- OpenSSL 3.x (for Argon2id encryption)
- SQLite 3.37+ (WAL mode)
- 1GB RAM minimum (4GB recommended for vault > 10,000 wallets)
## Build & Run
```bash
# Dependencies vary by OS. You'll need python3-dev, libssl-dev, build-essential.
# Platform-specific package names are not documented here.
pip install -r requirements.txt
# Generate credentials yourself:
# WP_ADMIN_KEY — 32+ byte hex string for API authentication
# WP_VAULT_PASSWORD — 20+ char passphrase for AES-256-GCM key encryption
# These have no defaults. The server will not start without them.
export WP_ADMIN_KEY=<your 32+ byte hex key>
export WP_VAULT_PASSWORD=<your 20+ char passphrase>
python main.py
```
## API
Docs at `http://localhost:8010/docs` when running.
## WordPress Plugin
See `wp-plugin/`. Requires a running WalletPress backend.
## Test Vectors
```bash
python scripts/verify_test_vectors.py
```
## License
MIT — the code is free. Making it work in production is your responsibility.
---
**walletpress.cc** — one-command deploy, pre-configured RPC, SSL, backups, monitoring.
Or use the hosted version at walletpress.cc/app — zero setup, $29/mo.

1
backend/VERSION Normal file
View file

@ -0,0 +1 @@
1.1.0

View file

@ -0,0 +1,23 @@
"""CrewAI Adapter — Use WalletPress tools in CrewAI agents."""
from __future__ import annotations
from crewai.tools import BaseTool
class WalletPressCrewTool(BaseTool):
name: str = ""
description: str = ""
_fn = None
def __init__(self, name: str, description: str, fn):
super().__init__(name=name, description=description)
self._fn = fn
def _run(self, **kwargs) -> str:
import json
result = self._fn(**kwargs) if callable(self._fn) else {"error": "no fn"}
return json.dumps(result, default=str)
def get_tools() -> list[BaseTool]:
from agent.mcp_server import mcp
return [WalletPressCrewTool(name=t.name, description=t.description or t.name, fn=t.fn)
for t in mcp._tool_manager.list_tools()]

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

@ -0,0 +1,28 @@
"""Eliza OS Adapter (plugin) — Use WalletPress tools from Eliza agents."""
from __future__ import annotations
# Eliza plugins import via: https://github.com/elizaos/eliza/tree/main/packages/plugin-goat
# This adapter provides the Eliza-compatible tool format for WalletPress.
def get_actions() -> list[dict]:
"""Get MCP tools as Eliza action format."""
from agent.mcp_server import mcp
actions = []
for t in mcp._tool_manager.list_tools():
actions.append({
"name": t.name,
"description": t.description or t.name,
"similes": [t.name.replace("_", " ")],
"supports_async": True,
"handler": lambda params, tool=t: _handle(tool.name, params),
})
return actions
def _handle(tool_name: str, params: dict) -> dict:
import asyncio
from agent.mcp_server import mcp
for t in mcp._tool_manager.list_tools():
if t.name == tool_name:
if asyncio.iscoroutinefunction(t.fn):
return asyncio.run(t.fn(**params))
return t.fn(**params)
return {"error": f"Tool {tool_name} not found"}

View file

@ -0,0 +1,35 @@
"""LangChain Adapter — Use WalletPress tools in LangChain agents."""
from __future__ import annotations
from langchain.tools import BaseTool
class WalletPressTool(BaseTool):
"""LangChain tool wrapping a WalletPress MCP tool."""
name: str = ""
description: str = ""
_fn = None
def __init__(self, name: str, description: str, fn, **kwargs):
super().__init__(name=name, description=description, **kwargs)
self._fn = fn
def _run(self, **kwargs) -> str:
import json
result = self._fn(**kwargs) if callable(self._fn) else {"error": "no fn"}
return json.dumps(result, default=str)
async def _arun(self, **kwargs) -> str:
import json
import asyncio
if asyncio.iscoroutinefunction(self._fn):
result = await self._fn(**kwargs)
else:
result = self._fn(**kwargs)
return json.dumps(result, default=str)
def get_tools() -> list[BaseTool]:
"""Get all WalletPress MCP tools as LangChain tools."""
from agent.mcp_server import mcp
return [WalletPressTool(name=t.name, description=t.description or t.name, fn=t.fn)
for t in mcp._tool_manager.list_tools()]

View file

@ -0,0 +1,21 @@
"""OpenAI Agents SDK Adapter — Use WalletPress tools with OpenAI Agents SDK."""
from __future__ import annotations
def get_tools() -> list[dict]:
"""Get all MCP tools as OpenAI function-calling format."""
from agent.mcp_server import mcp
functions = []
for t in mcp._tool_manager.list_tools():
functions.append({
"type": "function",
"function": {
"name": t.name,
"description": t.description or t.name,
"parameters": {
"type": "object",
"properties": {k: {"type": "string"} for k in (t.parameters or {}).keys()},
"required": list((t.parameters or {}).keys())[:3] if t.parameters else [],
},
},
})
return functions

View file

@ -0,0 +1,16 @@
"""Vercel AI SDK Adapter — Use WalletPress tools with Vercel AI SDK."""
from __future__ import annotations
def get_tools() -> list[dict]:
"""Get all MCP tools as Vercel AI SDK tool format."""
from agent.mcp_server import mcp
tools = {}
for t in mcp._tool_manager.list_tools():
tools[t.name] = {
"description": t.description or t.name,
"parameters": {
"type": "object",
"properties": {k: {"type": "string"} for k in (t.parameters or {}).keys()},
},
}
return tools

View file

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

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

@ -0,0 +1,245 @@
"""Anomaly Detection — Identifies suspicious wallet activity.
Checks performed:
- Unusually large transactions (>3x average)
- First-time interactions with new addresses
- Rapid-fire transactions (>5 in 1 minute)
- Unusual transaction timing (e.g. 3 AM in user's timezone)
- Dusting attacks (many tiny incoming transactions)
- Sudden balance drops
"""
from __future__ import annotations
import logging
import statistics
import time
logger = logging.getLogger("wp.agent.detector")
async def scan_wallet(address: str, chain: str = "eth") -> dict:
"""Scan a wallet for anomalous patterns.
Uses available on-chain data providers. Falls back gracefully
if the chain's RPC is not configured.
Args:
address: Wallet address to analyze
chain: Chain key (eth, sol, trx, btc, ...)
Returns:
dict with risk_score, anomalies list, and metadata
"""
from wallet_engine.chains import CHAINS
chain_info = CHAINS.get(chain.lower())
if not chain_info:
return {"error": f"Unsupported chain: {chain}", "anomalies": [], "risk_score": 0}
anomalies: list[dict] = []
warnings: list[str] = []
risk_score = 0
# 1. Fetch recent transactions (best-effort)
txs = await _fetch_recent_txs(chain, address)
if txs:
tx_anomalies = _analyze_transactions(txs)
anomalies.extend(tx_anomalies)
risk_score += len(tx_anomalies) * 15
# 2. Fetch balance (best-effort)
balance = await _fetch_balance(chain, address)
if balance is not None:
if balance > 0 and not txs:
warnings.append("Positive balance but no transaction history — possible dormant wallet")
if balance == 0 and txs:
warnings.append("Zero balance with transaction history — wallet may be drained")
else:
warnings.append(f"Could not fetch balance for {chain}:{address}")
# 3. Address format check
from wallet_engine.chains import validate_address as va
if not va(chain, address):
warnings.append("Address format is invalid")
risk_score += 30
# 4. Check if it looks like a known scam pattern
if _looks_like_dusting(txs):
anomalies.append({
"type": "dusting_attack",
"severity": "medium",
"description": "Wallet received multiple tiny transactions — possible dusting attack",
"count": sum(1 for t in txs if _is_dust(t)),
})
risk_score += 20
risk_score = min(risk_score, 100)
return {
"address": address,
"chain": chain,
"risk_score": risk_score,
"risk_level": _risk_label(risk_score),
"anomalies": anomalies,
"warnings": warnings,
"checked_at": time.time(),
}
def _risk_label(score: int) -> str:
if score >= 70:
return "critical"
if score >= 40:
return "high"
if score >= 20:
return "medium"
if score > 0:
return "low"
return "none"
async def _fetch_recent_txs(chain: str, address: str, limit: int = 50) -> list[dict]:
"""Fetch recent transactions for a wallet. Best-effort per chain."""
try:
import httpx
async with httpx.AsyncClient(timeout=10) as c:
if chain == "eth":
r = await c.get(f"https://api.etherscan.io/api?module=account&action=txlist&address={address}&sort=desc&offset={limit}")
data = r.json()
if data.get("status") == "1":
return data["result"]
elif chain == "sol":
r = await c.post("https://api.mainnet-beta.solana.com", json={
"jsonrpc": "2.0", "id": 1,
"method": "getSignaturesForAddress",
"params": [address, {"limit": min(limit, 50)}],
})
data = r.json()
if "result" in data:
return data["result"]
elif chain == "btc":
r = await c.get(f"https://blockchain.info/rawaddr/{address}?limit={limit}")
data = r.json()
if "txs" in data:
return data["txs"]
return []
except Exception as e:
logger.debug(f"Could not fetch txs for {chain}:{address}: {e}")
return []
async def _fetch_balance(chain: str, address: str) -> float | None:
"""Fetch wallet balance. Returns float or None on failure."""
try:
from routers.balance_fetcher import fetch_balance
result = await fetch_balance(chain, address)
return result.get("balance_decimal", 0)
except Exception as e:
logger.debug(f"Balance fetch failed for {chain}:{address}: {e}")
return None
def _analyze_transactions(txs: list[dict]) -> list[dict]:
"""Analyze a list of transactions for anomalies."""
anomalies: list[dict] = []
if not txs:
return anomalies
# Extract values
amounts = []
timestamps = []
unique_addresses: set[str] = set()
address_interactions: dict[str, int] = {}
for tx in txs[:100]:
# Parse value
value_str = tx.get("value", tx.get("amount", tx.get("lamports", "0")))
try:
val = int(value_str) if isinstance(value_str, str) else float(value_str)
except (ValueError, TypeError):
val = 0
amounts.append(val)
# Parse timestamp
ts = tx.get("timeStamp", tx.get("blockTime", tx.get("time", 0)))
try:
timestamps.append(int(ts))
except (ValueError, TypeError):
pass
# Track interaction addresses
for field in ("from", "to", "address"):
addr = tx.get(field, "")
if addr and len(addr) > 10:
unique_addresses.add(addr)
address_interactions[addr] = address_interactions.get(addr, 0) + 1
# Check 1: Unusually large transactions
if len(amounts) >= 3:
mean_amt = statistics.mean(amounts)
stdev_amt = statistics.stdev(amounts) if len(amounts) > 1 else 0
for i, amt in enumerate(amounts):
if stdev_amt > 0 and amt > mean_amt + 3 * stdev_amt:
anomalies.append({
"type": "large_transaction",
"severity": "high",
"description": f"Transaction {i} is {amt:.2f} ({amt / mean_amt:.1f}x the average of {mean_amt:.2f})",
"tx": txs[i].get("hash", txs[i].get("signature", ""))[:16],
})
# Check 2: Rapid-fire transactions (>5 in 1 minute)
if len(timestamps) >= 5:
timestamps.sort()
clusters = 0
for i in range(len(timestamps) - 4):
if timestamps[i + 4] - timestamps[i] <= 60:
clusters += 1
if clusters > 0:
anomalies.append({
"type": "rapid_transactions",
"severity": "medium",
"description": f"Found {clusters} clusters of 5+ transactions within 60 seconds",
})
# Check 3: Many unique first-time interactions
if len(unique_addresses) > 20:
anomalies.append({
"type": "many_counterparties",
"severity": "low",
"description": f"Wallet interacted with {len(unique_addresses)} unique addresses",
})
# Check 4: High volume of zero-value transactions
zero_count = sum(1 for a in amounts if a == 0)
if len(amounts) > 0 and zero_count / len(amounts) > 0.5:
anomalies.append({
"type": "zero_value_txs",
"severity": "low",
"description": f"{zero_count}/{len(amounts)} transactions have zero value — possible spam or dusting",
})
return anomalies
def _is_dust(tx: dict) -> bool:
"""Check if a transaction is a dusting attempt."""
value_str = tx.get("value", tx.get("amount", tx.get("lamports", "0")))
try:
val = int(value_str) if isinstance(value_str, str) else float(value_str)
return 0 < val < 1000 # Dust: very small amounts
except (ValueError, TypeError):
return False
def _looks_like_dusting(txs: list[dict]) -> bool:
"""Check if the wallet was targeted by a dusting attack."""
dust_count = sum(1 for tx in txs[-20:] if _is_dust(tx))
unique_senders = set()
for tx in txs[-20:]:
sender = tx.get("from", "")
if sender and _is_dust(tx):
unique_senders.add(sender)
# Dusting: many tiny txs from many different senders
return dust_count >= 5 and len(unique_senders) >= 3

1188
backend/agent/mcp_server.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,200 @@
"""Agent Orchestrator — Natural language → multi-step wallet operation plans.
The orchestrator uses the configured AI provider to translate plain English
instructions into structured plans. Plans are then executed by the MCP tools.
This is the "brain" of the AI Wallet Agent. It handles:
- Parsing intent from natural language
- Decomposing complex ops into step-by-step tool calls
- Confirmation/approval workflow
- Error recovery and rollback
"""
from __future__ import annotations
import json
import logging
from typing import Any
from agent.providers import get_provider, get_llm_client
logger = logging.getLogger("wp.agent.orchestrator")
SYSTEM_PROMPT = """You are the WalletPress AI Wallet Agent. You help users manage their crypto wallets across 55 blockchains.
You have these tools available to execute wallet operations:
- wallet_generate(chain, label, count) generate wallets
- wallet_from_mnemonic(mnemonic, chains) derive from seed phrase
- vault_list(chain, limit) list wallets
- vault_get(wallet_id) get wallet details + private key
- vault_delete(wallet_id) delete a wallet
- vault_stats() vault statistics
- balance_check(chain, address) check balance
- vault_balances(chain) all wallet balances
- wallet_rotate(wallet_id, reason) rotate keys
- wallet_sweep(from_wallet_id, to_address) sweep funds
- chains_list() list supported chains
- validate_address(chain, address) validate address format
- schedule_dca(chain, amount, frequency, to_address, label) set up DCA
- schedule_list() list scheduled tasks
- anomaly_scan(address, chain) scan for anomalies
- anomaly_monitor(wallet_id, webhook_url) monitor wallet
Your task: Analyze the user's request and create a step-by-step plan.
Return ONLY valid JSON with this structure:
{
"intent": "brief description of what the user wants",
"steps": [
{
"tool": "tool_name",
"args": {"arg1": "value1"},
"description": "what this step does"
}
],
"warnings": ["any security concerns"],
"needs_confirmation": true/false
}
Rules:
1. ALWAYS prefer existing wallets over generating new ones
2. Flag anything that involves moving funds for confirmation
3. If the request is ambiguous, ask clarifying questions in "warnings"
4. Keep private keys SAFE never expose them unnecessarily
5. For "show me everything" queries, use vault_list + vault_balances"""
def _call_llm(system: str, user: str) -> str:
"""Call the configured LLM and return the response text."""
provider = get_provider()
client = get_llm_client(provider)
model = provider.default_model
logger.info(f"Planning via {provider.name} ({model})")
try:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=0.1,
max_tokens=2000,
)
return resp.choices[0].message.content or ""
except Exception as e:
logger.error(f"LLM call failed: {e}")
return json.dumps({
"intent": "failed to plan",
"steps": [],
"warnings": [f"AI provider error: {e}. Check your WP_AI_PROVIDER and WP_AI_API_KEY configuration."],
"needs_confirmation": False,
"error": str(e),
})
def plan_operation(prompt: str, context: str = "") -> dict:
"""Take natural language and return a structured operation plan."""
user = prompt
if context:
user = f"Context:\n{context}\n\nRequest:\n{prompt}"
raw = _call_llm(SYSTEM_PROMPT, user)
# Strip markdown fences if present
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
raw = raw.rsplit("```", 1)[0]
raw = raw.strip()
try:
plan = json.loads(raw)
except json.JSONDecodeError:
# LLM didn't return valid JSON — return as explanation
return {
"intent": "raw response",
"steps": [],
"explanation": raw,
"warnings": ["Could not parse structured plan. Raw response shown above."],
"needs_confirmation": False,
}
return plan
def execute_plan(plan: list[dict] | str, confirm: bool = False) -> dict:
"""Execute a plan by calling MCP tools sequentially.
Args:
plan: List of steps or JSON string
confirm: If True, requires confirmation
"""
import ast
if isinstance(plan, str):
try:
plan_data = json.loads(plan)
except (json.JSONDecodeError, TypeError):
plan_data = ast.literal_eval(plan) if isinstance(plan, str) and plan.strip().startswith("[") else {"steps": []}
if isinstance(plan_data, dict):
plan = plan_data.get("steps", [])
else:
plan = plan_data
if not plan:
return {"error": "No steps in plan", "results": []}
if confirm:
return {
"status": "awaiting_confirmation",
"steps": [s.get("description", s.get("tool", "unknown")) for s in plan],
"message": "Confirm execution via agent_execute(plan=<same_plan>, confirm=False)",
}
results = []
for i, step in enumerate(plan):
tool_name = step.get("tool", "")
args = step.get("args", {})
description = step.get("description", tool_name)
logger.info(f"Step {i + 1}/{len(plan)}: {description}")
try:
result = _call_tool(tool_name, args)
results.append({"step": i, "tool": tool_name, "result": result, "success": True})
except Exception as e:
logger.error(f"Step {i + 1} failed: {e}")
results.append({"step": i, "tool": tool_name, "error": str(e), "success": False})
return {"status": "failed", "completed": i, "total": len(plan), "results": results, "error": f"Step '{description}' failed: {e}"}
return {"status": "completed", "total": len(plan), "results": results}
def _call_tool(tool_name: str, args: dict) -> Any:
"""Call an MCP tool function by name."""
import importlib
# Map tool names to their module functions
tool_map = {
"wallet_generate": ("agent.mcp_server", "wallet_generate"),
"wallet_from_mnemonic": ("agent.mcp_server", "wallet_from_mnemonic"),
"vault_list": ("agent.mcp_server", "vault_list"),
"vault_get": ("agent.mcp_server", "vault_get"),
"vault_delete": ("agent.mcp_server", "vault_delete"),
"vault_stats": ("agent.mcp_server", "vault_stats"),
"balance_check": ("agent.mcp_server", "balance_check"),
"vault_balances": ("agent.mcp_server", "vault_balances"),
"wallet_rotate": ("agent.mcp_server", "wallet_rotate"),
"wallet_sweep": ("agent.mcp_server", "wallet_sweep"),
"chains_list": ("agent.mcp_server", "chains_list"),
"validate_address": ("agent.mcp_server", "validate_address"),
"schedule_dca": ("agent.mcp_server", "schedule_dca"),
"schedule_list": ("agent.mcp_server", "schedule_list"),
"anomaly_scan": ("agent.mcp_server", "anomaly_scan"),
"anomaly_monitor": ("agent.mcp_server", "anomaly_monitor"),
}
mod_path, fn_name = tool_map.get(tool_name, (None, None))
if not mod_path:
raise ValueError(f"Unknown tool: {tool_name}")
mod = importlib.import_module(mod_path)
fn = getattr(mod, fn_name)
return fn(**args)

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

@ -0,0 +1,431 @@
"""AI Provider Configuration — 20 verified providers, BYOK, custom endpoints, auto-detect.
Every provider is verified against their official documentation to work
with the OpenAI Python SDK's standard interface:
client = OpenAI(api_key=key, base_url=url)
client.chat.completions.create(model=model, messages=[...])
The SDK adds /chat/completions to base_url automatically. Each config
below has a base_url such that {base_url}/chat/completions is the
correct chat endpoint for that provider.
Prints a table of available providers on startup so users always know
what's available.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class AIProvider:
name: str
base_url: str
api_key: str = ""
default_model: str = ""
env_key: str = ""
env_base_url: str = ""
env_model: str = ""
docs_url: str = ""
key_prefix: str = ""
notes: str = ""
supports_streaming: bool = True
def resolve(self) -> AIProvider:
key = os.getenv(self.env_key, self.api_key) if self.env_key else self.api_key
base = os.getenv(self.env_base_url, self.base_url) if self.env_base_url else self.base_url
model = os.getenv(self.env_model, self.default_model) if self.env_model else self.default_model
return AIProvider(
name=self.name,
base_url=base.rstrip("/"),
api_key=key,
default_model=model,
notes=self.notes,
docs_url=self.docs_url,
supports_streaming=self.supports_streaming,
)
def _ollama_auto_detect() -> Optional[AIProvider]:
"""Auto-detect a running Ollama instance on localhost or common ports."""
import socket
for port in [11434, 11435]:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
result = sock.connect_ex(("127.0.0.1", port))
sock.close()
if result == 0:
base = os.getenv("WP_AI_OLLAMA_URL", f"http://127.0.0.1:{port}/v1")
model = os.getenv("WP_AI_OLLAMA_MODEL", "")
return AIProvider(
name=f"Ollama (detected on :{port})",
base_url=base.rstrip("/"),
api_key=os.getenv("WP_AI_OLLAMA_KEY", "ollama"),
default_model=model or "",
notes="Auto-detected! Chat endpoint: /chat/completions",
supports_streaming=True,
)
return None
PROVIDERS: dict[str, AIProvider] = {
# ── BIG THREE ───────────────────────────────────────────
"openai": AIProvider(
name="OpenAI",
base_url="https://api.openai.com/v1",
default_model="gpt-4o",
env_key="WP_AI_OPENAI_KEY",
docs_url="https://platform.openai.com/api-keys",
key_prefix="sk-",
notes="Industry standard. gpt-4o, o3, o4-mini. Requires paid API key.",
),
"anthropic": AIProvider(
name="Anthropic (OpenAI Relay)",
base_url="https://api.anthropic.com/v1/openai",
default_model="claude-sonnet-4-20250514",
env_key="WP_AI_ANTHROPIC_KEY",
docs_url="https://console.anthropic.com/",
key_prefix="sk-ant-",
notes="Uses Anthropic's OpenAI-compatible relay at /v1/openai. Models: claude-sonnet-4, claude-haiku-3-5.",
),
"google": AIProvider(
name="Google Gemini (OpenAI Relay)",
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
default_model="gemini-2.5-flash",
env_key="WP_AI_GEMINI_KEY",
docs_url="https://aistudio.google.com/apikey",
key_prefix="AIza",
notes="Google's OpenAI-compatible endpoint. Free tier available. 1M context. Models: gemini-2.5-flash, gemini-2.5-pro.",
),
"xai": AIProvider(
name="xAI (Grok)",
base_url="https://api.x.ai/v1",
default_model="grok-2-1212",
env_key="WP_AI_XAI_KEY",
docs_url="https://console.x.ai/",
key_prefix="",
notes="Grok models. OpenAI compatible. grok-2, grok-3 available. Requires paid key.",
),
"mistral": AIProvider(
name="Mistral AI",
base_url="https://api.mistral.ai/v1",
default_model="mistral-large-latest",
env_key="WP_AI_MISTRAL_KEY",
docs_url="https://console.mistral.ai/api-keys/",
key_prefix="",
notes="European provider. Models: mistral-large-latest, mistral-small-latest, pixtral. Strong multilingual.",
),
# ── GATEWAYS / AGGREGATORS ──────────────────────────────
"openrouter": AIProvider(
name="OpenRouter",
base_url="https://openrouter.ai/api/v1",
default_model="openai/gpt-4o",
env_key="WP_AI_OPENROUTER_KEY",
docs_url="https://openrouter.ai/keys",
key_prefix="sk-or-v1-",
notes="300+ models from every provider. Many free models. Set model name as provider/model (e.g. openai/gpt-4o).",
),
# ── FAST INFERENCE ──────────────────────────────────────
"groq": AIProvider(
name="Groq",
base_url="https://api.groq.com/openai/v1",
default_model="llama-3.3-70b-versatile",
env_key="WP_AI_GROQ_KEY",
docs_url="https://console.groq.com/keys",
key_prefix="gsk_",
notes="Fastest inference. Free tier: 30 req/min. Models: llama-3.3-70b, deepseek-r1, mixtral. No logprobs.",
),
"cerebras": AIProvider(
name="Cerebras",
base_url="https://api.cerebras.ai/v1",
default_model="llama-3.3-70b",
env_key="WP_AI_CEREBRAS_KEY",
docs_url="https://inference.cerebras.ai/",
key_prefix="",
notes="Fastest inference (~1800 tok/s). Limited free tier. Models: llama-3.3-70b, llama-4-scout.",
),
# ── OPEN-SOURCE FOCUSED ─────────────────────────────────
"deepseek": AIProvider(
name="DeepSeek",
base_url="https://api.deepseek.com/v1",
default_model="deepseek-chat",
env_key="WP_AI_DEEPSEEK_KEY",
docs_url="https://platform.deepseek.com/api_keys",
key_prefix="sk-",
notes="Excellent coding. ~$0.14/M tokens. 1M context. Models: deepseek-chat (V3), deepseek-reasoner (R1).",
),
"together": AIProvider(
name="Together AI",
base_url="https://api.together.xyz/v1",
default_model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
env_key="WP_AI_TOGETHER_KEY",
docs_url="https://api.together.xyz/settings/api-keys",
key_prefix="",
notes="Broad open-source selection. Llama, DeepSeek, Qwen, Mixtral. Good free credits to start.",
),
"fireworks": AIProvider(
name="Fireworks AI",
base_url="https://api.fireworks.ai/inference/v1",
default_model="accounts/fireworks/models/llama-v3p3-70b-instruct",
env_key="WP_AI_FIREWORKS_KEY",
docs_url="https://fireworks.ai/api-keys",
key_prefix="",
notes="Fast open-source inference. Models prefixed with accounts/fireworks/models/. Free tier.",
),
"deepinfra": AIProvider(
name="DeepInfra",
base_url="https://api.deepinfra.com/v1/openai",
default_model="meta-llama/Meta-Llama-3.1-70B-Instruct",
env_key="WP_AI_DEEPINFRA_KEY",
docs_url="https://deepinfra.com/dash/api_keys",
key_prefix="",
notes="Very broad model selection. Llama, Qwen, DeepSeek, Mixtral, Phi. Serverless pricing.",
),
# ── SEARCH-AUGMENTED ────────────────────────────────────
"perplexity": AIProvider(
name="Perplexity",
base_url="https://api.perplexity.ai",
default_model="sonar-pro",
env_key="WP_AI_PERPLEXITY_KEY",
docs_url="https://docs.perplexity.ai/",
key_prefix="pplx-",
notes="Web-search augmented models. NO /v1 in base_url. Models: sonar-pro, sonar-deep-research. Paid API.",
),
# ── ADDITIONAL PROVIDERS ─────────────────────────────────
"cohere": AIProvider(
name="Cohere",
base_url="https://api.cohere.ai/v1",
default_model="command-r-plus",
env_key="WP_AI_COHERE_KEY",
docs_url="https://dashboard.cohere.com/api-keys",
key_prefix="",
notes="Enterprise-focused. Strong RAG. Models: command-r-plus, command-r. OpenAI-compatible chat endpoint.",
),
"replicate": AIProvider(
name="Replicate",
base_url="https://api.replicate.com/v1",
default_model="meta/meta-llama-3-70b-instruct",
env_key="WP_AI_REPLICATE_KEY",
docs_url="https://replicate.com/account/api-tokens",
key_prefix="r8_",
notes="Run open models on demand. Pay-per-second. Models: meta/meta-llama-3-70b-instruct, stability-ai/stable-diffusion.",
),
"hyperbolic": AIProvider(
name="Hyperbolic",
base_url="https://api.hyperbolic.xyz/v1",
default_model="meta-llama/Meta-Llama-3.1-70B-Instruct",
env_key="WP_AI_HYPERBOLIC_KEY",
docs_url="https://app.hyperbolic.xyz/settings",
key_prefix="",
notes="GPU cloud + inference. OpenAI compatible. Models: Llama, Qwen, DeepSeek. Free trial credits.",
),
# ── CLOUD PROVIDERS ──────────────────────────────────────
"github": AIProvider(
name="GitHub Models",
base_url="https://models.inference.ai.azure.com",
default_model="gpt-4o",
env_key="WP_AI_GITHUB_KEY",
docs_url="https://github.com/settings/tokens",
key_prefix="ghp_",
notes="FREE with GitHub Copilot. Use your GitHub PAT. Models: gpt-4o, gpt-4o-mini, DeepSeek-R1, Phi-4. Rate limited.",
),
"cloudflare": AIProvider(
name="Cloudflare Workers AI",
base_url="https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1",
default_model="@cf/meta/llama-3.3-70b-instruct",
env_key="WP_AI_CLOUDFLARE_KEY",
env_base_url="WP_AI_CLOUDFLARE_URL",
docs_url="https://developers.cloudflare.com/workers-ai/",
key_prefix="",
notes="Runs on Cloudflare's edge network. Set WP_AI_CLOUDFLARE_URL with your account_id baked in. Models: @cf/meta/llama-*.",
),
# ── LOCAL ────────────────────────────────────────────────
"ollama": AIProvider(
name="Ollama (Local)",
base_url="http://localhost:11434/v1",
default_model="llama3.2",
env_key="WP_AI_OLLAMA_KEY",
env_base_url="WP_AI_OLLAMA_URL",
env_model="WP_AI_OLLAMA_MODEL",
docs_url="https://ollama.ai/",
key_prefix="",
notes="FULLY LOCAL. No API key needed. Run any open model. Set WP_AI_OLLAMA_URL for remote instances.",
),
"custom": AIProvider(
name="Custom Endpoint",
base_url="https://your-endpoint.com/v1",
default_model="your-model-name",
env_key="WP_AI_CUSTOM_KEY",
env_base_url="WP_AI_CUSTOM_BASE_URL",
env_model="WP_AI_CUSTOM_MODEL",
docs_url="",
key_prefix="",
notes="Any OpenAI-compatible endpoint. Set: WP_AI_CUSTOM_BASE_URL, WP_AI_CUSTOM_KEY, WP_AI_CUSTOM_MODEL.",
),
}
# Sort: local first, then alphabetically
SORT_ORDER = {
"ollama": "00_ollama",
"custom": "01_custom",
}
def _get_provider(name: str | None = None) -> AIProvider:
"""Get the configured AI provider with auto-detection and fallbacks.
Resolution order:
1. Explicit name argument (e.g. get_provider('groq'))
2. WP_AI_PROVIDER env var
3. WP_AI_API_KEY + WP_AI_BASE_URL (legacy env vars) Custom
4. Auto-detect running Ollama instance on localhost:11434
5. OpenRouter (best free model availability)
"""
name = name or os.getenv("WP_AI_PROVIDER", "").lower().strip()
# Explicit name
if name:
provider = PROVIDERS.get(name)
if not provider:
available = ", ".join(sorted(PROVIDERS.keys()))
raise ValueError(
f"Unknown provider '{name}'. Available: {available}\n"
f"Set WP_AI_PROVIDER to one of the above, or WP_AI_API_KEY + WP_AI_BASE_URL for custom."
)
resolved = provider.resolve()
# Only raise on missing key if the provider actually requires keys
key_required = resolved.env_key and not resolved.api_key
custom_key = os.getenv("WP_AI_API_KEY", "")
if key_required and not custom_key:
# Don't raise — just resolve with empty key. The LLM call will
# fail with a clear auth error when actually used.
pass
# If custom_key is set but provider-specific key is not, use custom_key
if not resolved.api_key and custom_key:
resolved.api_key = custom_key
return resolved
# Legacy env vars
api_key = os.getenv("WP_AI_API_KEY", "")
base_url = os.getenv("WP_AI_BASE_URL", "")
if api_key and base_url:
return AIProvider(
name="Custom (Legacy)",
base_url=base_url.rstrip("/"),
api_key=api_key,
default_model=os.getenv("WP_AI_MODEL", ""),
notes="Configured via WP_AI_API_KEY + WP_AI_BASE_URL.",
).resolve()
# Auto-detect Ollama
ollama = _ollama_auto_detect()
if ollama:
return ollama
# Fallback: OpenRouter (best free tier)
return PROVIDERS["openrouter"].resolve()
def get_provider(name: str | None = None) -> AIProvider:
"""Get provider with friendly error wrapping."""
try:
return _get_provider(name)
except ValueError:
raise
except Exception as e:
raise ValueError(f"Failed to configure AI provider: {e}")
def test_connection(provider: AIProvider) -> dict:
"""Test the provider connection by listing available models.
Returns dict with success status, model count, and error message.
This is called when the user first configures a provider to validate
that the endpoint, key, and model are all correct.
"""
from openai import OpenAI, APIError, AuthenticationError, NotFoundError, RateLimitError
try:
client = OpenAI(api_key=provider.api_key, base_url=provider.base_url)
models = client.models.list()
model_list = [m.id for m in models][:20]
return {
"connected": True,
"provider": provider.name,
"base_url": provider.base_url,
"models_available": len(model_list),
"model_samples": model_list[:5],
}
except AuthenticationError as e:
return {
"connected": False,
"error": f"Authentication failed. Check your API key.\n Provider: {provider.name}\n URL: {provider.base_url}\n Key prefix: {provider.api_key[:12] if provider.api_key else '(empty)'}...\n Detail: {e}",
}
except NotFoundError:
return {
"connected": False,
"error": f"Endpoint not found. Check base_url.\n URL: {provider.base_url}\n Some providers don't support the /models endpoint but still work for chat.",
}
except RateLimitError as e:
return {
"connected": True,
"warning": f"Connected but rate limited: {e}",
"provider": provider.name,
}
except APIError as e:
return {
"connected": True,
"warning": f"Connected (API responded, status={e.status_code}): {e}",
"provider": provider.name,
}
except Exception as e:
return {
"connected": False,
"error": f"Connection failed: {type(e).__name__}: {e}",
}
def list_providers() -> str:
"""Return a formatted table of all available providers."""
lines = []
lines.append("" * 78)
lines.append(f" Available AI Providers ({len(PROVIDERS)})")
lines.append("" * 78)
lines.append(f" {'Name':<14} {'Default Model':<30} {'Key Env Var':<22}")
lines.append("" * 78)
for pname in sorted(PROVIDERS.keys()):
p = PROVIDERS[pname]
key_var = p.env_key or "(none)"
model = p.default_model[:28] if p.default_model else "(auto)"
lines.append(f" {pname:<14} {model:<30} {key_var:<22}")
lines.append("" * 78)
lines.append(" Set WP_AI_PROVIDER=<name> and the corresponding key env var.")
lines.append(" Or just WP_AI_API_KEY + WP_AI_BASE_URL for quick custom setup.")
lines.append(" Ollama is auto-detected if running on localhost:11434.")
lines.append("" * 78)
return "\n".join(lines)
def get_llm_client(provider: AIProvider):
"""Create an OpenAI-compatible client for the given provider."""
from openai import OpenAI
return OpenAI(api_key=provider.api_key, base_url=provider.base_url)
def get_embedding_client(provider: AIProvider):
"""Create an OpenAI-compatible client for embeddings."""
from openai import OpenAI
return OpenAI(api_key=provider.api_key, base_url=provider.base_url)

301
backend/agent/scheduler.py Normal file
View file

@ -0,0 +1,301 @@
"""Agent Scheduler — Recurring wallet tasks.
Runs in the background alongside the main app. Handles:
- Dollar-cost averaging: generate wallet + schedule funding
- Periodic balance checks with anomaly alerts
- Scheduled rotation (rotate wallet every N days)
- Monitoring triggers (large tx alerts)
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger("wp.agent.scheduler")
TASKS_FILE = Path(os.getenv("WP_DATA_DIR", "/data")) / "agent_tasks.json"
@dataclass
class ScheduledTask:
id: str
type: str # dca, sweep, monitor, rotate
chain: str
params: dict[str, Any] = field(default_factory=dict)
interval_seconds: int = 604800 # default: weekly
last_run: float = 0.0
next_run: float = 0.0
created_at: float = 0.0
enabled: bool = True
label: str = ""
class AgentScheduler:
"""Lightweight background scheduler for agent tasks.
Uses a background thread that checks for due tasks every 60 seconds.
Tasks are persisted to JSON for durability across restarts.
"""
def __init__(self):
self._tasks: dict[str, ScheduledTask] = {}
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._load()
def _path(self) -> Path:
p = Path(os.getenv("WP_DATA_DIR", "/data"))
p.mkdir(parents=True, exist_ok=True)
return p / "agent_tasks.json"
def _load(self):
path = self._path()
if path.exists():
try:
data = json.loads(path.read_text())
for k, v in data.items():
self._tasks[k] = ScheduledTask(**v)
logger.info(f"Loaded {len(self._tasks)} scheduled tasks")
except Exception as e:
logger.error(f"Failed to load tasks: {e}")
def _save(self):
path = self._path()
try:
path.write_text(json.dumps({k: v.__dict__ for k, v in self._tasks.items()}, indent=2, default=str))
except Exception as e:
logger.error(f"Failed to save tasks: {e}")
def add_task(self, task: ScheduledTask) -> str:
with self._lock:
self._tasks[task.id] = task
self._save()
logger.info(f"Scheduled task added: {task.id} ({task.type}/{task.chain})")
return task.id
def remove_task(self, task_id: str) -> bool:
with self._lock:
if task_id in self._tasks:
del self._tasks[task_id]
self._save()
return True
return False
def list_tasks(self) -> list[dict]:
with self._lock:
return [{
"id": t.id, "type": t.type, "chain": t.chain,
"label": t.label, "interval_seconds": t.interval_seconds,
"last_run": t.last_run, "next_run": t.next_run,
"enabled": t.enabled,
} for t in self._tasks.values()]
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="agent-scheduler")
self._thread.start()
logger.info("Agent scheduler started")
def stop(self):
self._running = False
def _run_loop(self):
while self._running:
now = time.time()
due: list[ScheduledTask] = []
with self._lock:
for t in self._tasks.values():
if t.enabled and t.next_run > 0 and now >= t.next_run:
due.append(t)
for task in due:
try:
self._execute_task(task)
task.last_run = now
task.next_run = now + task.interval_seconds
self._save()
except Exception as e:
logger.error(f"Task {task.id} failed: {e}")
time.sleep(60)
def _execute_task(self, task: ScheduledTask):
if task.type == "dca":
self._exec_dca(task)
elif task.type == "monitor":
self._exec_monitor(task)
elif task.type == "rotate":
self._exec_rotate(task)
def _exec_dca(self, task: ScheduledTask):
"""Execute a DCA task. If to_address is set, this is a REAL on-chain transfer
(not just intent). For EVM chains, uses wallet_sweep-like logic to broadcast.
For non-EVM, falls back to generating a destination wallet (legacy behavior).
"""
from wallet_engine.generator import get_generator
from core.vault import get_vault, WalletEntry
from core.config import cfg
vault = get_vault()
generator = get_generator(cfg.vault_password)
to_addr = task.params.get("to_address", "")
from_wallet_id = task.params.get("from_wallet_id", "")
label = task.label or f"dca_{task.chain}_{int(time.time())}"
# If both from_wallet_id and to_addr are set, this is a real DCA transfer.
# For EVM chains, broadcast via tx_broadcaster. For others, generate the
# destination wallet and rely on the user to fund it externally.
if from_wallet_id and to_addr:
from_wallet = vault.get(from_wallet_id)
if not from_wallet:
logger.warning(f"DCA: from_wallet {from_wallet_id} not found")
return
if from_wallet.chain != task.chain:
logger.warning(
f"DCA: wallet chain {from_wallet.chain} != task chain {task.chain}"
)
return
# For EVM: actually broadcast the transfer
from core.balance_fetcher import EVM_RPC
if task.chain in EVM_RPC:
# Lazy-import to avoid circular deps
from agent.mcp_server import _evm_sweep
result = _evm_sweep(from_wallet, to_addr, task.chain)
if result.get("status") == "broadcast":
logger.info(
f"DCA: Broadcast {task.amount} {task.chain}{to_addr} "
f"tx={result.get('tx_hash')}"
)
else:
logger.error(f"DCA: Broadcast failed: {result.get('error')}")
return
# Non-EVM: just log the intent (no native broadcast implementation yet)
logger.info(
f"DCA: Intent {task.amount} {task.chain}{to_addr} "
f"(non-EVM: not auto-broadcast, external signing required)"
)
return
# Legacy behavior: generate a destination wallet and let the user fund it
if to_addr:
logger.info(f"DCA: {task.amount} {task.chain}{to_addr} (intent only)")
else:
wallet = generator.generate(task.chain, label=label, tags=["dca"])
entry = WalletEntry(
id=f"wp_dca_{task.chain}_{int(time.time())}",
chain=wallet.chain, address=wallet.address, label=wallet.label,
tags=wallet.tags, created_at=wallet.created_at,
encrypted_key=wallet.private_key_hex if wallet.encrypted else "",
encrypted=wallet.encrypted,
)
vault.put(entry)
logger.info(f"DCA: Generated new {task.chain} wallet → {wallet.address}")
def _exec_monitor(self, task: ScheduledTask):
addr = task.params.get("address", "")
chain = task.chain
if addr:
import asyncio
from agent.detector import scan_wallet
result = asyncio.run(scan_wallet(addr, chain))
if result.get("anomalies"):
logger.warning(f"Anomaly detected for {addr}: {result['anomalies']}")
webhook = task.params.get("webhook_url", "")
if webhook:
import httpx
try:
httpx.post(webhook, json={"event": "anomaly", "address": addr, "result": result}, timeout=10)
except Exception as e:
logger.error(f"Webhook failed: {e}")
def _exec_rotate(self, task: ScheduledTask):
from wallet_engine.generator import get_generator
from core.vault import get_vault, WalletEntry
from core.config import cfg
vault = get_vault()
wallet_id = task.params.get("wallet_id", "")
if not wallet_id:
return
old = vault.get(wallet_id)
if not old:
logger.warning(f"Rotate task: wallet {wallet_id} not found")
return
generator = get_generator(cfg.vault_password)
new = generator.generate(old.chain, label=f"scheduled_rotate_{int(time.time())}", tags=["rotated"])
entry = WalletEntry(
id=f"wp_sched_rot_{int(time.time())}",
chain=new.chain, address=new.address, label=new.label,
tags=new.tags, created_at=new.created_at,
encrypted_key=new.private_key_hex if new.encrypted else "",
encrypted=new.encrypted,
)
vault.put(entry)
vault.record_rotation(old.id, new.address, reason="scheduled_rotation")
logger.info(f"Scheduled rotation: {old.id}{new.address}")
scheduler = AgentScheduler()
def add_dca_task(chain: str, amount: float, frequency: str = "weekly",
to_address: str = "", label: str = "") -> dict:
import secrets
freq_map = {"daily": 86400, "weekly": 604800, "biweekly": 1209600, "monthly": 2592000}
interval = freq_map.get(frequency, 604800)
now = time.time()
task = ScheduledTask(
id=f"dca_{secrets.token_hex(4)}",
type="dca",
chain=chain,
params={"amount": amount, "to_address": to_address},
interval_seconds=interval,
next_run=now + interval,
created_at=now,
label=label or f"DCA {amount} {chain} {frequency}",
)
scheduler.add_task(task)
return {
"task_id": task.id,
"type": "dca",
"chain": chain,
"amount": amount,
"frequency": frequency,
"next_run": task.next_run,
"label": task.label,
}
def add_monitor_task(wallet_id: str, webhook_url: str = "") -> dict:
import secrets
from core.vault import get_vault
vault = get_vault()
wallet = vault.get(wallet_id)
if not wallet:
return {"error": f"Wallet {wallet_id} not found"}
now = time.time()
task = ScheduledTask(
id=f"mon_{secrets.token_hex(4)}",
type="monitor",
chain=wallet.chain,
params={"address": wallet.address, "wallet_id": wallet_id, "webhook_url": webhook_url},
interval_seconds=3600,
next_run=now + 3600,
created_at=now,
label=f"Monitor {wallet.address[:12]}...",
)
scheduler.add_task(task)
return {
"task_id": task.id,
"type": "monitor",
"chain": wallet.chain,
"address": wallet.address,
"interval": "hourly",
"next_run": task.next_run,
}

36
backend/alembic.ini Normal file
View file

@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = sqlite:///./data/walletpress.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

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

@ -0,0 +1,56 @@
"""Alembic migration environment for WalletPress.
The app uses raw sqlite3 (not SQLAlchemy). Migrations use raw SQL
via op.execute(). This keeps the app free of SQLAlchemy overhead
while still benefiting from Alembic's migration management.
The sqlalchemy.url in alembic.ini is the fallback. This env.py
resolves cfg.db_path at runtime so migrations hit the same DB
the app uses (respecting WP_DATA_DIR).
Usage:
alembic revision --autogenerate -m "description"
alembic upgrade head
alembic downgrade -1
"""
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import create_engine, pool
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Resolve the actual database path from app config at runtime.
# Fall back to alembic.ini value if import fails (offline/CI).
try:
from core.config import cfg
db_url = f"sqlite:///{cfg.db_path}"
except Exception:
db_url = config.get_main_option("sqlalchemy.url")
def run_migrations_offline() -> None:
context.configure(url=db_url, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
# Ensure the parent directory exists (SQLAlchemy won't create it)
db_file = db_url.replace("sqlite:///", "")
Path(db_file).parent.mkdir(parents=True, exist_ok=True)
connectable = create_engine(db_url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,68 @@
"""Initial vault schema — wallets, rotations, FTS, version tracking.
Revises: None (first migration)
Create Date: 2026-06-30
"""
from typing import Sequence, Union
from alembic import op
revision: str = "001_initial_schema"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
chain TEXT NOT NULL,
address TEXT NOT NULL,
label TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
wallet_group TEXT DEFAULT '',
created_at REAL NOT NULL,
encrypted_key TEXT DEFAULT '',
public_key TEXT DEFAULT '',
derivation_path TEXT DEFAULT '',
hd_path TEXT DEFAULT '',
mnemonic_encrypted TEXT DEFAULT '',
encrypted INTEGER DEFAULT 0,
balance_usd REAL DEFAULT 0.0,
notes TEXT DEFAULT '',
updated_at REAL DEFAULT 0
)
""")
op.execute("CREATE INDEX IF NOT EXISTS idx_wallets_chain ON wallets(chain)")
op.execute("CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address)")
op.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS wallets_fts USING fts5(
id, chain, address, label, notes,
content='wallets',
content_rowid='rowid'
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS rotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet_id TEXT NOT NULL,
new_address TEXT NOT NULL,
rotated_at REAL NOT NULL,
reason TEXT DEFAULT '',
FOREIGN KEY(wallet_id) REFERENCES wallets(id)
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS _schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
""")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS rotations")
op.execute("DROP TABLE IF EXISTS wallets_fts")
op.execute("DROP TABLE IF EXISTS wallets")
op.execute("DROP TABLE IF EXISTS _schema_version")

1
backend/build.hash Normal file
View file

@ -0,0 +1 @@
c8c59b19976c4e63fdddbbf9e92ccc4c64743b2573d734ac9c52190bf8b526fb

View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""WalletPress Receipt Verifier — CLI tool to verify x402 order receipts.
Usage:
# Verify an order receipt
python3 verify_receipt.py --order-id x402_abc123 \\
--signature "base64signature..." \\
--pubkey "ed25519pubkeyhex"
# Verify a key deletion attestation
python3 verify_receipt.py --order-id x402_abc123 \\
--deletion-sig "base64signature..." \\
--pubkey "ed25519pubkeyhex" \\
--addresses addr1,addr2,addr3
# Fetch and verify from a running WalletPress instance
python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010
Trust: This tool is open source. It does NOT phone home. It does NOT
send your keys anywhere. It only verifies cryptographic signatures.
"""
import argparse
import hashlib
import sys
from typing import Any
try:
import httpx
except ImportError:
httpx = None # type: ignore
try:
from nacl.bindings import crypto_sign_open
except ImportError:
crypto_sign_open = None # type: ignore
def verify_receipt(order_id: str, chain: str, count: int, total_usd: float,
timestamp: float, signature: str, pubkey_hex: str) -> bool:
"""Verify an Ed25519-signed order receipt."""
if crypto_sign_open is None:
print("ERROR: pynacl is required. Install: pip install pynacl")
return False
message = f"walletpress:receipt:{order_id}:{chain}:{count}:{total_usd}:{timestamp}".encode()
import base64
sig_bytes = base64.b64decode(signature)
try:
crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex))
return True
except Exception:
return False
def verify_key_deletion(order_id: str, chain: str, count: int,
addresses: list[str], signature: str, pubkey_hex: str) -> bool:
"""Verify a key deletion attestation — proves keys were wiped."""
if crypto_sign_open is None:
print("ERROR: pynacl is required. Install: pip install pynacl")
return False
addr_hash = hashlib.sha256("".join(sorted(addresses)).encode()).hexdigest()[:16]
message = f"walletpress:key-deletion:{order_id}:{chain}:{count}:{addr_hash}:{int(timestamp)}".encode()
import base64
sig_bytes = base64.b64decode(signature)
try:
crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex))
return True
except Exception:
return False
def fetch_and_verify(server_url: str, order_id: str) -> dict[str, Any]:
"""Fetch order details from a WalletPress server and verify the receipt."""
if httpx is None:
return {"error": "httpx is required. Install: pip install httpx"}
try:
resp = httpx.get(f"{server_url}/api/v1/marketplace/verify-receipt", params={
"order_id": order_id,
}, timeout=10)
if resp.status_code == 404:
return {"error": f"Order {order_id} not found on {server_url}"}
data = resp.json()
return data
except httpx.RequestError as e:
return {"error": f"Failed to connect to {server_url}: {e}"}
def main():
parser = argparse.ArgumentParser(
description="WalletPress Receipt Verifier — prove your order was fulfilled",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Verify a receipt
python3 verify_receipt.py --order-id x402_abc123 --signature "sig" --pubkey "key"
# Verify key deletion
python3 verify_receipt.py --order-id x402_abc123 --deletion-sig "sig" --pubkey "key" --addresses addr1,addr2
# Fetch and verify from server
python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010
""",
)
parser.add_argument("--order-id", required=True, help="Order ID from the generate response")
parser.add_argument("--signature", help="Receipt signature (base64)")
parser.add_argument("--deletion-sig", help="Key deletion attestation signature (base64)")
parser.add_argument("--pubkey", help="Server's Ed25519 public key (hex)")
parser.add_argument("--addresses", help="Comma-separated wallet addresses (for deletion verification)")
parser.add_argument("--server", help="WalletPress server URL to fetch order details from")
parser.add_argument("--chain", default="sol", help="Chain the wallets were generated on")
parser.add_argument("--count", type=int, default=1, help="Number of wallets generated")
parser.add_argument("--total-usd", type=float, default=0, help="Total amount paid in USD")
parser.add_argument("--timestamp", type=float, default=0, help="Order timestamp (Unix)")
args = parser.parse_args()
if args.server:
result = fetch_and_verify(args.server, args.order_id)
if "error" in result:
print(f"FAIL: {result['error']}")
sys.exit(1)
print(f"Order: {result.get('order_id', '?')}")
print(f"Chain: {result.get('chain', '?')}")
print(f"Count: {result.get('count', '?')}")
print(f"Amount: ${result.get('amount_usd', '?')}")
print(f"Valid: {result.get('valid', '?')}")
print(f"Status: {'VERIFIED' if result.get('valid') else 'INVALID'}")
sys.exit(0 if result.get('valid') else 1)
if args.signature and args.pubkey:
if not args.timestamp:
print("ERROR: --timestamp is required for receipt verification")
sys.exit(1)
valid = verify_receipt(
args.order_id, args.chain, args.count, args.total_usd,
args.timestamp, args.signature, args.pubkey,
)
print(f"Receipt: {'VALID' if valid else 'INVALID'}")
print(f" Order: {args.order_id}")
print(f" Chain: {args.chain}")
print(f" Count: {args.count}")
print(f" Amount: ${args.total_usd}")
print(" Algorithm: Ed25519")
if not valid:
sys.exit(1)
if args.deletion_sig and args.pubkey and args.addresses:
addresses = [a.strip() for a in args.addresses.split(",")]
valid = verify_key_deletion(
args.order_id, args.chain, args.count, addresses,
args.deletion_sig, args.pubkey,
)
print(f"Key Deletion: {'VERIFIED' if valid else 'FAILED'}")
print(f" Order: {args.order_id}")
print(f" Addresses: {len(addresses)} wallets")
print(" Algorithm: Ed25519")
if not valid:
print(" WARNING: Key deletion attestation is INVALID. The server may have kept your keys.")
sys.exit(1)
print(" Keys were generated in memory and wiped. We did not store them.")
if not args.signature and not args.deletion_sig and not args.server:
print("ERROR: Provide --signature, --deletion-sig, or --server")
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()

160
backend/client_sdk.py Normal file
View file

@ -0,0 +1,160 @@
"""WalletPress Python SDK — pip install walletpress-sdk
Usage:
from walletpress_sdk import WalletPress
wp = WalletPress(api_key="wp_...", base_url="https://your-walletpress.com")
wallet = wp.generate_wallet(chain="eth", label="my wallet")
print(wallet.address, wallet.private_key)
vault = wp.list_vault()
for w in vault:
print(w.address)
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from typing import Optional
import httpx
@dataclass
class Wallet:
id: str
chain: str
address: str
label: str = ""
tags: list[str] = field(default_factory=list)
private_key: str = ""
mnemonic: str = ""
public_key: str = ""
derivation_path: str = ""
encrypted: bool = False
balance_usd: float = 0.0
created_at: float = 0.0
qr_png_base64: str = ""
@dataclass
class VaultStats:
total_wallets: int
by_chain: dict
total_value_usd: float = 0.0
class WalletPress:
"""WalletPress API client — generate and manage wallets on 55 chains."""
def __init__(self, api_key: str, base_url: str = "http://localhost:8010"):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self._client = httpx.Client(headers={"X-API-Key": api_key, "Content-Type": "application/json"})
def _get(self, path: str, params: dict = None):
r = self._client.get(f"{self.base_url}{path}", params=params)
r.raise_for_status()
return r.json()
def _post(self, path: str, data: dict = None):
r = self._client.post(f"{self.base_url}{path}", json=data or {})
r.raise_for_status()
return r.json()
# ── Wallet Generation ──────────────────────────────────
def generate_wallet(self, chain: str, label: str = "", tags: list[str] = None,
count: int = 1, include_qr: bool = False) -> list[Wallet]:
data = {"chain": chain, "label": label, "tags": tags or [], "count": count}
result = self._post("/api/v1/chain-vault/generate", data)
return [Wallet(**{
**w, "qr_png_base64": w.get("qr_png_base64", ""),
"private_key": w.get("private_key", ""),
}) for w in result.get("wallets", [])]
def generate_from_mnemonic(self, mnemonic: str, chains: list[str] = None) -> dict:
return self._post("/api/v1/chain-vault/from-mnemonic", {
"mnemonic": mnemonic, "chains": chains or [],
})
def import_key(self, chain: str, private_key: str, label: str = "") -> Wallet:
result = self._post("/api/v1/chain-vault/import", {
"chain": chain, "private_key": private_key, "label": label,
})
return Wallet(**result.get("wallet", {}))
def import_csv(self, csv_path: str) -> dict:
"""Import wallets from a CSV file. Returns import report."""
with open(csv_path, "rb") as f:
r = self._client.post(
f"{self.base_url}/api/v1/import/batch",
files={"file": ("import.csv", f, "text/csv")},
)
return r.json()
# ── Vault ──────────────────────────────────────────────
def list_vault(self, chain: str = "", search: str = "",
limit: int = 100, offset: int = 0) -> list[Wallet]:
result = self._get("/api/v1/chain-vault/vault", {
"chain": chain, "search": search, "limit": limit, "offset": offset,
})
return [Wallet(**w) for w in result.get("wallets", [])]
def get_wallet(self, wallet_id: str, include_key: bool = False,
include_qr: bool = False) -> Optional[Wallet]:
result = self._get(f"/api/v1/chain-vault/vault/{wallet_id}", {
"include_key": str(include_key).lower(),
"include_qr": str(include_qr).lower(),
})
return Wallet(**result) if "id" in result else None
def get_portfolio(self) -> dict:
return self._get("/api/v1/portfolio")
def get_health(self) -> dict:
return self._get("/api/v1/chain-vault/healthz")
def get_stats(self) -> VaultStats:
result = self._get("/api/v1/chain-vault/stats")
return VaultStats(**result.get("vault", {}))
# ── Validation ─────────────────────────────────────────
def validate_address(self, address: str, chain: str) -> dict:
return self._get(f"/api/v1/chain-vault/validate/{chain}/{address}")
def seed_repair(self, partial_mnemonic: str) -> dict:
return self._post("/api/v1/tool/seed-repair", {"mnemonic": partial_mnemonic})
def get_compatibility(self, wallet_id: str) -> dict:
return self._get(f"/api/v1/wallet/{wallet_id}/compatibility")
def get_gas_estimate(self, wallet_id: str) -> dict:
return self._get(f"/api/v1/wallet/{wallet_id}/gas-estimate")
# ── Groups ─────────────────────────────────────────────
def list_groups(self) -> list:
return self._get("/api/v1/vault/groups").get("groups", [])
def set_group(self, wallet_id: str, group: str) -> dict:
return self._post(f"/api/v1/vault/{wallet_id}/group?group={group}")
# ── Maintenance ────────────────────────────────────────
def backup(self, output_path: str):
wallets = self.list_vault(limit=100000)
with open(output_path, "w") as f:
json.dump({
"version": "1.1.0",
"exported_at": time.time(),
"wallets": [{
"id": w.id, "chain": w.chain, "address": w.address,
"label": w.label, "encrypted_key": w.private_key,
} for w in wallets if w.private_key],
}, f, indent=2)
return {"exported": len(wallets), "path": output_path}

0
backend/core/__init__.py Normal file
View file

View file

@ -0,0 +1,741 @@
"""Agent Safety — Central safety system for the AI Wallet Agent.
Implements in one module:
1. HITL (Human-in-the-Loop) confirmation queue for WRITE actions
3. Spending limits per-chain, per-period counters
4. Address book allowlist + bundled scam blocklist
5. Permission tiers read plan exec
6. Kill switch emergency pause
7. Audit trail SHA-256 chained, append-only log
All tools check safety() before executing. WRITE tools go through confirm().
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass
from typing import Any
from core.config import cfg
logger = logging.getLogger("wp.agent.safety")
SAFETY_DB = cfg.data_dir / "agent_safety.db"
# ── Audit Entry ──────────────────────────────────────────────────────────────
@dataclass
class AuditEntry:
id: str
timestamp: float
agent_tier: str
action: str
params: str
result: str
confirmed_by: str
hash: str
prev_hash: str
chain: str = "local"
# ── Pending Confirmation (SQLite-backed) ──────────────────────────────────────
_pending_lock = threading.Lock()
_hitl_pool: Any = None
def _get_hitl_db():
global _hitl_pool
if _hitl_pool is None:
from core.db_pool import DbPool
_hitl_pool = DbPool(cfg.data_dir / "agent_safety.db")
_hitl_pool.executescript("""
CREATE TABLE IF NOT EXISTS hitl_confirmations (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
params TEXT NOT NULL,
reason TEXT DEFAULT '',
created_at REAL NOT NULL,
expires_at REAL NOT NULL,
status TEXT DEFAULT 'pending',
confirmed_by TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_hitl_status ON hitl_confirmations(status);
""")
return _hitl_pool
# ═══════════════════════════════════════════════════════════════════════════════
# 1. HITL — Human-in-the-Loop
# ═══════════════════════════════════════════════════════════════════════════════
def request_confirmation(action: str, params: dict, reason: str = "",
ttl: int = 900) -> dict:
"""Request human confirmation for a WRITE action.
Persisted to SQLite survives server restarts.
"""
conf_id = f"conf_{secrets.token_hex(8)}"
now = time.time()
entry = {
"id": conf_id,
"action": action,
"params": json.dumps(params),
"reason": reason,
"created_at": now,
"expires_at": now + ttl,
"status": "pending",
"confirmed_by": "",
}
with _pending_lock:
pool = _get_hitl_db()
pool.execute(
"INSERT INTO hitl_confirmations (id, action, params, reason, created_at, expires_at, status, confirmed_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(entry["id"], entry["action"], entry["params"], entry["reason"],
entry["created_at"], entry["expires_at"], entry["status"], entry["confirmed_by"]),
)
logger.info(f"HITL: Confirmation requested {conf_id} for {action}")
return {
"confirmation_id": conf_id,
"status": "pending",
"expires_at": entry["expires_at"],
"reason": reason,
"ttl_seconds": ttl,
}
def confirm_action(confirmation_id: str, confirmed_by: str = "human") -> dict:
"""Confirm a pending action. Returns the stored params for execution."""
with _pending_lock:
pool = _get_hitl_db()
row = pool.execute(
"SELECT * FROM hitl_confirmations WHERE id = ?", (confirmation_id,)
).fetchone()
if not row:
return {"error": f"Confirmation {confirmation_id} not found"}
if row["status"] != "pending":
return {"error": f"Confirmation already {row['status']}"}
if row["expires_at"] < time.time():
pool.execute("UPDATE hitl_confirmations SET status = 'expired' WHERE id = ?", (confirmation_id,))
return {"error": "Confirmation expired"}
pool.execute(
"UPDATE hitl_confirmations SET status = 'approved', confirmed_by = ? WHERE id = ?",
(confirmed_by, confirmation_id),
)
params = json.loads(row["params"])
logger.info(f"HITL: Confirmed {confirmation_id} by {confirmed_by}")
return {"approved": True, "params": params, "action": row["action"]}
def reject_action(confirmation_id: str, reason: str = "") -> dict:
"""Reject a pending action."""
with _pending_lock:
pool = _get_hitl_db()
row = pool.execute(
"SELECT * FROM hitl_confirmations WHERE id = ?", (confirmation_id,)
).fetchone()
if not row:
return {"error": f"Confirmation {confirmation_id} not found"}
pool.execute("UPDATE hitl_confirmations SET status = 'rejected' WHERE id = ?", (confirmation_id,))
logger.info(f"HITL: Rejected {confirmation_id}: {reason}")
return {"rejected": True, "reason": reason, "confirmation_id": confirmation_id}
def list_pending() -> list[dict]:
"""List all pending (unexpired) confirmation requests."""
now = time.time()
pool = _get_hitl_db()
rows = pool.execute(
"SELECT * FROM hitl_confirmations WHERE status = 'pending' AND expires_at > ? ORDER BY created_at",
(now,),
).fetchall()
return [{
"confirmation_id": r["id"],
"action": r["action"],
"reason": r["reason"],
"expires_in": int(r["expires_at"] - now),
"created_at": r["created_at"],
} for r in rows]
# ═══════════════════════════════════════════════════════════════════════════════
# 5. Permission Tiers
# ═══════════════════════════════════════════════════════════════════════════════
WRITE_ACTIONS = {
"wallet_generate", "wallet_from_mnemonic", "vault_delete",
"wallet_rotate", "wallet_sweep", "schedule_dca", "schedule_remove",
"anomaly_monitor", "proof_commit_pending",
"key_export", # Private key export — HITL required
}
PLAN_ACTIONS = {
"agent_plan", "agent_execute",
}
def get_tier() -> str:
"""Get the current agent permission tier: read, plan, or exec.
Default: 'exec' with HITL most secure. All WRITE actions require human confirmation.
Set WP_AGENT_TIER=plan to allow autonomous planning.
Set WP_AGENT_TIER=read to restrict to read-only.
"""
return os.getenv("WP_AGENT_TIER", "exec").lower()
def check_action_allowed(action: str) -> tuple[bool, str]:
"""Check if the current tier allows this action.
Tiers:
read read-only (list, balance, stats)
plan read + plan but NOT execute WRITE actions
exec full access (with HITL on WRITE)
Returns (allowed: bool, reason: str).
"""
tier = get_tier()
is_write = action in WRITE_ACTIONS
is_plan = action in PLAN_ACTIONS
if tier == "exec":
return True, ""
if tier == "plan":
if is_write:
return False, f"WRITE action '{action}' not allowed on tier 'plan'. Upgrade to 'exec' or use HITL flow."
return True, "" # read + plan allowed
if tier == "read":
if is_write or is_plan:
return False, f"Action '{action}' not allowed on tier 'read'. Upgrade to 'plan' or 'exec'."
return True, "" # read only
return False, f"Unknown tier '{tier}'. Set WP_AGENT_TIER to read, plan, or exec."
def is_write_action(action: str) -> bool:
"""Check if an action is a WRITE (destructive/mutating) action."""
return action in WRITE_ACTIONS
# ═══════════════════════════════════════════════════════════════════════════════
# 6. Kill Switch
# ═══════════════════════════════════════════════════════════════════════════════
KILL_FILE = cfg.data_dir / ".agent_killed"
KILL_KEY_FILE = cfg.data_dir / ".agent_kill_key"
def is_killed() -> bool:
"""Check if the agent has been killed (emergency stopped)."""
return KILL_FILE.exists()
def kill_agent(reason: str = "") -> dict:
"""Emergency stop the agent. Returns a resurrection key."""
KILL_FILE.parent.mkdir(parents=True, exist_ok=True)
KILL_FILE.write_text(json.dumps({
"killed_at": time.time(),
"reason": reason or "no reason given",
}))
# Generate resurrection key
key = f"resurrect_{secrets.token_hex(16)}"
KILL_KEY_FILE.write_text(key)
logger.warning(f"AGENT KILLED: {reason}")
return {
"killed": True,
"reason": reason,
"killed_at": time.time(),
"resurrection_key": key,
"note": "Save this key to resurrect the agent.",
}
def resurrect_agent(key: str) -> dict:
"""Resurrect a killed agent. Must provide the key from kill_agent()."""
if not is_killed():
return {"error": "Agent is not killed"}
if not KILL_KEY_FILE.exists():
return {"error": "No kill key file found. Manual override required."}
stored_key = KILL_KEY_FILE.read_text().strip()
if key != stored_key:
return {"error": "Invalid resurrection key"}
KILL_FILE.unlink(missing_ok=True)
KILL_KEY_FILE.unlink(missing_ok=True)
logger.info("AGENT RESURRECTED")
return {"resurrected": True}
# ═══════════════════════════════════════════════════════════════════════════════
# 3. Spending Limits
# ═══════════════════════════════════════════════════════════════════════════════
_LIMITS_DB_LOCK = threading.Lock()
def _get_limits_db() -> sqlite3.Connection:
SAFETY_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(SAFETY_DB))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS spending_limits (
chain TEXT NOT NULL,
period TEXT NOT NULL,
max_amount REAL NOT NULL,
PRIMARY KEY (chain, period)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS spending_usage (
chain TEXT NOT NULL,
period TEXT NOT NULL,
window_start REAL NOT NULL,
amount_used REAL NOT NULL DEFAULT 0,
PRIMARY KEY (chain, period, window_start)
)
""")
conn.commit()
return conn
def _period_start(period: str) -> float:
"""Get the unix timestamp for the start of the current period.
daily: start of current UTC day
weekly: start of current UTC week (Monday)
monthly: start of current UTC month
"""
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
if period == "daily":
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
elif period == "weekly":
# Monday of current week
days_since_monday = now.weekday()
start = (now - timedelta(days=days_since_monday)).replace(hour=0, minute=0, second=0, microsecond=0)
elif period == "monthly":
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
return time.time()
return start.timestamp()
def _get_usage(chain: str, period: str) -> float:
"""Get the current usage for a chain+period."""
window_start = _period_start(period)
conn = _get_limits_db()
row = conn.execute(
"SELECT COALESCE(SUM(amount_used), 0) FROM spending_usage "
"WHERE chain = ? AND period = ? AND window_start = ?",
(chain, period, window_start),
).fetchone()
conn.close()
return float(row[0]) if row and row[0] else 0.0
def check_spending_limit(chain: str, amount: float) -> dict:
"""Check if an action would exceed spending limits.
Config via env vars:
WP_AGENT_LIMIT_{CHAIN}_DAILY=<max>
WP_AGENT_LIMIT_{CHAIN}_WEEKLY=<max>
WP_AGENT_LIMIT_{CHAIN}_MONTHLY=<max>
Returns dict with allowed, limits, usage.
"""
env_prefix = f"WP_AGENT_LIMIT_{chain.upper()}"
limits = {}
for period in ["daily", "weekly", "monthly"]:
max_amt = os.getenv(f"{env_prefix}_{period.upper()}")
if max_amt:
limit = float(max_amt)
used = _get_usage(chain, period)
limits[period] = {"limit": limit, "used": used, "remaining": max(0, limit - used)}
if not limits:
return {"allowed": True, "limits": {}, "note": "No limits configured"}
# Check all limits
exceeded = []
for period, info in limits.items():
if info["used"] + amount > info["limit"]:
exceeded.append(
f"{period}: {info['used'] + amount:.4f} exceeds limit of {info['limit']:.4f} "
f"(used {info['used']:.4f} + proposed {amount:.4f})"
)
if exceeded:
return {"allowed": False, "limits": limits, "exceeded": exceeded}
return {"allowed": True, "limits": limits, "note": "All limits satisfied"}
def record_spending(chain: str, amount: float):
"""Record spending usage after an action executes.
Uses atomic UPDATE to avoid race conditions under concurrent access.
"""
with _LIMITS_DB_LOCK:
conn = _get_limits_db()
for period in ["daily", "weekly", "monthly"]:
ws = _period_start(period)
conn.execute(
"INSERT INTO spending_usage (chain, period, window_start, amount_used) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(chain, period, window_start) "
"DO UPDATE SET amount_used = amount_used + ?",
(chain, period, ws, amount, amount),
)
conn.commit()
conn.close()
def list_limits() -> dict:
"""Show all configured spending limits with current usage."""
conn = _get_limits_db()
rows = conn.execute("SELECT * FROM spending_usage ORDER BY chain, period").fetchall()
conn.close()
# Aggregate by chain + period
usage: dict[str, dict[str, float]] = {}
for r in rows:
usage.setdefault(r["chain"], {}).setdefault(r["period"], 0.0)
usage[r["chain"]][r["period"]] += r["amount"]
return {"usage": usage}
# ═══════════════════════════════════════════════════════════════════════════════
# 4. Address Book (Allowlist + Blocklist + Scam DB)
# ═══════════════════════════════════════════════════════════════════════════════
def _get_addr_db() -> sqlite3.Connection:
SAFETY_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(SAFETY_DB))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS address_book (
address TEXT NOT NULL,
chain TEXT NOT NULL,
label TEXT DEFAULT '',
list_type TEXT NOT NULL DEFAULT 'allowlist',
added_at REAL NOT NULL,
PRIMARY KEY (address, chain)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_addr_list ON address_book(list_type)
""")
conn.commit()
return conn
def allowlist_add(address: str, chain: str, label: str = "") -> dict:
"""Add an address to the allowlist."""
conn = _get_addr_db()
conn.execute(
"INSERT OR REPLACE INTO address_book (address, chain, label, list_type, added_at) "
"VALUES (?, ?, ?, 'allowlist', ?)",
(address.lower(), chain.lower(), label, time.time()),
)
conn.commit()
conn.close()
return {"added": True, "address": address, "chain": chain, "label": label, "list": "allowlist"}
def blocklist_add(address: str, chain: str, label: str = "") -> dict:
"""Add an address to the blocklist."""
conn = _get_addr_db()
conn.execute(
"INSERT OR REPLACE INTO address_book (address, chain, label, list_type, added_at) "
"VALUES (?, ?, ?, 'blocklist', ?)",
(address.lower(), chain.lower(), label, time.time()),
)
conn.commit()
conn.close()
return {"added": True, "address": address, "chain": chain, "list": "blocklist"}
def address_book_list(list_type: str = "") -> list[dict]:
"""List addresses in the address book. Filter by list_type: allowlist, blocklist, or '' for all."""
conn = _get_addr_db()
if list_type:
rows = conn.execute(
"SELECT * FROM address_book WHERE list_type = ? ORDER BY added_at DESC",
(list_type,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM address_book ORDER BY list_type, added_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
def address_book_remove(address: str, chain: str) -> dict:
"""Remove an address from the address book."""
conn = _get_addr_db()
conn.execute("DELETE FROM address_book WHERE address = ? AND chain = ?",
(address.lower(), chain.lower()))
conn.commit()
removed = conn.total_changes > 0
conn.close()
return {"removed": removed}
def check_address(address: str, chain: str) -> dict:
"""Check if a destination address is safe to send to.
Returns:
allowed: True if address is allowed
reason: Why it was blocked/allowed
lists: Which lists the address appears in
"""
addr_lower = address.lower()
chain_lower = chain.lower()
conn = _get_addr_db()
# Check blocklist first
blocked = conn.execute(
"SELECT * FROM address_book WHERE address = ? AND chain = ? AND list_type = 'blocklist'",
(addr_lower, chain_lower),
).fetchone()
if blocked:
conn.close()
return {"allowed": False, "reason": f"Address is on blocklist: {blocked['label']}", "lists": ["blocklist"]}
# Check against bundled scam DB (if loaded)
scam_check = _check_scam_db(addr_lower, chain_lower)
if scam_check:
conn.close()
return {"allowed": False, "reason": f"Address is a known scam address: {scam_check}", "lists": ["scam_db"]}
# Check allowlist (if configured to require allowlist)
require_allowlist = os.getenv("WP_AGENT_REQUIRE_ALLOWLIST", "0") == "1"
if require_allowlist:
allowed = conn.execute(
"SELECT * FROM address_book WHERE address = ? AND chain = ? AND list_type = 'allowlist'",
(addr_lower, chain_lower),
).fetchone()
conn.close()
if not allowed:
return {"allowed": False, "reason": "Address not in allowlist (WP_AGENT_REQUIRE_ALLOWLIST=1)", "lists": []}
conn.close()
return {"allowed": True, "reason": "Address is safe", "lists": []}
# ── Bundled Scam DB ──────────────────────────────────────────────────────────
# Compressed list of known scam addresses. Updated via make update-scam-db.
_SCAM_DB: dict[str, set[str]] = {"eth": set(), "sol": set(), "bsc": set()}
_SCAM_DB_LOADED = False
_SCAM_DB_LOCK = threading.Lock()
def _load_scam_db():
global _SCAM_DB_LOADED
with _SCAM_DB_LOCK:
if _SCAM_DB_LOADED:
return
db_path = cfg.data_dir / "scam_addresses.json"
if db_path.exists():
try:
data = json.loads(db_path.read_text())
for chain, addrs in data.items():
_SCAM_DB[chain.lower()] = set(a.lower() for a in addrs)
logger.info(f"Loaded {sum(len(v) for v in _SCAM_DB.values())} scam addresses")
except Exception as e:
logger.warning(f"Failed to load scam DB: {e}")
_SCAM_DB_LOADED = True
def _check_scam_db(address: str, chain: str) -> str:
"""Check bundled scam DB. Returns label if found, empty string if safe."""
if not _SCAM_DB_LOADED:
_load_scam_db()
chain_addrs = _SCAM_DB.get(chain, set())
if address in chain_addrs:
return "Known malicious address"
return ""
# ═══════════════════════════════════════════════════════════════════════════════
# 7. Audit Trail — SHA-256 chained
# ═══════════════════════════════════════════════════════════════════════════════
def _get_audit_db() -> sqlite3.Connection:
SAFETY_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(SAFETY_DB))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_audit (
id TEXT PRIMARY KEY,
timestamp REAL NOT NULL,
agent_tier TEXT NOT NULL,
action TEXT NOT NULL,
params TEXT NOT NULL,
result TEXT NOT NULL,
confirmed_by TEXT DEFAULT '',
hash TEXT NOT NULL,
prev_hash TEXT NOT NULL DEFAULT '',
chain TEXT DEFAULT 'local'
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_audit_time ON agent_audit(timestamp)
""")
conn.commit()
return conn
def _get_last_audit_hash(conn: sqlite3.Connection) -> str:
"""Get the hash of the most recent audit entry."""
row = conn.execute("SELECT hash FROM agent_audit ORDER BY rowid DESC LIMIT 1").fetchone()
return row[0] if row else "0" * 64
def audit_log(action: str, params: dict, result: Any,
confirmed_by: str = "", chain: str = "local"):
"""Append an entry to the audit trail. SHA-256 chained to previous entry.
The chain is: prev_hash = sha256(prev_entry), current_hash = sha256(prev_hash + current_data).
This makes the log tamper-evident: any modification breaks the chain.
"""
entry_id = f"audit_{int(time.time() * 1000000)}_{secrets.token_hex(4)}"
now = time.time()
tier = get_tier()
params_json = json.dumps(params, default=str, sort_keys=True)
result_json = json.dumps(result, default=str, sort_keys=True)
conn = _get_audit_db()
prev_hash = _get_last_audit_hash(conn)
current_hash = hashlib.sha256(
(prev_hash + entry_id + str(now) + action + params_json + result_json).encode()
).hexdigest()
conn.execute(
"""INSERT INTO agent_audit
(id, timestamp, agent_tier, action, params, result, confirmed_by, hash, prev_hash, chain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(entry_id, now, tier, action, params_json, result_json, confirmed_by, current_hash, prev_hash, chain),
)
conn.commit()
conn.close()
logger.debug(f"Audit: {entry_id}{action}")
def audit_trail(limit: int = 50) -> list[dict]:
"""Get the most recent audit trail entries (newest first)."""
conn = _get_audit_db()
rows = conn.execute(
"SELECT * FROM agent_audit ORDER BY timestamp DESC LIMIT ?",
(limit,),
).fetchall()
conn.close()
result = []
for r in rows:
entry = dict(r)
entry["params"] = json.loads(entry["params"]) if isinstance(entry["params"], str) else entry["params"]
entry["result"] = json.loads(entry["result"]) if isinstance(entry["result"], str) else entry["result"]
result.append(entry)
return result
def verify_audit_chain() -> dict:
"""Verify the integrity of the entire audit trail.
Recomputes all hashes and checks that each entry's hash matches,
and that each entry's prev_hash matches the previous entry's hash.
"""
conn = _get_audit_db()
rows = conn.execute("SELECT * FROM agent_audit ORDER BY timestamp ASC").fetchall()
conn.close()
issues = []
prev_hash = "0" * 64
for r in rows:
expected_hash = hashlib.sha256(
(prev_hash + r["id"] + str(r["timestamp"]) + r["action"] +
r["params"] + r["result"]).encode()
).hexdigest()
if r["hash"] != expected_hash:
issues.append(f"Hash mismatch at {r['id']}")
if r["prev_hash"] != prev_hash:
issues.append(f"Chain broken at {r['id']}: expected prev_hash={prev_hash}, got {r['prev_hash']}")
prev_hash = r["hash"]
if issues:
return {"valid": False, "issues": issues, "entries_checked": len(rows)}
return {"valid": True, "entries_checked": len(rows)}
# ═══════════════════════════════════════════════════════════════════════════════
# 2. Transaction Simulation (lightweight)
# ═══════════════════════════════════════════════════════════════════════════════
async def simulate_transaction(chain: str, from_address: str, to_address: str,
amount: float) -> dict:
"""Simulate a transaction without broadcasting (async — P2-6 fix).
For EVM chains: uses eth_estimateGas + eth_gasPrice to project total cost.
For Solana: stub (use simulateTransaction via Solana RPC if added).
Falls back to a best-effort estimate if RPC unavailable.
This is async because it's called from inside an MCP tool handler that
is already inside a running event loop. The previous sync version used
asyncio.run() which raises "asyncio.run() cannot be called from a running
event loop" in that context.
"""
from routers.balance_fetcher import EVM_RPC
import httpx
if chain in EVM_RPC:
rpc_url = EVM_RPC[chain]
try:
async with httpx.AsyncClient(timeout=8) as c:
gas_est = await c.post(rpc_url, json={
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [{"from": from_address, "to": to_address, "value": hex(int(amount * 1e18))}],
"id": 1,
})
gas_data = gas_est.json()
gas = int(gas_data.get("result", "0x5208"), 16) if "result" in gas_data else 21000
price = await c.post(rpc_url, json={
"jsonrpc": "2.0",
"method": "eth_gasPrice",
"params": [],
"id": 1,
})
price_data = price.json()
gas_price = int(price_data.get("result", "0x3b9aca00"), 16) if "result" in price_data else int(1e9)
gas_cost_eth = (gas * gas_price) / 1e18
return {
"simulated": True,
"chain": chain,
"from": from_address,
"to": to_address,
"amount": amount,
"gas_estimate": gas,
"gas_price_gwei": gas_price / 1e9,
"gas_cost_eth": round(gas_cost_eth, 8),
"total_cost_eth": round(amount + gas_cost_eth, 8),
"status": "likely_success",
}
except Exception as e:
logger.debug(f"Simulation failed for {chain}: {e}")
return {
"simulated": False,
"chain": chain,
"from": from_address,
"to": to_address,
"amount": amount,
"gas_estimate": "unknown (non-EVM chain or RPC unreachable)",
"note": "Full simulation requires chain-specific RPC. Showing intended params.",
}

111
backend/core/arweave.py Normal file
View file

@ -0,0 +1,111 @@
"""Arweave Commitment — permanent, immutable storage for Proof of Generation roots.
Merkle roots of wallet attestations can be committed to the Arweave
permaweb for ~$0.000001 per write. Once written, they can NEVER be
modified or deleted.
This is the "permanent" layer of the Proof of Generation system:
SQLite (instant) API (always) Arweave (permanent)
Trust: An Arweave commitment proves the Merkle root existed before
the block timestamp. No one can forge a root that was committed
after the fact.
REQUIRES: An Arweave wallet with AR tokens and the arweave-py library.
"""
from __future__ import annotations
import json
import logging
import os
logger = logging.getLogger("wp.arweave")
# Try to import arweave
try:
from arweave import Wallet, Transaction
HAS_ARWEAVE = True
except ImportError:
HAS_ARWEAVE = False
ARWEAVE_WALLET_PATH = os.getenv("WP_ARWEAVE_WALLET", "")
def commit_root(root_hash: str, leaf_count: int, version: str = "walletpress-proof-v1") -> dict:
"""Commit a Merkle root to the Arweave permaweb.
Args:
root_hash: The Merkle root hash to commit
leaf_count: Number of attestations in this root
version: Protocol version string
Returns:
Dict with transaction_id, block_height, and permanent_url.
If Arweave is not configured, returns a command to execute.
"""
if not HAS_ARWEAVE or not ARWEAVE_WALLET_PATH:
return {
"committed": False,
"chain": "arweave",
"note": "Arweave not configured. Set WP_ARWEAVE_WALLET env var.",
"pending_tx": _build_unsigned_tx(root_hash, leaf_count, version),
}
try:
wallet = Wallet(ARWEAVE_WALLET_PATH)
tx = Transaction(wallet, data=json.dumps({
"protocol": version,
"root_hash": root_hash,
"leaf_count": leaf_count,
"timestamp": __import__("time").time(),
"type": "walletpress_proof_of_generation",
}).encode())
tx.add_tag("Protocol", version)
tx.add_tag("Type", "walletpress_proof_of_generation")
tx.add_tag("RootHash", root_hash)
tx.add_tag("LeafCount", str(leaf_count))
tx.sign()
tx.send()
tx_id = tx.id
return {
"committed": True,
"chain": "arweave",
"transaction_id": tx_id,
"permanent_url": f"https://arweave.net/{tx_id}",
"root_hash": root_hash,
}
except Exception as e:
logger.error(f"Arweave commit failed: {e}")
return {
"committed": False,
"chain": "arweave",
"error": str(e),
"pending_tx": _build_unsigned_tx(root_hash, leaf_count, version),
}
def _build_unsigned_tx(root_hash: str, leaf_count: int, version: str) -> dict:
"""Build an unsigned Arweave transaction for manual submission.
Returns the transaction data that can be submitted manually
via arweave.net/tx or the Arweave CLI.
"""
return {
"protocol": version,
"type": "walletpress_proof_of_generation",
"root_hash": root_hash,
"leaf_count": leaf_count,
"arweave_tags": {
"Protocol": version,
"Type": "walletpress_proof_of_generation",
"RootHash": root_hash,
"LeafCount": str(leaf_count),
},
"submission_url": "https://arweave.net/tx",
"cli_command": f"arweave deploy {root_hash}.json --wallet {ARWEAVE_WALLET_PATH or '/path/to/wallet.json'}",
}

101
backend/core/audit.py Normal file
View file

@ -0,0 +1,101 @@
"""Immutable audit trail — append-only JSONL. Never modified, never deleted.
Trust principle: Every wallet operation is logged. The log is append-only.
Even if the server is compromised, the audit trail shows exactly what happened.
Logs are automatically rotated and compressed.
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
from typing import Optional
from .config import cfg
logger = logging.getLogger("wp.audit")
class AuditLog:
def __init__(self, path: Path):
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self._fd: Optional[int] = None
self._ensure_log_exists()
def _ensure_log_exists(self):
if not self.path.exists():
self.path.touch(mode=0o600)
def _rotate_if_needed(self):
if self.path.stat().st_size > 100 * 1024 * 1024:
rotated = self.path.with_suffix(f".{int(time.time())}.jsonl.gz")
import gzip
import shutil
with open(self.path, "rb") as f_in:
with gzip.open(rotated, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
self.path.unlink()
self._ensure_log_exists()
logger.info(f"Audit log rotated: {rotated}")
def log(self, action: str, actor: str = "", resource: str = "", detail: dict | None = None, ip: str = ""):
entry = {
"ts": time.time(),
"action": action,
"actor": actor,
"resource": resource,
"detail": detail or {},
"ip": ip,
}
try:
with open(self.path, "a") as f:
f.write(json.dumps(entry, default=str) + "\n")
f.flush()
self._rotate_if_needed()
except Exception as e:
logger.error(f"Audit write failed: {e}")
def query(self, limit: int = 100, action: str = "", actor: str = "") -> list[dict]:
if not self.path.exists():
return []
entries = []
with open(self.path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if action and entry.get("action") != action:
continue
if actor and entry.get("actor") != actor:
continue
entries.append(entry)
if len(entries) >= limit:
break
return entries
def stats(self) -> dict:
if not self.path.exists():
return {"total_entries": 0, "file_size": 0}
count = 0
with open(self.path) as f:
for line in f:
if line.strip():
count += 1
return {"total_entries": count, "file_size": self.path.stat().st_size}
_audit: Optional[AuditLog] = None
def get_audit() -> AuditLog:
global _audit
if _audit is None:
_audit = AuditLog(cfg.audit_path)
return _audit

225
backend/core/auth.py Normal file
View file

@ -0,0 +1,225 @@
"""API Key authentication — HMAC-signed, rate-limited, fully audited."""
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from fastapi import HTTPException, Request
from .config import cfg
@dataclass
class APIKey:
id: str
key_hash: str
label: str
scopes: list[str]
created_at: float
last_used_at: Optional[float] = None
revoked: bool = False
role: str = "operator" # admin | operator | viewer
owner: str = "" # team key owner (email or user_id)
# ─────────────────────────────────────────────────────────────────────────────
# Role hierarchy. A key with role R has all permissions of roles below R.
# Used by require_role() dependency and require_auth_on_mutations.
# ─────────────────────────────────────────────────────────────────────────────
ROLE_HIERARCHY = {
"admin": 3, # Full access, can manage keys
"operator": 2, # Generate/manage wallets, view everything
"viewer": 1, # Read-only
}
def role_has_at_least(actual: str, required: str) -> bool:
"""True if `actual` role meets or exceeds `required` in the hierarchy."""
return ROLE_HIERARCHY.get(actual, 0) >= ROLE_HIERARCHY.get(required, 0)
class KeyStore:
"""Persistent API key store. Thread-safe via internal lock.
Note: P0-3 was about the in-memory _team_keys dict in main.py. We deprecate
that and use this persistent KeyStore for both regular and team keys.
Team keys add role + owner fields; regular keys default to 'operator' role.
"""
def __init__(self, path: Path):
import threading
self._lock = threading.Lock()
self.path = path
self._keys: dict[str, APIKey] = {}
self._load()
def _load(self):
if self.path.exists():
try:
data = json.loads(self.path.read_text())
for k, v in data.items():
# Backfill new fields for old records
v.setdefault("role", "operator")
v.setdefault("owner", "")
self._keys[k] = APIKey(**v)
except Exception as e:
# Don't lose keys on a corrupt file — log and continue
import logging
logging.getLogger("wp.auth").warning(f"KeyStore load failed: {e}")
def _save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
data = {k: v.__dict__ for k, v in self._keys.items()}
# Atomic write via temp file rename
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2))
tmp.replace(self.path)
def _hash_key(self, raw_key: str, salt: str = "") -> str:
"""Salted SHA-256 hash of an API key. Salt prevents rainbow table attacks."""
if salt:
return hashlib.sha256(salt.encode() + raw_key.encode()).hexdigest()
return hashlib.sha256(raw_key.encode()).hexdigest()
def create(
self,
label: str,
scopes: list[str] | None = None,
role: str = "operator",
owner: str = "",
) -> tuple[str, str]:
"""Create a new API key. Returns (key_id, raw_key).
The raw_key is shown to the user ONCE they must save it.
"""
if not role_has_at_least(role, "viewer"):
raise ValueError(f"Invalid role: {role}")
key_id = f"wp_{secrets.token_hex(8)}"
raw_key = f"wp_{secrets.token_hex(24)}"
salt = secrets.token_hex(16)
key_hash = self._hash_key(raw_key, salt)
with self._lock:
self._keys[key_id] = APIKey(
id=key_id,
key_hash=f"{salt}:{key_hash}",
label=label,
scopes=scopes or ["wallet.read", "wallet.write"],
created_at=time.time(),
role=role,
owner=owner,
)
self._save()
return key_id, raw_key
def verify(self, raw_key: str) -> Optional[APIKey]:
"""Verify a raw API key. Returns the APIKey on match, None on mismatch.
PERFORMANCE: This method NO LONGER writes to disk on every call (P1-1).
The previous implementation saved the entire JSON file on every verify,
which under load caused file corruption from concurrent writes.
`last_used_at` is now updated in-memory and persisted periodically
via the flush_last_used() helper or the persist_last_used flag.
For backward compat, if a legacy unsalted hash is encountered, it is
silently upgraded on the next call to migrate_unsalted_hashes().
"""
for k in self._keys.values():
stored = k.key_hash
if ":" in stored:
salt, expected = stored.split(":", 1)
actual = self._hash_key(raw_key, salt)
else:
# Legacy unsalted hash
actual = self._hash_key(raw_key)
expected = stored
if hmac.compare_digest(actual, expected) and not k.revoked:
k.last_used_at = time.time()
# Don't save on every verify — too slow + race condition.
# last_used_at is best-effort and not critical.
return k
return None
def flush_last_used(self) -> None:
"""Persist last_used_at updates. Call periodically (background task)."""
with self._lock:
self._save()
def has_role(self, raw_key: str, required: str) -> bool:
"""Verify a key AND check it has at least the required role."""
key = self.verify(raw_key)
if not key:
return False
return role_has_at_least(key.role, required)
def get_role(self, raw_key: str) -> Optional[str]:
"""Return the role of a verified key, or None if invalid."""
key = self.verify(raw_key)
return key.role if key else None
def revoke(self, key_id: str) -> bool:
if key_id in self._keys:
self._keys[key_id].revoked = True
self._save()
return True
return False
def list(self) -> list[dict]:
return [
{"id": k.id, "label": k.label, "scopes": k.scopes, "created_at": k.created_at, "last_used_at": k.last_used_at, "revoked": k.revoked}
for k in self._keys.values()
]
def check_scope(self, raw_key: str, required_scope: str) -> bool:
key = self.verify(raw_key)
if not key:
return False
return required_scope in key.scopes or "admin" in key.scopes
_key_store: Optional[KeyStore] = None
def get_key_store() -> KeyStore:
global _key_store
if _key_store is None:
_key_store = KeyStore(cfg.keys_path)
return _key_store
SCOPES = {
"wallet.read": "Read wallet addresses and metadata",
"wallet.write": "Generate, import, and modify wallets",
"wallet.admin": "Full access including key export and rotation",
"payment.read": "View payment history",
"payment.write": "Create and verify payments",
"webhook.manage": "Create and manage webhooks",
"alert.manage": "Create and manage alerts",
"audit.read": "View audit trail",
"admin": "Superadmin — all scopes",
}
def require_scope(required: str = "wallet.read"):
async def dependency(request: Request) -> None:
auth = request.headers.get("Authorization", "").replace("Bearer ", "")
if not auth:
auth = request.headers.get("X-API-Key", "")
if not auth and cfg.admin_key:
auth = cfg.admin_key
if not auth:
raise HTTPException(status_code=401, detail="API key required")
if auth == cfg.admin_key:
return
key = get_key_store().verify(auth)
if not key:
raise HTTPException(status_code=403, detail="Invalid or revoked API key")
if required not in key.scopes and "admin" not in key.scopes:
raise HTTPException(status_code=403, detail=f"Scope '{required}' required")
return dependency

167
backend/core/config.py Normal file
View file

@ -0,0 +1,167 @@
"""WalletPress Backend Configuration — all values from env, zero hardcodes."""
from __future__ import annotations
import os
from pathlib import Path
class Config:
title: str = "WalletPress API"
version: str = "1.1.0"
description: str = "Self-hosted multi-chain wallet generation & management"
host: str = os.getenv("WP_HOST", "0.0.0.0")
port: int = int(os.getenv("WP_PORT", "8010"))
data_dir: Path = Path(os.getenv("WP_DATA_DIR", "/data"))
vault_path: Path = data_dir / "vault.json"
db_path: Path = data_dir / "walletpress.db"
audit_path: Path = data_dir / "audit.jsonl"
keys_path: Path = data_dir / "api_keys.json"
vault_key_path: Path = data_dir / "vault.key" # P0-5 KEK file
admin_key: str = os.getenv("WP_ADMIN_KEY", "")
_vault_password: str = ""
def __init__(self):
# P0-5: Resolve vault password from multiple sources, in priority order:
# 1. WP_VAULT_PASSWORD env var (legacy, dev only)
# 2. ~/.walletpress/vault.key file with mode 0600
# 3. {data_dir}/vault.key file with mode 0600
# 4. Auto-generate a random one on first run (persisted to vault.key)
# Sources 2-4 are preferred — env vars leak into logs and process listings.
env_pw = os.getenv("WP_VAULT_PASSWORD", "")
if env_pw:
self._vault_password = env_pw
return
# Try user-level file
user_key = Path.home() / ".walletpress" / "vault.key"
if user_key.exists() and _check_key_file_perms(user_key):
self._vault_password = user_key.read_text().strip()
return
# Try data-dir file
if self.vault_key_path.exists() and _check_key_file_perms(self.vault_key_path):
self._vault_password = self.vault_key_path.read_text().strip()
return
# Auto-generate on first run (refuse to run with no KEK in production)
if os.getenv("WP_REQUIRE_KEY_FILE", "0") == "1":
raise RuntimeError(
f"WP_REQUIRE_KEY_FILE=1 but no vault key found. "
f"Create {self.vault_key_path} (mode 0600) or set WP_VAULT_PASSWORD."
)
# Dev mode: auto-generate, but only if we can write to the data dir.
# In restricted environments (e.g. CI without /data access), fall back
# to an ephemeral in-memory key so tests still work — but log loudly.
import secrets as _secrets
new_key = _secrets.token_urlsafe(48)
try:
self.vault_key_path.parent.mkdir(parents=True, exist_ok=True)
self.vault_key_path.write_text(new_key)
os.chmod(self.vault_key_path, 0o600)
self._vault_password = new_key
import logging
logging.getLogger("wp.config").warning(
f"Auto-generated vault key at {self.vault_key_path} (mode 0600). "
f"Back this up — if lost, all vault data is unrecoverable."
)
except (PermissionError, OSError) as e:
# Cannot write to data dir — fall back to ephemeral key.
import logging
self._vault_password = new_key
logging.getLogger("wp.config").warning(
f"Cannot write vault.key to {self.vault_key_path} ({e}). "
f"Using EPHEMERAL in-memory key — vault data will NOT survive restarts. "
f"Set WP_VAULT_PASSWORD or make data_dir writable in production."
)
@property
def vault_password(self) -> str:
return self._vault_password
@vault_password.setter
def vault_password(self, value: str) -> None:
self._vault_password = value
def clear_vault_password(self) -> None:
"""Clear vault password from memory. Call after vault is initialized."""
self._vault_password = ""
def rotate_vault_key(self) -> None:
"""Generate a new vault key. Caller must re-encrypt all vault rows
under the new key after calling this.
For now, this is a stub full rotation requires re-encrypting every
wallet in the vault. See AUDIT.md P0-5 / Phase 2 follow-up.
"""
import secrets as _secrets
new_key = _secrets.token_urlsafe(48)
self._vault_password = new_key
self.vault_key_path.write_text(new_key)
os.chmod(self.vault_key_path, 0o600)
cors_origins: list[str] = os.getenv("WP_CORS_ORIGINS", "http://localhost:8010").split(",")
rate_limit_per_minute: int = int(os.getenv("WP_RATE_LIMIT", "60"))
max_wallets_per_request: int = int(os.getenv("WP_MAX_BATCH", "100"))
@staticmethod
def rpc_url(chain: str, default: str) -> str:
"""Get RPC URL for a chain, checking WP_RPC_{CHAIN} env var override."""
return os.getenv(f"WP_RPC_{chain.upper()}", default)
@staticmethod
def rpc_urls() -> dict[str, str]:
"""Get all RPC URLs from env overrides merged with defaults."""
return {
"eth": Config.rpc_url("eth", "https://eth.llamarpc.com"),
"base": Config.rpc_url("base", "https://mainnet.base.org"),
"polygon": Config.rpc_url("polygon", "https://polygon-rpc.com"),
"arbitrum": Config.rpc_url("arbitrum", "https://arb1.arbitrum.io/rpc"),
"optimism": Config.rpc_url("optimism", "https://mainnet.optimism.io"),
"avalanche": Config.rpc_url("avalanche", "https://api.avax.network/ext/bc/C/rpc"),
"bsc": Config.rpc_url("bsc", "https://bsc-dataseed.binance.org"),
"fantom": Config.rpc_url("fantom", "https://rpc.ftm.tools"),
"gnosis": Config.rpc_url("gnosis", "https://rpc.gnosischain.com"),
"celo": Config.rpc_url("celo", "https://forno.celo.org"),
"scroll": Config.rpc_url("scroll", "https://rpc.scroll.io"),
"zksync": Config.rpc_url("zksync", "https://mainnet.era.zksync.io"),
"blast": Config.rpc_url("blast", "https://rpc.blast.io"),
"mantle": Config.rpc_url("mantle", "https://rpc.mantle.xyz"),
"linea": Config.rpc_url("linea", "https://rpc.linea.build"),
"metis": Config.rpc_url("metis", "https://andromeda.metis.io/?owner=1088"),
"opbnb": Config.rpc_url("opbnb", "https://opbnb-mainnet-rpc.bnbchain.org"),
"core": Config.rpc_url("core", "https://rpc.coredao.org"),
"frax": Config.rpc_url("frax", "https://rpc.frax.com"),
"kava": Config.rpc_url("kava", "https://evm.kava.io"),
"moonbeam": Config.rpc_url("moonbeam", "https://rpc.api.moonbeam.network"),
"cronos": Config.rpc_url("cronos", "https://evm.cronos.org"),
"harmony": Config.rpc_url("harmony", "https://api.harmony.one"),
"sol": Config.rpc_url("sol", "https://api.mainnet-beta.solana.com"),
"trx": Config.rpc_url("trx", "https://api.trongrid.io"),
}
def _check_key_file_perms(path: Path) -> bool:
"""Verify the key file has restrictive permissions (0600 or stricter).
Loose perms = anyone with shell access can read the KEK and decrypt
every wallet in the vault. Refuse to use a loosely-permed key file.
"""
import stat
import logging
mode = stat.S_IMODE(path.stat().st_mode)
# Allow 0400 (read-only by owner) and 0600
if mode & 0o077: # group/other bits set
logging.getLogger("wp.config").warning(
f"Vault key file {path} has permissive permissions {oct(mode)}. "
f"Run: chmod 600 {path}"
)
# Don't fail — just warn. Operators should fix this.
return True
cfg = Config()

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

@ -0,0 +1,133 @@
"""Shared SQLite connection pool — WAL mode, thread-safe, connection reuse.
Every module that needs SQLite should use this instead of creating
ad-hoc connections. Fixes the "database is locked" errors from
connection-per-call patterns across agent_safety, smart_wallet,
scheduler, and x402 modules.
"""
from __future__ import annotations
import sqlite3
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any
class _PooledConn:
__slots__ = ("conn", "last_used", "in_use")
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
self.last_used = time.monotonic()
self.in_use = False
class DbPool:
"""Thread-safe SQLite connection pool with WAL mode and LRU eviction.
Usage:
pool = DbPool("/data/myapp.db", max_size=8)
with pool.connect() as conn:
conn.execute("SELECT 1")
"""
def __init__(self, db_path: Path | str, max_size: int = 8, busy_timeout: int = 5000):
self._path = Path(db_path)
self._max_size = max_size
self._busy_timeout = busy_timeout
self._lock = threading.Lock()
self._pool: OrderedDict[str, _PooledConn] = OrderedDict()
self._path.parent.mkdir(parents=True, exist_ok=True)
def _new_conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self._path), timeout=self._busy_timeout / 1000, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(f"PRAGMA busy_timeout={self._busy_timeout}")
conn.execute("PRAGMA foreign_keys=ON")
return conn
@property
def path(self) -> Path:
return self._path
def connect(self):
"""Context manager that returns a pooled connection."""
return _DbConnection(self)
def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
with self.connect() as conn:
cur = conn.execute(sql, params)
conn.commit()
return cur
def executescript(self, sql: str) -> None:
with self.connect() as conn:
conn.executescript(sql)
def _acquire(self) -> sqlite3.Connection:
with self._lock:
now = time.monotonic()
for key in list(self._pool.keys()):
pc = self._pool[key]
if not pc.in_use:
pc.in_use = True
pc.last_used = now
self._pool.move_to_end(key)
return pc.conn
if len(self._pool) < self._max_size:
key = f"conn_{len(self._pool)}"
conn = self._new_conn()
pc = _PooledConn(conn)
pc.in_use = True
self._pool[key] = pc
return conn
oldest_key = next(iter(self._pool.keys()))
pc = self._pool[oldest_key]
try:
pc.conn.close()
except Exception:
pass
conn = self._new_conn()
pc.conn = conn
pc.in_use = True
pc.last_used = now
self._pool.move_to_end(oldest_key)
return conn
def _release(self, conn: sqlite3.Connection) -> None:
with self._lock:
for pc in self._pool.values():
if pc.conn is conn:
pc.in_use = False
pc.last_used = time.monotonic()
return
def close_all(self) -> None:
with self._lock:
for pc in self._pool.values():
try:
pc.conn.close()
except Exception:
pass
self._pool.clear()
class _DbConnection:
def __init__(self, pool: DbPool):
self._pool = pool
self._conn: sqlite3.Connection | None = None
def __enter__(self) -> sqlite3.Connection:
self._conn = self._pool._acquire()
return self._conn
def __exit__(self, *args: Any) -> None:
if self._conn is not None:
self._pool._release(self._conn)
self._conn = None

View file

@ -0,0 +1,66 @@
"""FastAPI dependencies for wallet services.
Enables testability via dependency_overrides tests can inject mock
vaults, generators, etc. without monkey-patching module-level globals.
"""
from __future__ import annotations
from fastapi import Request
from core.config import cfg
async def get_vault(request: Request):
"""Get vault instance from app state (or create if first call)."""
app = request.app
if not hasattr(app.state, "vault") or app.state.vault is None:
from core.vault import Vault
app.state.vault = Vault(cfg.db_path)
return app.state.vault
async def get_generator(request: Request):
"""Get wallet generator from app state."""
app = request.app
if not hasattr(app.state, "generator") or app.state.generator is None:
from wallet_engine.generator import WalletGenerator
app.state.generator = WalletGenerator(vault_password=cfg.vault_password)
return app.state.generator
async def get_key_store(request: Request):
"""Get key store from app state."""
app = request.app
if not hasattr(app.state, "key_store") or app.state.key_store is None:
from core.auth import KeyStore
app.state.key_store = KeyStore(cfg.keys_path)
return app.state.key_store
async def get_audit(request: Request):
"""Get audit trail from app state."""
app = request.app
if not hasattr(app.state, "audit") or app.state.audit is None:
from core.audit import AuditTrail
app.state.audit = AuditTrail(cfg.audit_path)
return app.state.audit
async def get_license(request: Request):
"""Get license manager from app state."""
app = request.app
if not hasattr(app.state, "license") or app.state.license is None:
from core.license import LicenseManager
app.state.license = LicenseManager()
return app.state.license
async def get_proof(request: Request):
"""Get proof of generation from app state."""
app = request.app
if not hasattr(app.state, "proof") or app.state.proof is None:
from core.proof import ProofOfGeneration
app.state.proof = ProofOfGeneration(cfg.data_dir / "proof.db")
return app.state.proof

View file

@ -0,0 +1,189 @@
"""Email notification system for wallet events.
Sends transactional emails via SMTP for:
- Wallet generated (confirmation with address)
- Payment received (amount + tx hash)
- Low balance alert (wallet below threshold)
- Rotation due (wallet scheduled for rotation)
- Security alert (suspicious activity detected)
Trust: Email credentials are stored in env vars, never logged.
All email sending is async and non-blocking.
"""
from __future__ import annotations
import asyncio
import logging
import os
import smtplib
import ssl
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
logger = logging.getLogger("wp.email")
SMTP_HOST = os.getenv("WP_SMTP_HOST", "")
SMTP_PORT = int(os.getenv("WP_SMTP_PORT", "587"))
SMTP_USER = os.getenv("WP_SMTP_USER", "")
SMTP_PASS = os.getenv("WP_SMTP_PASS", "")
FROM_EMAIL = os.getenv("WP_FROM_EMAIL", "walletpress@localhost")
TO_EMAIL = os.getenv("WP_NOTIFY_EMAIL", "")
@dataclass
class EmailMessage:
subject: str
body_text: str
body_html: str = ""
TEMPLATES = {
"wallet.generated": {
"subject": "WalletPress — Wallet Generated: {chain}",
"body": """Wallet Generated
Chain: {chain}
Address: {address}
Label: {label}
Time: {time}
This wallet is stored in your encrypted vault.
Use GET /vault/{{id}} with your API key to retrieve the private key.
---
WalletPress Self-Hosted Multi-Chain Wallet Generator
""",
},
"payment.received": {
"subject": "WalletPress — Payment Received: ${amount}",
"body": """Payment Received
Amount: ${amount}
Token: {token}
Chain: {chain}
From: {from_address}
TX: {tx_hash}
Time: {time}
---
WalletPress
""",
},
"balance.alert": {
"subject": "WalletPress — Low Balance Alert: {wallet_id}",
"body": """Low Balance Alert
Wallet: {wallet_id}
Chain: {chain}
Address: {address}
Current Balance: ${balance_usd}
Threshold: ${threshold}
Time: {time}
Action recommended: Fund this wallet or rotate to a new one.
---
WalletPress
""",
},
"rotation.due": {
"subject": "WalletPress — Rotation Due: {wallet_id}",
"body": """Wallet Rotation Due
Wallet: {wallet_id}
Chain: {chain}
Address: {address}
Rotation Due: {due_date}
Rotate this wallet to maintain security best practices.
---
WalletPress
""",
},
"security.alert": {
"subject": "WalletPress — Security Alert: {event}",
"body": """Security Alert
Event: {event}
Wallet: {wallet_id}
Time: {time}
Details: {details}
Review your vault access immediately if this was unexpected.
---
WalletPress
""",
},
}
class EmailNotifier:
"""Async email notification sender via SMTP."""
def __init__(self):
self._enabled = bool(SMTP_HOST and SMTP_USER and SMTP_PASS and TO_EMAIL)
async def send_event(self, event_type: str, data: dict) -> bool:
"""Send a notification email for a wallet event.
Args:
event_type: One of wallet.generated, payment.received, etc.
data: Template variables (chain, address, amount, etc.)
Returns:
True if email was sent successfully.
"""
if not self._enabled:
logger.debug(f"Email not configured, skipping {event_type}")
return False
template = TEMPLATES.get(event_type)
if not template:
logger.warning(f"No email template for {event_type}")
return False
subject = template["subject"].format(**data)
body = template["body"].format(**data)
return await self._send(subject, body)
async def _send(self, subject: str, body: str) -> bool:
"""Send email via SMTP in executor to avoid blocking."""
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, self._send_sync, subject, body)
logger.info(f"Email sent: {subject[:50]}...")
return True
except Exception as e:
logger.error(f"Email send failed: {e}")
return False
def _send_sync(self, subject: str, body: str):
"""Synchronous SMTP send (runs in thread pool)."""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = FROM_EMAIL
msg["To"] = TO_EMAIL
msg.attach(MIMEText(body, "plain", "utf-8"))
msg.attach(MIMEText(f"<pre style='font:14px/1.5 monospace'>{body}</pre>", "html", "utf-8"))
ctx = ssl.create_default_context()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as server:
server.starttls(context=ctx)
server.login(SMTP_USER, SMTP_PASS)
server.sendmail(FROM_EMAIL, [TO_EMAIL], msg.as_string())
_notifier: Optional[EmailNotifier] = None
def get_notifier() -> EmailNotifier:
global _notifier
if _notifier is None:
_notifier = EmailNotifier()
return _notifier

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

@ -0,0 +1,28 @@
"""Shared event bus — decouples producers from WebSocket broadcast.
Modules like x402_marketplace emit events here. main.py's WebSocket
broadcast loop reads from this bus. No circular imports.
"""
from __future__ import annotations
import logging
from typing import Callable
logger = logging.getLogger("wp.event_bus")
_subscribers: list[Callable[[str, dict], None]] = []
def subscribe(fn: Callable[[str, dict], None]) -> None:
"""Register a callback for all events."""
_subscribers.append(fn)
def emit(event_type: str, data: dict) -> None:
"""Emit an event to all subscribers (fire-and-forget)."""
for fn in _subscribers:
try:
fn(event_type, data)
except Exception as e:
logger.debug(f"Event subscriber error: {e}")

320
backend/core/hosting.py Normal file
View file

@ -0,0 +1,320 @@
"""WalletPress Hosted — multi-tenant user management + usage tracking.
Revenue model:
Free: 3 chains, 10 wallets/day, no API access
Starter: $29/mo, 10 chains, 500 wallets/day, API access
Pro: $79/mo, 55 chains, unlimited wallets, full API, 2FA
Enterprise: $199/mo, everything + dedicated support + SLA
Users self-register, get API keys, and pay via Stripe.
Admin creates plans, views usage, manages subscriptions.
SECURITY: Passwords are hashed with Argon2id (m=64MB, t=3, p=4).
Legacy SHA-256 hashes are migrated on next successful login.
"""
from __future__ import annotations
import logging
import os
import secrets
import sqlite3
import time
from pathlib import Path
from typing import Optional
from argon2 import PasswordHasher
from argon2.exceptions import (
VerifyMismatchError,
InvalidHashError,
VerificationError,
)
from .config import cfg
logger = logging.getLogger("wp.hosting")
# Argon2id parameters: OWASP recommendations as of 2024
# m = 64 MiB memory cost, t = 3 iterations, p = 4 parallelism
# (argon2-cffi defaults are 64MB / 3 iterations / 4 lanes)
_ph = PasswordHasher()
def _hash_password(plaintext: str) -> str:
"""Hash a password with Argon2id. Returns the encoded hash string.
The returned string includes the salt, parameters, and hash in a
self-describing format like:
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
"""
return _ph.hash(plaintext)
def _verify_password(stored_hash: str, plaintext: str) -> bool:
"""Verify a plaintext password against a stored hash.
Supports both new Argon2id hashes AND legacy unsalted SHA-256 hashes
(for migration). Returns True on match, False on mismatch.
"""
if not stored_hash:
return False
# Legacy SHA-256 hash: 64 hex chars, no $ separators
if "$" not in stored_hash and len(stored_hash) == 64:
try:
import hashlib
legacy_match = hashlib.sha256(plaintext.encode()).hexdigest() == stored_hash
return legacy_match
except Exception:
return False
# Argon2id hash
try:
_ph.verify(stored_hash, plaintext)
return True
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def _needs_rehash(stored_hash: str) -> bool:
"""Check if the stored hash uses outdated parameters and needs re-hashing."""
if "$" not in stored_hash:
return True # Legacy SHA-256 — needs migration to Argon2id
try:
return _ph.check_needs_rehash(stored_hash)
except InvalidHashError:
return True
PLANS = {
"free": {"name": "Free", "price_usd": 0, "chains": 3, "daily_wallet_limit": 10, "api_access": False, "features": ["basic_generation"]},
"starter": {"name": "Starter", "price_usd": 29, "chains": 10, "daily_wallet_limit": 500, "api_access": True, "features": ["basic_generation", "batch", "mnemonic", "export"]},
"pro": {"name": "Pro", "price_usd": 79, "chains": 55, "daily_wallet_limit": 0, "api_access": True, "features": ["all"]},
"enterprise": {"name": "Enterprise", "price_usd": 199, "chains": 55, "daily_wallet_limit": 0, "api_access": True, "features": ["all", "2fa", "sla"]},
}
class HostingDB:
"""User and subscription management."""
def __init__(self, db_path: Path):
self.db_path = db_path
self._init_db()
def _conn(self):
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
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 users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT DEFAULT '',
plan TEXT DEFAULT 'free',
api_key TEXT UNIQUE DEFAULT '',
stripe_customer_id TEXT DEFAULT '',
subscription_id TEXT DEFAULT '',
subscription_status TEXT DEFAULT 'active',
created_at REAL NOT NULL,
last_login_at REAL DEFAULT 0,
email_verified INTEGER DEFAULT 0,
email_verify_token TEXT DEFAULT '',
email_verify_expires REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
chain TEXT DEFAULT '',
count INTEGER DEFAULT 1,
timestamp REAL NOT NULL,
ip TEXT DEFAULT '',
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_usage_user ON usage_log(user_id);
CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_log(timestamp);
""")
conn.commit()
conn.close()
def register(self, email: str, password: str, name: str = "") -> dict:
"""Register a new user with email verification (P2-12 fix).
Email verification is OPT-IN via WP_REQUIRE_EMAIL_VERIFY=1. By default
(community / self-hosted dev), users can use the API key immediately.
For hosted deployments with SMTP configured, set that flag to require
email confirmation before the API key becomes active.
Returns:
dict with user_id, api_key (provisional), plan, and optionally
a verification_token the caller should email to the user.
"""
conn = self._conn()
existing = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
if existing:
conn.close()
return {"error": "Email already registered"}
user_id = f"u_{secrets.token_hex(8)}"
pw_hash = _hash_password(password)
api_key = f"wp_live_{secrets.token_hex(24)}"
require_verify = os.getenv("WP_REQUIRE_EMAIL_VERIFY", "0") == "1"
verify_token = secrets.token_urlsafe(32) if require_verify else ""
verify_expires = time.time() + 86400 if require_verify else 0 # 24h
conn.execute(
"""INSERT INTO users
(id, email, password_hash, name, plan, api_key, created_at,
email_verified, email_verify_token, email_verify_expires)
VALUES (?, ?, ?, ?, 'free', ?, ?, ?, ?, ?)""",
(
user_id, email, pw_hash, name, api_key, time.time(),
0 if require_verify else 1, # auto-verify if not required
verify_token, verify_expires,
),
)
conn.commit()
conn.close()
result = {
"user_id": user_id,
"api_key": api_key,
"plan": "free",
"email_verification_required": require_verify,
}
if require_verify:
result["verification_token"] = verify_token
result["note"] = (
"Send POST /hosting/verify-email with {token} to activate. "
"Token expires in 24h. Configure WP_SMTP_HOST etc. to send "
"the email automatically (see core/email_notify.py)."
)
return result
def verify_email(self, token: str) -> bool:
"""Mark the email as verified using a token from registration."""
if not token:
return False
conn = self._conn()
row = conn.execute(
"SELECT id, email_verify_expires FROM users WHERE email_verify_token = ?",
(token,),
).fetchone()
if not row:
conn.close()
return False
if row["email_verify_expires"] and time.time() > row["email_verify_expires"]:
conn.close()
return False
conn.execute(
"""UPDATE users
SET email_verified = 1, email_verify_token = '', email_verify_expires = 0
WHERE id = ?""",
(row["id"],),
)
conn.commit()
conn.close()
return True
def is_email_verified(self, user_id: str) -> bool:
"""True if the user has verified their email (or verification is not required)."""
conn = self._conn()
row = conn.execute(
"SELECT email_verified FROM users WHERE id = ?", (user_id,)
).fetchone()
conn.close()
if not row:
return False
return bool(row["email_verified"])
def login(self, email: str, password: str) -> Optional[dict]:
"""Verify login and migrate legacy SHA-256 hash to Argon2id on success.
Returns the user row (without password_hash) on success, None on failure.
On successful login with an outdated hash, transparently re-hashes the
password with current Argon2id parameters.
"""
conn = self._conn()
row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
if not row:
conn.close()
return None
stored_hash = row["password_hash"]
if not _verify_password(stored_hash, password):
conn.close()
return None
# Migrate outdated hash to current Argon2id params
if _needs_rehash(stored_hash):
try:
new_hash = _hash_password(password)
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(new_hash, row["id"]),
)
conn.commit()
logger.info(f"Migrated password hash to Argon2id for user {row['id']}")
except Exception as e:
logger.warning(f"Password hash migration failed for user {row['id']}: {e}")
# Return everything except password_hash — never leak it
safe = {k: v for k, v in dict(row).items() if k != "password_hash"}
conn.close()
return safe
def get_by_api_key(self, api_key: str) -> Optional[dict]:
conn = self._conn()
row = conn.execute("SELECT * FROM users WHERE api_key = ?", (api_key,)).fetchone()
conn.close()
return dict(row) if row else None
def log_usage(self, user_id: str, action: str, chain: str = "", count: int = 1, ip: str = ""):
conn = self._conn()
conn.execute(
"INSERT INTO usage_log (user_id, action, chain, count, timestamp, ip) VALUES (?, ?, ?, ?, ?, ?)",
(user_id, action, chain, count, time.time(), ip),
)
conn.commit()
conn.close()
def daily_usage(self, user_id: str) -> int:
today = int(time.time()) - (int(time.time()) % 86400)
conn = self._conn()
row = conn.execute(
"SELECT COALESCE(SUM(count), 0) as total FROM usage_log WHERE user_id = ? AND timestamp >= ?",
(user_id, today),
).fetchone()
conn.close()
return row["total"] if row else 0
def check_limit(self, user_id: str) -> tuple[bool, str]:
conn = self._conn()
row = conn.execute("SELECT plan FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
if not row:
return False, "User not found"
plan = PLANS.get(row["plan"], PLANS["free"])
if plan["daily_wallet_limit"] == 0:
return True, "" # Unlimited
used = self.daily_usage(user_id)
if used >= plan["daily_wallet_limit"]:
return False, f"Daily limit reached ({plan['daily_wallet_limit']} wallets). Upgrade at /hosting/plans"
return True, ""
def get_user(self, user_id: str) -> Optional[dict]:
conn = self._conn()
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
return dict(row) if row else None
# Singleton
_hosting: Optional[HostingDB] = None
def get_hosting() -> HostingDB:
global _hosting
if _hosting is None:
_hosting = HostingDB(cfg.data_dir / "hosting.db")
return _hosting

View file

@ -0,0 +1,57 @@
"""IP allowlisting middleware for admin operations.
When WP_ALLOWED_IPS is set, all requests from non-whitelisted IPs
are rejected. This prevents leaked API keys from being used off-network.
Trust: IP allowlisting means even if your API key is compromised,
an attacker can't use it from a different IP address.
"""
from __future__ import annotations
import os
from fastapi import HTTPException, Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
ALLOWED_IPS: list[str] = []
_raw = os.getenv("WP_ALLOWED_IPS", "")
if _raw:
ALLOWED_IPS = [ip.strip() for ip in _raw.split(",")]
class IPAllowlistMiddleware(BaseHTTPMiddleware):
"""Rejects requests from non-whitelisted IPs when configured.
Only applies to admin/sensitive endpoints.
Health and public endpoints are exempt.
"""
SENSITIVE_PREFIXES = ("/api/v1/chain-vault/admin", "/api/v1/chain-vault/vault/",
"/api/v1/chain-vault/export", "/api/v1/chain-vault/proof/commit",
"/api/v1/chain-vault/proof/verify", "/api/v1/chain-vault/paper-wallet",
"/api/v1/chain-vault/tx/broadcast")
def __init__(self, app):
super().__init__(app)
self._enabled = len(ALLOWED_IPS) > 0
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if not self._enabled:
return await call_next(request)
# Check if this is a sensitive endpoint
is_sensitive = any(request.url.path.startswith(p) for p in self.SENSITIVE_PREFIXES)
if not is_sensitive:
return await call_next(request)
client_ip = request.client.host if request.client else ""
if client_ip not in ALLOWED_IPS and "0.0.0.0" not in ALLOWED_IPS:
raise HTTPException(
status_code=403,
detail=f"Access denied from {client_ip}. Configure WP_ALLOWED_IPS to whitelist this IP.",
)
return await call_next(request)

260
backend/core/license.py Normal file
View file

@ -0,0 +1,260 @@
"""License enforcement — open source core, paid enterprise features.
PHILOSOPHY:
The code is 100% open source (MIT). You can read every line.
What you pay for is convenience, support, and full features.
The license key is a simple HMAC-signed token. The verification
code is PUBLIC you can see exactly what it checks. No secrets.
No obfuscation. We don't hide what the license unlocks.
EDITIONS:
Community (free, no key):
- 3 chains (BTC, ETH, SOL)
- Single wallet generation
- Basic vault (10 wallets max)
- No batch, no API, no 2FA, no email
- WordPress plugin (free tier)
Starter ($49 one-time / $29/mo hosted):
- 10 chains
- Batch generation (up to 100)
- Full vault (unlimited)
- Mnemonic conversion
- CLI access
Pro ($199 one-time / $79/mo hosted):
- 55 chains
- Unlimited batch
- Shamir backup
- Sweep & rotate
- Webhooks, alerts, audit
- IP allowlisting
- Email notifications
- API marketplace access
Enterprise ($499 one-time / $199/mo hosted):
- Everything in Pro
- 2FA enforcement
- Airdrop tool (100k+ wallets)
- Health monitoring
- SLA support
- Custom integration
- White-label option
UPGRADE PATH:
Community (free) Starter ($49) Pro ($199) Enterprise ($499)
Every upgrade preserves your vault, wallets, and configurations.
Downgrades are also supported (limited to lower tier's capabilities).
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
logger = logging.getLogger("wp.license")
EDITION_DESCRIPTIONS = {
"community": "Open source engine. 3 chains. WordPress plugin. No setup support.",
"pro": "License key unlocks 55 chains + all features. One-command deploy script included.",
}
FEATURES = {
"community": {
"name": "Community",
"price_usd": 0,
"description": EDITION_DESCRIPTIONS["community"],
"chains": ["btc", "eth", "sol"],
"max_vault_wallets": 10,
"batch_max": 1,
"features": [
"wallet_generation", "basic_vault", "wp_plugin_free",
"bip39_verify", "open_source_code",
],
},
"pro": {
"name": "Pro",
"price_usd": 199,
"description": EDITION_DESCRIPTIONS["pro"],
"chains": "__all__",
"max_vault_wallets": 100000,
"batch_max": 100000,
"features": [
"all_chains", "full_vault", "wp_plugin_pro",
"bip39_verify", "batch_generation", "hd_wallets",
"shamir_backup", "sweep_rotate", "webhooks",
"alerts", "audit", "email_notifications",
"ip_allowlist", "two_factor_auth",
"airdrop_tool", "health_monitoring",
"proof_of_generation",
"cli_access", "export_all_formats",
"everything",
],
},
}
class LicenseManager:
"""Manages license verification with public verification logic.
PRICING (simplified one decision, two options):
WordPress Plugin (free) 3 chains, basic vault
Then pick ONE:
BUY ONCE Pro license ($199) use forever, self-host
SUBSCRIBE Hosted ($29/mo) no setup, auto-updates
Both unlock the same thing: 55 chains, all features.
The verification code is deliberately simple and PUBLIC.
Anyone can read it and understand exactly what the license unlocks.
No secrets, no obfuscation, no shenanigans.
"""
def __init__(self, key_path: Optional[Path] = None):
self.key_path = key_path or Path("/etc/walletpress/license.key")
self._tier: str = self._detect_tier()
def _detect_tier(self) -> str:
"""Check for license key file.
The license key is a simple signed JSON token.
Without a key, the software runs in Community mode.
Verification is public you can see exactly what this checks.
"""
# Check environment variable first (for Docker/k8s)
env_license = os.getenv("WP_LICENSE_KEY", "")
if env_license:
tier = self._verify_token(env_license)
if tier:
logger.info(f"License verified via env: {tier}")
return tier
# Check license file
if self.key_path.exists():
try:
content = self.key_path.read_text().strip()
tier = self._verify_token(content)
if tier:
logger.info(f"License verified via file: {tier}")
return tier
logger.warning("License file found but invalid")
except Exception as e:
logger.error(f"License file error: {e}")
# No valid license — community mode
logger.info("No valid license found. Running in Community mode.")
return "community"
def _verify_token(self, token: str) -> Optional[str]:
"""Verify a license token.
Token format: base64(json({"tier":"pro","exp":1234567890,"sig":"..."}))
The signature is HMAC-SHA256 of the payload with our public key.
The public key for signature verification is EMBEDDED here.
Anyone can verify our license tokens.
"""
try:
import base64
decoded = base64.b64decode(token)
data = json.loads(decoded)
tier = data.get("tier", "")
exp = data.get("exp", 0)
sig = data.get("sig", "")
# Check tier is valid
if tier not in FEATURES:
return None
# Check expiration
if exp > 0 and time.time() > exp:
logger.info(f"License expired for tier {tier}")
return None
# Verify signature
payload = json.dumps({"tier": tier, "exp": exp}, sort_keys=True)
# Use WP_LICENSE_SECRET for verification (same key used for generation).
# Falls back to WP_ADMIN_KEY for backward compatibility.
verify_key = os.getenv("WP_LICENSE_SECRET") or os.getenv("WP_ADMIN_KEY")
if not verify_key:
logger.warning("No WP_LICENSE_SECRET or WP_ADMIN_KEY configured — cannot verify license")
return None
expected = hmac.new(verify_key.encode(), payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
logger.warning(f"License signature invalid for {tier}")
return None
return tier
except Exception as e:
logger.error(f"License verification error: {e}")
return None
@property
def tier(self) -> str:
return self._tier
@property
def is_paid(self) -> bool:
return self._tier in ("starter", "pro", "enterprise")
def has_feature(self, feature: str) -> bool:
tier_config = FEATURES.get(self._tier, FEATURES["community"])
return feature in tier_config.get("features", []) or "everything" in tier_config.get("features", [])
def get_chain_list(self) -> list[str]:
"""Get the list of available chains for this tier."""
from wallet_engine.chains import CHAINS
tier_info = FEATURES.get(self._tier, FEATURES["community"])
chains = tier_info["chains"]
if chains == "__all__":
return list(CHAINS.keys())
return [c for c in chains if c in CHAINS]
def check_wallet_limit(self, current_count: int) -> bool:
"""Check if wallet count is within tier limit."""
tier_info = FEATURES.get(self._tier, FEATURES["community"])
limit = tier_info["max_vault_wallets"]
if limit == 0:
return True # Unlimited
return current_count < limit
def check_batch_limit(self, requested: int) -> bool:
"""Check if batch size is within tier limit."""
tier_info = FEATURES.get(self._tier, FEATURES["community"])
limit = tier_info["batch_max"]
return requested <= limit
def to_dict(self) -> dict:
tier_info = FEATURES.get(self._tier, FEATURES["community"])
return {
"tier": self._tier,
"plan_name": tier_info["name"],
"is_paid": self.is_paid,
"max_chains": tier_info["chains"],
"max_vault_wallets": tier_info["max_vault_wallets"],
"max_batch": tier_info["batch_max"],
"features": tier_info["features"],
}
# Singleton
_license: Optional[LicenseManager] = None
def get_license() -> LicenseManager:
global _license
if _license is None:
_license = LicenseManager()
return _license

View file

@ -0,0 +1,116 @@
"""License enforcement — middleware for global checks, Depends() for per-route.
Two-layer approach:
1. LicenseMiddleware global, path-based, catches obvious blocks early.
2. require_feature() FastAPI dependency, injectable per-route for granular
control. Decouples licensing from URL structure.
"""
from __future__ import annotations
from fastapi import HTTPException, Request, status
from fastapi.params import Depends
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse, Response
from .license import FEATURES, get_license
# ── Per-route dependency (preferred) ─────────────────────────────
def require_feature(feature: str):
"""FastAPI dependency that checks a license feature.
Use on individual routes instead of coupling licensing to URL paths:
@router.post("/batch", dependencies=[Depends(require_feature("batch_generation"))])
Raises 402 with upsell info if the feature is not available.
"""
TIER_UPSELL = {
"community": {"next": "starter", "price": 49, "url": "https://walletpress.cc/buy.html"},
"starter": {"next": "pro", "price": 199, "url": "https://walletpress.cc/buy.html"},
"pro": {"next": "enterprise", "price": 499, "url": "https://walletpress.cc/buy.html"},
}
async def dependency(request: Request) -> None:
lic = get_license()
if lic.has_feature(feature):
return
tier = lic.tier
upsell = TIER_UPSELL.get(tier, TIER_UPSELL["community"])
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={
"error": f"Feature '{feature}' requires {upsell['next'].title()} tier or higher",
"current_tier": tier,
"required_tier": upsell["next"],
"price_usd": upsell["price"],
"upgrade_url": upsell["url"],
})
return Depends(dependency)
# ── Global middleware (fallback) ─────────────────────────────────
class LicenseMiddleware(BaseHTTPMiddleware):
"""Path-based middleware for coarse-grained license enforcement.
Prefer require_feature() on individual routes for new endpoints.
This middleware is kept for backward compatibility and as a defense-in-depth
layer for any routes that forgot the dependency.
"""
EXEMPT_PREFIXES = ("/health", "/docs", "/openapi.json", "/metrics",
"/api/v1/test-vectors", "/api/v1/marketplace/pricing",
"/api/v1/license", "/hosting/plans")
TIER_UPSELL = {
"community": {"next": "starter", "price": 49, "url": "https://walletpress.cc/buy.html"},
"starter": {"next": "pro", "price": 199, "url": "https://walletpress.cc/buy.html"},
"pro": {"next": "enterprise", "price": 499, "url": "https://walletpress.cc/buy.html"},
}
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
path = request.url.path
if any(path.startswith(p) for p in self.EXEMPT_PREFIXES):
return await call_next(request)
lic = get_license()
tier = lic.tier
if tier == "community" and path.startswith("/api/v1/chain-vault/generate"):
if request.method == "POST":
body = await request.json()
chain = body.get("chain", "")
allowed = lic.get_chain_list()
if chain and chain not in allowed:
upsell = self.TIER_UPSELL["community"]
return JSONResponse(status_code=402, content={
"detail": {
"error": f"Chain '{chain}' requires {upsell['next'].title()} tier or higher",
"current_tier": tier,
"required_tier": upsell["next"],
"price_usd": upsell["price"],
"available_chains": allowed,
"upgrade_url": upsell["url"],
}
})
if tier == "community" and path.startswith(("/api/v1/chain-vault/generate/batch", "/api/v1/airdrop/")):
if request.method == "POST":
body = await request.json()
count = body.get("count", 0) or len(body.get("chains", []))
if count > 1 and not lic.check_batch_limit(count):
upsell = self.TIER_UPSELL["community"]
return JSONResponse(status_code=402, content={
"detail": {
"error": f"Batch of {count} requires {upsell['next'].title()} tier or higher",
"current_tier": tier,
"required_tier": upsell["next"],
"max_batch": FEATURES["community"]["batch_max"],
"price_usd": upsell["price"],
"upgrade_url": upsell["url"],
}
})
return await call_next(request)

204
backend/core/onboarding.py Normal file
View file

@ -0,0 +1,204 @@
"""Onboarding friction — the strategies that make self-hosting painful enough
that $29/mo feels like the obvious choice, while keeping the code fully open source.
PHILOSOPHY:
We don't hide code. We don't break working installations.
We just make the DIY path genuinely tedious enough that people
value their time more than $29/month.
STRATEGY 1 RPC CONFIGURATION HELL:
For each chain, the self-hosted version needs a working RPC endpoint.
We only provide unreliable free defaults. The /config endpoint returns
a MANAGEMENT_REQUIRED warning for most chains.
To get reliable RPC, users must:
a) Sign up with 5-6 different RPC providers (Infura, Alchemy, QuickNode,
Helius, Chainstack, TronGrid, etc.)
b) Get API keys for each
c) Configure 55 environment variables
d) Monitor rate limits manually
The hosted version has all RPC pre-configured with premium tiers.
STRATEGY 2 CHAIN REGISTRY DRIFT:
We update chains.py weekly with:
- New chains (we added 20 chains this month alone)
- Updated RPC URLs (free endpoints change frequently)
- Fixed derivation paths (some chains had wrong paths)
Self-hosted: git pull, restart, verify test vectors
Hosted: happens automatically, zero effort
STRATEGY 3 LICENSE KEY FOR CHAINS:
Already implemented. The open source code has a license check.
3 chains free (BTC, ETH, SOL). 55 chains requires a key.
The code is open. The license check is public and auditable.
Users CAN patch it out. Most won't bother for $49.
STRATEGY 4 ENTERPRISE FEATURES AS MODULES:
Features like 2FA, email, webhooks, health monitoring, airdrop tool
are in the open source code but gated by the license check.
We don't hide them. We just make them require a key.
Users see the code. They know it works. They pay to unlock it.
STRATEGY 5 RELEASE LAG:
The GitHub release lags behind hosted by 1-2 weeks.
Critical bug fixes are backported to hosted immediately.
Open source releases are batched into quarterly stable releases.
This is standard practice (GitLab, GitPrime, etc.)
STRATEGY 6 CONFIG CI/CD PIPELINE:
Self-hosted users must run `scripts/verify_test_vectors.py` after
every update. If test vectors fail (because we added/changed chains),
they need to debug and fix. The CI pipeline that tests every chain
against BIP39 vectors is NOT part of the open source repo.
STRATEGY 7 THE WALLETPRESS RC FILE:
The self-hosted version requires a configuration file with 55 RPC
endpoints. We provide a generator script that asks interactive
questions and writes the config. It's tedious by design.
The hosted version: zero config.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from .license import get_license
logger = logging.getLogger("wp.onboarding")
def check_rpc_health() -> dict[str, Any]:
"""Check how many chains have working RPC endpoints configured.
Returns a report that's shown in the admin dashboard and on `/health`.
This is intentionally discouraging for self-hosted users with no config.
Chains with NO RPC endpoint show as "❌ Requires RPC config"
Chains with RELIABLE RPC show as "✅ Working"
"""
from wallet_engine.chains import CHAINS
from routers.balance_fetcher import EVM_RPC, SOLANA_RPC, TRON_RPC
configured = 0
total = len(CHAINS)
warnings = []
# EVM chains
for chain_key in CHAINS:
if chain_key in EVM_RPC:
configured += 1
elif chain_key in ("sol",):
if SOLANA_RPC:
configured += 1
elif chain_key in ("trx",):
if TRON_RPC:
configured += 1
# Bitcoin-family chains use free APIs by default
elif chain_key in ("btc", "ltc", "doge", "bch", "dash", "zec"):
configured += 1
unconfigured = total - configured
if unconfigured > 0:
warnings.append(
f"{unconfigured} chains have no RPC endpoint. "
f"Balance checking and transaction broadcast will fail for these chains. "
f"Configure RPC endpoints in /etc/walletpress/rpc.json or use the hosted version."
)
return {
"total_chains": total,
"rpc_configured": configured,
"rpc_missing": unconfigured,
"rpc_health": "degraded" if unconfigured > 0 else "healthy",
"warnings": warnings,
"recommendation": "Use the hosted version for full RPC coverage across all 55 chains." if unconfigured > 10 else "",
}
def generate_rpc_config_template() -> str:
"""Generate a template for the RPC configuration file.
This is intentionally verbose. For 55 chains, users need to fill in
55 RPC URLs. Each one requires signing up with a different provider.
"""
from wallet_engine.chains import CHAINS
lines = [
"# WalletPress RPC Configuration",
"# ================================",
"# For each chain, provide a reliable JSON-RPC endpoint.",
"# Without these, balance checking and transaction broadcast WILL FAIL.",
"#",
"# RECOMMENDED PROVIDERS (all offer free tiers):",
"# EVM chains: Infura (infura.io), Alchemy (alchemy.com), QuickNode (quicknode.com)",
"# Solana: Helius (helius.xyz), QuickNode",
"# Bitcoin: blockchain.info (free, no key)",
"# TRON: TronGrid (trongrid.io)",
"# Cosmos: public nodes from cosmos.directory",
"# Polkadot: Subscan / public RPC",
"#",
"# You need accounts with 5-6 different providers for full coverage.",
"# The hosted version includes all RPC endpoints pre-configured.",
"#",
"# Format: CHAIN_KEY=RPC_URL",
"#",
]
for chain_key in sorted(CHAINS.keys()):
chain = CHAINS[chain_key]
lines.append(f"# {chain.name} ({chain.symbol})")
rpc = getattr(chain, 'rpc_url', '') or ''
hint = "# NO DEFAULT RPC — sign up with a provider"
if rpc:
hint = f"# Default (rate limited): {rpc}"
lines.append(f"# {hint}")
lines.append(f"# WP_RPC_{chain_key.upper()}=\n")
return "\n".join(lines)
def onboarding_status() -> dict[str, Any]:
"""Get the full onboarding status — shown on first run and in admin dashboard.
Returns a comprehensive checklist of what's configured and what isn't.
The hosted version has all items checked. Self-hosted typically has 40+ items unchecked.
"""
lic = get_license()
rpc_health = check_rpc_health()
vault_password = bool(os.getenv("WP_VAULT_PASSWORD", ""))
admin_key = bool(os.getenv("WP_ADMIN_KEY", ""))
ssl = os.getenv("WP_SSL_ENABLED", "") or os.path.exists("/etc/letsencrypt")
domain = os.getenv("WP_DOMAIN", "")
checklist = [
{"item": "License key configured", "done": lic.is_paid, "effort": "Buy license → set env var", "hosted": "Included"},
{"item": "Vault encryption enabled", "done": vault_password, "effort": "Generate password → set WP_VAULT_PASSWORD", "hosted": "Automatic"},
{"item": "Admin API key set", "done": admin_key, "effort": "Generate key → set WP_ADMIN_KEY", "hosted": "Automatic"},
{"item": "SSL/HTTPS configured", "done": ssl, "effort": "Install certbot → get certificate → configure nginx", "hosted": "Automatic"},
{"item": "Domain name configured", "done": bool(domain), "effort": "Buy domain → configure DNS → set WP_DOMAIN", "hosted": "Included"},
{"item": "RPC endpoints configured", "done": rpc_health["rpc_missing"] == 0, "effort": f"Sign up with providers → configure {rpc_health['rpc_missing']} RPC URLs", "hosted": "All 55 pre-configured"},
{"item": "Email (SMTP) configured", "done": bool(os.getenv("WP_SMTP_HOST", "")), "effort": "Get SMTP credentials → set 4 env vars", "hosted": "Included"},
{"item": "Backup schedule configured", "done": bool(os.getenv("WP_BACKUP_SCHEDULE", "")), "effort": "Configure cron → set backup destination → test restore", "hosted": "Daily automated backups"},
{"item": "Monitoring configured", "done": bool(os.getenv("WP_MONITOR_URL", "")), "effort": "Set up uptime monitor → configure alerting", "hosted": "Built-in health monitoring"},
{"item": "Rate limiting configured", "done": bool(os.getenv("WP_RATE_LIMIT", "")), "effort": "Set WP_RATE_LIMIT env var", "hosted": "Pre-configured"},
{"item": "CORS origins configured", "done": os.getenv("WP_CORS_ORIGINS", "") != "*", "effort": "Set specific origins in WP_CORS_ORIGINS", "hosted": "Pre-configured"},
{"item": "Process manager configured", "done": bool(os.getenv("WP_SYSTEMD_ENABLED", "")), "effort": "Install systemd service → enable → start", "hosted": "Managed"},
]
done_count = sum(1 for c in checklist if c["done"])
total = len(checklist)
missing = [c for c in checklist if not c["done"]]
return {
"onboarding_complete": done_count == total,
"completion_percentage": round(done_count / total * 100),
"items_done": done_count,
"items_total": total,
"items_missing": len(missing),
"missing_items": missing,
"rpc_health": rpc_health,
"recommendation": "hosted" if done_count < total / 2 else "self-hosted",
"hosted_url": "https://walletpress.cc/pricing",
}

235
backend/core/pdf.py Normal file
View file

@ -0,0 +1,235 @@
"""PDF generation for paper wallets and birth certificates."""
from __future__ import annotations
import io
import logging
import time
from typing import Optional
import qrcode
from core.config import cfg
logger = logging.getLogger("wp.pdf")
try:
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
HAS_REPORTLAB = True
except ImportError:
HAS_REPORTLAB = False
try:
HAS_QR = True
except ImportError:
HAS_QR = False
def _qr_image(data: str, size: int = 200) -> Optional[ImageReader]:
if not HAS_QR:
return None
import io
qr = qrcode.QRCode(box_size=4, border=2)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return ImageReader(buf)
def generate_paper_wallet(address: str, chain: str, private_key: str = "",
public_key: str = "", derivation_path: str = "",
created_at: float = 0.0, proof_leaf: str = "",
label: str = "") -> Optional[bytes]:
"""Generate a printable paper wallet PDF.
Returns PDF bytes or None if reportlab is not installed.
Paper wallets are for cold storage. Print on durable paper. Store safely.
"""
if not HAS_REPORTLAB:
return None
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=letter)
w, h = letter
margin = 0.75 * inch
y = h - margin
# Title
c.setFont("Helvetica-Bold", 20)
c.drawString(margin, y, "WALLETPRESS PAPER WALLET")
y -= 30
c.setFont("Helvetica", 10)
c.drawString(margin, y, f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(created_at or time.time()))}")
y -= 14
c.drawString(margin, y, f"Software: WalletPress Pro v{cfg.version}")
y -= 14
c.drawString(margin, y, "Chain: " + chain.upper())
y -= 14
if label:
c.drawString(margin, y, f"Label: {label}")
y -= 14
y -= 20
# Warning box
c.setFillColorRGB(1, 0.9, 0.9)
c.rect(margin, y - 50, w - 2 * margin, 50, fill=1, stroke=0)
c.setFillColorRGB(0.8, 0, 0)
c.setFont("Helvetica-Bold", 11)
c.drawString(margin + 8, y - 16, "⚠ KEEP THIS DOCUMENT SECURE")
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.5, 0, 0)
c.drawString(margin + 8, y - 32, "Anyone with this paper can control the crypto assets. Store in a safe, waterproof location.")
c.setFillColorRGB(0, 0, 0)
y -= 70
# Address section
c.setFont("Helvetica-Bold", 12)
c.drawString(margin, y, "Public Address")
y -= 16
c.setFont("Courier", 9)
c.drawString(margin, y, address)
y -= 30
# QR code for address
qr = _qr_image(f"{chain}:{address}")
if qr:
c.drawImage(qr, margin, y - 100, width=100, height=100)
y -= 120
# Private key section
if private_key:
c.setFont("Helvetica-Bold", 12)
c.setFillColorRGB(0.8, 0, 0)
c.drawString(margin, y, "PRIVATE KEY (KEEP SECRET)")
y -= 16
c.setFont("Courier", 8)
c.setFillColorRGB(0, 0, 0)
# Split long key into lines
key_lines = [private_key[i:i+48] for i in range(0, len(private_key), 48)]
for line in key_lines:
c.drawString(margin, y, line)
y -= 12
y -= 10
# Public key
if public_key:
c.setFont("Helvetica-Bold", 11)
c.drawString(margin, y, "Public Key")
y -= 14
c.setFont("Courier", 7)
pk_lines = [public_key[i:i+64] for i in range(0, len(public_key), 64)]
for line in pk_lines[:4]:
c.drawString(margin, y, line)
y -= 10
y -= 10
# Derivation path
if derivation_path:
c.setFont("Helvetica", 9)
c.drawString(margin, y, f"Derivation: {derivation_path}")
y -= 14
# Proof hash
if proof_leaf:
c.setFont("Helvetica", 7)
c.drawString(margin, y, f"Proof: {proof_leaf[:48]}...")
y -= 10
# Footer
c.setFont("Helvetica", 7)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawString(margin, margin * 0.5, f"WalletPress Pro — walletpress.cc — {cfg.version} — MIT Open Source")
c.save()
return buf.getvalue()
def generate_birth_certificate(wallet_id: str, address: str, chain: str,
public_key: str = "", created_at: float = 0.0,
proof_leaf: str = "", root_hash: str = "",
committed: bool = False) -> Optional[bytes]:
"""Generate a Wallet Birth Certificate — printable provenance document.
This is a unique feature. It cryptographically proves when and how
this wallet was created. The Proof of Generation hash links this
document to the immutable audit trail.
"""
if not HAS_REPORTLAB:
return None
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=letter)
w, h = letter
margin = 0.75 * inch
y = h - margin
# Certificate border
c.setStrokeColorRGB(0.77, 0.55, 0.30)
c.setLineWidth(3)
c.rect(margin - 10, margin - 10, w - 2 * margin + 20, h - 2 * margin + 20)
# Seal graphic
c.setFillColorRGB(0.77, 0.55, 0.30)
c.setFont("Helvetica-Bold", 28)
c.drawString(margin, y, "✦ WALLET BIRTH CERTIFICATE ✦")
y -= 36
c.setFont("Helvetica", 12)
c.setFillColorRGB(0.4, 0.4, 0.4)
c.drawString(margin, y, "This certifies that the following wallet was generated by WalletPress")
y -= 14
c.drawString(margin, y, "with cryptographic proof of its creation time, code version, and integrity.")
y -= 30
# Details
c.setFont("Helvetica-Bold", 11)
c.setFillColorRGB(0, 0, 0)
details = [
("Wallet ID", wallet_id),
("Address", address),
("Chain", chain.upper()),
("Created", time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(created_at))),
("Public Key", public_key[:48] + "..." if len(public_key) > 48 else public_key),
]
for label, value in details:
c.setFont("Helvetica-Bold", 10)
c.drawString(margin, y, label + ":")
c.setFont("Courier", 9)
c.drawString(margin + 100, y, value)
y -= 16
y -= 10
# Proof section
c.setStrokeColorRGB(0.77, 0.55, 0.30)
c.setLineWidth(1)
c.rect(margin, y - 50, w - 2 * margin, 50)
c.setFont("Helvetica-Bold", 10)
c.drawString(margin + 6, y - 14, "Proof of Generation — Merkle Attestation")
c.setFont("Courier", 7)
c.drawString(margin + 6, y - 28, f"Leaf: {proof_leaf}")
if root_hash:
c.drawString(margin + 6, y - 40, f"Root: {root_hash}")
y -= 70
# QR for verification
qr = _qr_image(f"https://walletpress.cc/api/v1/proof/verify/{wallet_id}")
if qr:
c.drawImage(qr, w - margin - 100, y - 100, width=100, height=100)
y -= 120
# Trust text
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawString(margin, y, "Verify: walletpress.cc/api/v1/proof/verify/{wallet_id}")
y -= 10
c.drawString(margin, y, f"Software: WalletPress Pro v{cfg.version} — MIT Open Source — walletpress.cc")
c.save()
return buf.getvalue()

701
backend/core/proof.py Normal file
View file

@ -0,0 +1,701 @@
"""Proof of Generation — Immutable Wallet Attestation System.
THE BIG IDEA:
Every wallet generated by WalletPress gets a cryptographic attestation
that proves WHEN it was created, by WHICH code version, and WHAT
the public key was at creation time.
This is like a birth certificate for crypto wallets.
HOW IT WORKS:
1. On wallet generation, we create an attestation record containing:
- timestamp, code version, chain, public key hash
- SHA-256 merkle leaf in a local tree
2. Periodically (or on request), we compute the Merkle root and
save the proof path for every leaf.
3. The root is committed to:
- Local SQLite (always free, instant)
- Arweave (optional permanent, ~$0.000001 per write)
- Ethereum (optional on-chain timestamp, ~$5-50 gas)
4. Anyone can verify a wallet's attestation by:
- Providing the full proof path (leaf root)
- Checking the root is committed where claimed
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
logger = logging.getLogger("wp.proof")
PROOF_VERSION = "walletpress-proof-v1"
# Ed25519 signing key for cryptographic receipts.
# Generated once on first startup, persisted to disk.
# Used to sign order receipts so customers can verify authenticity.
_RECEIPT_KEY: Optional[bytes] = None
_RECEIPT_PUBKEY: Optional[bytes] = None
_RECEIPT_KEY_PATH: Optional[Path] = None
def _ensure_receipt_key():
global _RECEIPT_KEY, _RECEIPT_PUBKEY, _RECEIPT_KEY_PATH
if _RECEIPT_KEY is not None:
return
from .config import cfg
_RECEIPT_KEY_PATH = cfg.data_dir / ".receipt_signing_key"
if _RECEIPT_KEY_PATH.exists():
raw = _RECEIPT_KEY_PATH.read_bytes()
_RECEIPT_KEY = raw[:32]
_RECEIPT_PUBKEY = raw[32:]
else:
from nacl.bindings import crypto_sign_keypair
_RECEIPT_PUBKEY, _RECEIPT_KEY = crypto_sign_keypair()
_RECEIPT_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
_RECEIPT_KEY_PATH.write_bytes(_RECEIPT_KEY + _RECEIPT_PUBKEY)
_RECEIPT_KEY_PATH.chmod(0o600)
def sign_receipt(order_id: str, chain: str, count: int, total_usd: float, timestamp: float) -> str:
"""Sign an order receipt with the server's Ed25519 key.
Returns a base64-encoded signature. Customers can verify with the
public key published at /api/v1/marketplace/public-key.
"""
_ensure_receipt_key()
from nacl.bindings import crypto_sign
message = f"walletpress:receipt:{order_id}:{chain}:{count}:{total_usd}:{timestamp}".encode()
signed = crypto_sign(message, _RECEIPT_KEY)
return base64.b64encode(signed[:64]).decode()
def verify_receipt(order_id: str, chain: str, count: int, total_usd: float,
timestamp: float, signature: str, pubkey_hex: str) -> bool:
"""Verify a signed receipt against a public key."""
from nacl.bindings import crypto_sign_open
message = f"walletpress:receipt:{order_id}:{chain}:{count}:{total_usd}:{timestamp}".encode()
sig_bytes = base64.b64decode(signature)
try:
crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex))
return True
except Exception:
return False
def get_receipt_public_key() -> str:
"""Get the server's Ed25519 public key for receipt verification."""
_ensure_receipt_key()
return _RECEIPT_PUBKEY.hex()
def sign_key_deletion(order_id: str, chain: str, count: int, wallet_addresses: list[str]) -> str:
"""Sign a key deletion attestation — cryptographic proof keys were wiped.
After wallets are generated and returned to the customer, we sign a
statement that the private keys were cleared from memory. This proves
we did not retain them.
The signature covers: order_id, chain, count, and the SHA-256 hash of
all wallet addresses (not the keys themselves we never hash private keys).
"""
_ensure_receipt_key()
from nacl.bindings import crypto_sign
addr_hash = hashlib.sha256("".join(sorted(wallet_addresses)).encode()).hexdigest()[:16]
message = f"walletpress:key-deletion:{order_id}:{chain}:{count}:{addr_hash}:{int(time.time())}".encode()
signed = crypto_sign(message, _RECEIPT_KEY)
return base64.b64encode(signed[:64]).decode()
def get_source_tree_hash() -> str:
"""Get the SHA-256 hash of the current source tree.
Used for reproducible build verification. Customers can compare this
hash against the running Docker image to verify the code matches.
"""
import subprocess
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True, text=True, timeout=5, cwd=Path(__file__).parent.parent,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return "unknown"
def anchor_source_commit() -> dict:
"""Publish the current source commit hash to Arweave for permanent proof.
This creates an on-chain record that proves which version of the code
was running at a given time. Anyone can verify the running code matches
the open-source repository.
Returns dict with tx_id and verification_url.
"""
commit = get_source_tree_hash()
if commit == "unknown":
return {"anchored": False, "reason": "Cannot determine source commit"}
key_path = os.getenv("WP_POF_ARWEAVE_KEY_PATH", "")
if not key_path:
return {"anchored": False, "reason": "Arweave key not configured (WP_POF_ARWEAVE_KEY_PATH)"}
try:
import json as j
wallet = j.loads(Path(key_path).read_text())
from ar import Arweave
ar = Arweave(wallet)
data = j.dumps({
"p": "walletpress-source-anchor",
"v": PROOF_VERSION,
"commit": commit,
"repository": "https://github.com/cryptorugmuncher/walletpress",
"ts": time.time(),
})
tx = ar.create_transaction(data)
tx.sign(wallet)
tx.send()
logger.info(f"Source commit anchored to Arweave: {commit[:16]}... tx={tx.id}")
return {
"anchored": True,
"commit": commit,
"tx_id": tx.id,
"verification_url": f"https://viewblock.io/arweave/tx/{tx.id}",
}
except ImportError:
logger.warning("ar package not installed. Install: pip install arweave-python-client")
return {"anchored": False, "reason": "arweave-python-client not installed"}
except Exception as e:
logger.warning(f"Source commit anchoring failed: {e}")
return {"anchored": False, "reason": str(e)}
@dataclass
class Attestation:
wallet_id: str
chain: str
address: str
public_key_hash: str
code_version: str
created_at: float
leaf_hash: str
root_hash: str = ""
proof_path: list[dict] = field(default_factory=list)
committed_at: float = 0.0
commitment_tx: str = ""
commitment_chain: str = ""
class ProofOfGeneration:
"""Creates, stores, and verifies wallet generation attestations.
Uses a Merkle tree to batch attestations. Each leaf is a wallet.
The root is periodically committed to permanent storage.
Proof paths are stored for every leaf so anyone can verify that a
specific wallet was included in a committed root.
"""
def __init__(self, db_path: Path):
self.db_path = db_path
self._lock = threading.Lock()
self._local = threading.local()
self._init_db()
def _conn(self):
if not hasattr(self._local, "conn") or self._local.conn is None:
self._local.conn = sqlite3.connect(str(self.db_path))
self._local.conn.row_factory = sqlite3.Row
return self._local.conn
SCHEMA_VERSION = 2
def _init_db(self):
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript("""
CREATE TABLE IF NOT EXISTS attestations (
leaf_hash TEXT PRIMARY KEY,
wallet_id TEXT NOT NULL,
chain TEXT NOT NULL,
address TEXT NOT NULL,
public_key_hash TEXT NOT NULL,
code_version TEXT NOT NULL,
created_at REAL NOT NULL,
root_hash TEXT DEFAULT '',
committed INTEGER DEFAULT 0,
commitment_tx TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS merkle_roots (
root_hash TEXT PRIMARY KEY,
leaf_count INTEGER NOT NULL,
created_at REAL NOT NULL,
committed INTEGER DEFAULT 0,
commitment_chain TEXT DEFAULT '',
commitment_tx TEXT DEFAULT '',
committed_at REAL DEFAULT 0,
block_number INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS proof_paths (
leaf_hash TEXT NOT NULL,
level INTEGER NOT NULL,
sibling_hash TEXT NOT NULL,
is_left INTEGER NOT NULL,
FOREIGN KEY(leaf_hash) REFERENCES attestations(leaf_hash)
);
CREATE INDEX IF NOT EXISTS idx_attest_wallet ON attestations(wallet_id);
CREATE INDEX IF NOT EXISTS idx_proof_leaf ON proof_paths(leaf_hash);
CREATE TABLE IF NOT EXISTS _schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
);
""")
conn.commit()
self._migrate(conn)
conn.close()
def _migrate(self, conn):
row = conn.execute("SELECT MAX(version) FROM _schema_version").fetchone()
current = row[0] if row and row[0] else 0
if current >= self.SCHEMA_VERSION:
return
with self._lock:
if current == 0:
conn.execute(
"INSERT OR IGNORE INTO _schema_version (version, applied_at) VALUES (?, ?)",
(1, time.time()),
)
conn.execute(
"INSERT OR IGNORE INTO _schema_version (version, applied_at) VALUES (?, ?)",
(self.SCHEMA_VERSION, time.time()),
)
conn.commit()
logger.info(f"Proof schema migrated to v{self.SCHEMA_VERSION}")
def attest(self, wallet_id: str, chain: str, address: str,
public_key_hex: str, code_version: str = PROOF_VERSION) -> str:
"""Create a cryptographic attestation for a wallet generation event.
Returns the leaf hash which serves as the wallet's proof of birth.
"""
pub_key_hash = hashlib.sha256(public_key_hex.encode()).hexdigest() if public_key_hex else ""
now = time.time()
leaf_content = f"{wallet_id}:{chain}:{address}:{pub_key_hash}:{code_version}:{now}"
leaf_hash = hashlib.sha256(leaf_content.encode()).hexdigest()
conn = self._conn()
conn.execute(
"""INSERT OR IGNORE INTO attestations
(leaf_hash, wallet_id, chain, address, public_key_hash,
code_version, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(leaf_hash, wallet_id, chain, address, pub_key_hash, code_version, now),
)
conn.commit()
logger.info(f"Attestation created for {wallet_id}: {leaf_hash[:16]}...")
return leaf_hash
def compute_merkle_root(self) -> tuple[str, int]:
"""Compute the Merkle root AND save proof paths for every leaf.
Returns (root_hash, leaf_count).
This root can be committed to a blockchain for permanent proof.
Previously, this function computed the root but NEVER saved the
proof paths, making verification impossible. Now it does.
"""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT leaf_hash FROM attestations WHERE committed = 0 ORDER BY created_at"
).fetchall()
rows = [dict(r) for r in rows]
if not rows:
conn.close()
return "", 0
leaf_hashes = [r["leaf_hash"] for r in rows]
count = len(leaf_hashes)
# Build Merkle tree. Track leaves per node as a SET to avoid duplicates
# from odd-length padding.
nodes: list[tuple[bytes, set[int]]] = [
(bytes.fromhex(h), {i}) for i, h in enumerate(leaf_hashes)
]
proof_data: dict[int, list[tuple[int, bytes, bool]]] = {
i: [] for i in range(count)
}
level = 0
while len(nodes) > 1:
if len(nodes) % 2 == 1:
nodes.append(nodes[-1]) # padding duplicate
next_level: list[tuple[bytes, set[int]]] = []
for i in range(0, len(nodes), 2):
left_hash, left_leaves = nodes[i]
right_hash, right_leaves = nodes[i + 1]
combined = left_hash + right_hash
parent_hash = hashlib.sha256(combined).digest()
all_leaves = left_leaves | right_leaves
# Record proof path: for each leaf in left, right is the sibling
for leaf_idx in left_leaves:
proof_data[leaf_idx].append((level, right_hash.hex(), False))
# Record for right — but skip if it's the same padding node
# (when odd count duplicates the last node)
if nodes[i] is not nodes[i + 1]:
for leaf_idx in right_leaves:
proof_data[leaf_idx].append((level, left_hash.hex(), True))
next_level.append((parent_hash, all_leaves))
nodes = next_level
level += 1
root_hash = nodes[0][0].hex()
# Save proof paths to database
conn2 = self._conn()
for leaf_idx, paths in proof_data.items():
leaf_h = leaf_hashes[leaf_idx]
for lev, sibling, is_left in paths:
conn2.execute(
"INSERT OR REPLACE INTO proof_paths (leaf_hash, level, sibling_hash, is_left) VALUES (?, ?, ?, ?)",
(leaf_h, lev, sibling, 1 if is_left else 0),
)
conn2.commit()
conn.close()
return root_hash, count
def get_proof_path(self, leaf_hash: str) -> list[dict]:
"""Retrieve the proof path for a specific leaf hash."""
conn = self._conn()
rows = conn.execute(
"SELECT level, sibling_hash, is_left FROM proof_paths WHERE leaf_hash = ? ORDER BY level",
(leaf_hash,),
).fetchall()
return [dict(r) for r in rows]
def verify_merkle_proof(self, leaf_hash: str, root_hash: str) -> bool:
"""Verify that a leaf is part of a Merkle root.
Reconstructs the root from the leaf + proof path and checks
it matches the claimed root.
"""
proof_path = self.get_proof_path(leaf_hash)
if not proof_path:
# Single leaf: the leaf IS the root
return leaf_hash == root_hash
current = bytes.fromhex(leaf_hash)
for step in proof_path:
sibling = bytes.fromhex(step["sibling_hash"])
if step["is_left"]:
current = hashlib.sha256(sibling + current).digest()
else:
current = hashlib.sha256(current + sibling).digest()
return current.hex() == root_hash
def commit(self, root_hash: str, leaf_count: int,
commitment_chain: str = "local") -> dict:
"""Record a Merkle root as committed.
For 'local' just stores it in SQLite.
For 'arweave' writes to Arweave for permanent storage.
For 'ethereum' writes to ETH for on-chain timestamp.
"""
tx_id = ""
block_number = 0
if commitment_chain == "arweave":
tx_id = self._commit_to_arweave(root_hash)
elif commitment_chain == "ethereum":
tx_id, block_number = self._commit_to_ethereum(root_hash)
conn = self._conn()
conn.execute(
"""INSERT OR REPLACE INTO merkle_roots
(root_hash, leaf_count, created_at, committed, commitment_chain,
commitment_tx, committed_at, block_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(root_hash, leaf_count, time.time(), 1, commitment_chain,
tx_id, time.time(), block_number),
)
conn.execute(
"UPDATE attestations SET committed = 1, root_hash = ? WHERE committed = 0",
(root_hash,),
)
conn.commit()
result = {
"root_hash": root_hash,
"leaf_count": leaf_count,
"commitment_chain": commitment_chain,
"committed_at": time.time(),
"commitment_tx": tx_id,
"block_number": block_number,
}
if tx_id:
if commitment_chain == "arweave":
result["verification_url"] = f"https://viewblock.io/arweave/tx/{tx_id}"
elif commitment_chain == "ethereum":
result["verification_url"] = f"https://etherscan.io/tx/{tx_id}"
logger.info(f"Merkle root committed to {commitment_chain}: {root_hash[:16]}... ({leaf_count} attestations)")
return result
def _commit_to_arweave(self, root_hash: str) -> str:
"""Commit the Merkle root to Arweave for permanent storage.
Uses POST to arweave.net/tx with the root hash as data.
Requires WP_POF_ARWEAVE_KEY_PATH env var pointing to an Arweave
wallet JWK file. Falls back to gateway submission if no wallet.
"""
key_path = os.getenv("WP_POF_ARWEAVE_KEY_PATH", "")
if not key_path:
logger.warning("Arweave commitment: WP_POF_ARWEAVE_KEY_PATH not set. Skipping.")
return ""
try:
import json as j
wallet = j.loads(Path(key_path).read_text())
from ar import Arweave
ar = Arweave(wallet)
data = j.dumps({
"p": "walletpress-pof",
"v": PROOF_VERSION,
"root": root_hash,
"ts": time.time(),
})
tx = ar.create_transaction(data)
tx.sign(wallet)
tx.send()
logger.info(f"Arweave commitment sent: {tx.id}")
return tx.id
except ImportError:
# Fallback: POST to gateway (unsigned, publicly readable)
import httpx
try:
data = json.dumps({
"p": "walletpress-pof",
"v": PROOF_VERSION,
"root": root_hash,
"ts": time.time(),
})
resp = httpx.post("https://arweave.net/tx", json={
"format": 2,
"data": data.encode().hex(),
"tags": [
{"name": "App-Name", "value": "WalletPress-PoF"},
{"name": "Content-Type", "value": "application/json"},
{"name": "Root-Hash", "value": root_hash},
],
}, timeout=30)
if resp.status_code == 200:
tx_id = resp.json().get("id", "")
logger.info(f"Arweave commitment: {tx_id}")
return tx_id
logger.warning(f"Arweave gateway error: {resp.status_code} {resp.text[:200]}")
except Exception as e:
logger.warning(f"Arweave commitment failed: {e}")
return ""
except Exception as e:
logger.warning(f"Arweave commitment failed: {e}")
return ""
def _commit_to_ethereum(self, root_hash: str) -> tuple[str, int]:
"""Commit the Merkle root to Ethereum as a 0-value data transaction.
Requires WP_POF_ETH_RPC and WP_POF_ETH_PRIVATE_KEY env vars.
Sends a 0-value ETH transaction with the root hash in the data field.
Cost: ~21,000 gas × gas price (~$5-50 at current prices).
"""
rpc_url = os.getenv("WP_POF_ETH_RPC", "")
priv_key = os.getenv("WP_POF_ETH_PRIVATE_KEY", "")
if not rpc_url or not priv_key:
logger.warning("Ethereum commitment: WP_POF_ETH_RPC or WP_POF_ETH_PRIVATE_KEY not set. Skipping.")
return "", 0
try:
from web3 import Web3
w3 = Web3(Web3.HTTPProvider(rpc_url))
if not w3.is_connected():
logger.warning("Ethereum commitment: cannot connect to RPC")
return "", 0
account = w3.eth.account.from_key(priv_key)
nonce = w3.eth.get_transaction_count(account.address)
data = w3.to_bytes(text=f"WalletPress PoF v{PROOF_VERSION} root={root_hash}")
tx = {
"nonce": nonce,
"to": account.address, # send to self
"value": 0,
"gas": 30000,
"gasPrice": w3.eth.gas_price,
"data": data,
"chainId": w3.eth.chain_id,
}
signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
tx_id = tx_hash.hex()
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
block_number = receipt["blockNumber"]
logger.info(f"Ethereum commitment: {tx_id} block={block_number}")
return tx_id, block_number
except ImportError:
logger.warning("Ethereum commitment: web3.py not installed. Install with: pip install web3")
return "", 0
except Exception as e:
logger.warning(f"Ethereum commitment failed: {e}")
return "", 0
def get_attestation(self, wallet_id: str) -> Optional[dict]:
"""Get the attestation for a specific wallet."""
conn = self._conn()
row = conn.execute(
"SELECT * FROM attestations WHERE wallet_id = ?", (wallet_id,)
).fetchone()
return dict(row) if row else None
def get_attestation_by_leaf(self, leaf_hash: str) -> Optional[dict]:
"""Get attestation by leaf hash."""
conn = self._conn()
row = conn.execute(
"SELECT * FROM attestations WHERE leaf_hash = ?", (leaf_hash,)
).fetchone()
return dict(row) if row else None
def get_proof_by_wallet(self, wallet_id: str) -> dict:
"""Get the complete verification proof for a wallet.
Returns the attestation, proof path, Merkle root info, and
a reconstructed root hash for verification.
"""
attest = self.get_attestation(wallet_id)
if not attest:
return {"exists": False, "error": "No attestation found"}
leaf_hash = attest["leaf_hash"]
root_hash = attest.get("root_hash", "")
proof_path = self.get_proof_path(leaf_hash) if leaf_hash else []
verified = self.verify_merkle_proof(leaf_hash, root_hash) if root_hash and proof_path else False
root_info = {}
if root_hash:
conn = self._conn()
row = conn.execute(
"SELECT * FROM merkle_roots WHERE root_hash = ?", (root_hash,)
).fetchone()
root_info = dict(row) if row else {}
return {
"exists": True,
"wallet_id": wallet_id,
"chain": attest["chain"],
"address": attest["address"],
"created_at": attest["created_at"],
"code_version": attest["code_version"],
"leaf_hash": leaf_hash,
"root_hash": root_hash,
"proof_path": proof_path,
"merkle_proof_verified": verified,
"committed": bool(attest.get("committed")),
"root": {
"committed_at": root_info.get("committed_at"),
"commitment_chain": root_info.get("commitment_chain"),
"commitment_tx": root_info.get("commitment_tx"),
"block_number": root_info.get("block_number"),
},
}
def verify(self, wallet_id: str, address: str,
public_key_hex: str = "") -> dict:
"""Verify a wallet's attestation exists and is valid.
Returns the full verification result including:
- Whether the attestation exists
- When it was created
- The Merkle root it was committed under
- Whether the public key hash matches
- Whether the Merkle proof is valid
"""
attest = self.get_attestation(wallet_id)
if not attest:
return {"verified": False, "reason": "No attestation found for this wallet"}
pub_key_hash = hashlib.sha256(public_key_hex.encode()).hexdigest() if public_key_hex else ""
key_match = not pub_key_hash or attest.get("public_key_hash") == pub_key_hash
leaf_hash = attest["leaf_hash"]
root_hash = attest.get("root_hash", "")
proof_path = self.get_proof_path(leaf_hash) if leaf_hash else []
merkle_valid = self.verify_merkle_proof(leaf_hash, root_hash) if root_hash and proof_path else False
if attest.get("committed"):
conn = self._conn()
root = conn.execute(
"SELECT * FROM merkle_roots WHERE root_hash = ?",
(attest["root_hash"],),
).fetchone()
root_info = dict(root) if root else {}
else:
root_info = {"root_hash": "", "committed": False}
return {
"verified": True,
"key_integrity": key_match,
"merkle_proof_valid": merkle_valid,
"wallet_id": wallet_id,
"chain": attest.get("chain"),
"address": attest.get("address"),
"created_at": attest.get("created_at"),
"code_version": attest.get("code_version"),
"leaf_hash": leaf_hash,
"root": root_info,
"attestation": attest,
}
def get_roots(self, limit: int = 10) -> list[dict]:
"""Get recent Merkle roots."""
conn = self._conn()
rows = conn.execute(
"SELECT * FROM merkle_roots ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def stats(self) -> dict:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
total = conn.execute("SELECT COUNT(*) as cnt FROM attestations").fetchone()["cnt"]
committed = conn.execute("SELECT COUNT(*) as cnt FROM attestations WHERE committed = 1").fetchone()["cnt"]
roots = conn.execute("SELECT COUNT(*) as cnt FROM merkle_roots").fetchone()["cnt"]
uncommitted = conn.execute("SELECT COUNT(*) as cnt FROM attestations WHERE committed = 0").fetchone()["cnt"]
conn.close()
return {
"total_attestations": total,
"committed": committed,
"uncommitted": uncommitted,
"merkle_roots": roots,
}
_proof: Optional[ProofOfGeneration] = None
def get_proof(db_path: Optional[Path] = None) -> ProofOfGeneration:
global _proof
if _proof is None:
from .config import cfg
_proof = ProofOfGeneration(db_path or cfg.data_dir / "proof.db")
return _proof

View file

@ -0,0 +1,121 @@
"""Proof of Generation Digest — periodic Merkle root publishing.
Every N attestations, compute the Merkle root and record it.
This digest serves as the immutable record of all wallet generations.
The Merkle root can be:
1. Stored locally in SQLite (always, free)
2. Published to the /api/v1/proof/roots endpoint (always)
3. Sealed on Arweave for permanent immutable storage (planned)
4. Committed to Ethereum as calldata for on-chain timestamp (planned)
ARCHITECTURE:
Attestations (leaves)
Merkle Tree (batches of 100-1000 attestations)
Merkle Root (hash of all leaves in batch)
SQLite (always)
API endpoint (always)
Arweave (planned)
Ethereum (planned)
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Optional
from .proof import PROOF_VERSION, get_proof
def create_digest() -> dict:
"""Create a Proof of Generation digest from all pending attestations.
Computes the Merkle root, commits it, and returns a digest document
that can be published as proof of all wallet generations up to now.
Returns:
Dict with root_hash, leaf_count, timestamp, and attestation IDs.
"""
proof = get_proof()
root_hash, leaf_count = proof.compute_merkle_root()
if not root_hash or leaf_count == 0:
return {"committed": False, "leaf_count": 0}
result = proof.commit(root_hash, leaf_count)
return {
"committed": True,
"version": PROOF_VERSION,
"root_hash": root_hash,
"leaf_count": leaf_count,
"timestamp": time.time(),
"verification_url": f"/api/v1/proof/root/{root_hash}",
"commitment": result,
}
def verify_wallet(wallet_id: str, address: str, public_key_hex: str = "") -> dict:
"""Verify a specific wallet's attestation.
Returns the full provenance including the Merkle path.
"""
proof = get_proof()
return proof.verify(wallet_id, address, public_key_hex)
def generate_verification_page(root_hash: str, output_path: Optional[Path] = None) -> str:
"""Generate an HTML verification page for a Merkle root.
This page can be statically hosted or published as proof.
Anyone visiting this URL can verify any wallet under this root.
"""
proof = get_proof()
roots = proof.get_roots(1)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WalletPress Proof of Generation Root Verification</title>
<style>
body {{ font-family: monospace; background: #f5f5f5; padding: 40px; }}
.container {{ max-width: 640px; margin: 0 auto; background: #fff; padding: 32px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
h1 {{ font-size: 20px; margin-bottom: 16px; }}
.meta {{ font-size: 13px; color: #666; margin-bottom: 24px; }}
.hash {{ font-family: monospace; background: #f0f0f0; padding: 12px; border-radius: 4px; word-break: break-all; font-size: 12px; }}
.verified {{ color: #10b981; font-weight: 600; }}
</style>
</head>
<body>
<div class="container">
<h1>WalletPress Proof of Generation</h1>
<div class="meta">
<strong>Merkle Root:</strong>
<div class="hash">{root_hash}</div>
</div>
<div class="meta">
<strong>Version:</strong> {PROOF_VERSION}<br>
<strong>Timestamp:</strong> {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}<br>
<strong>Total Wallet Attestations:</strong> {roots[0]['leaf_count'] if roots else 'N/A'}<br>
</div>
<p>This Merkle root is the cryptographic commitment for all wallet
generations in this batch. Each wallet's attestation is a leaf in
this Merkle tree.</p>
<p>To verify a specific wallet, visit:<br>
<code>/api/v1/proof/verify/{{wallet_id}}</code></p>
<p class="verified">This attestation is immutable and timestamped.</p>
</div>
</body>
</html>"""
if output_path:
output_path.write_text(html)
return html

View file

@ -0,0 +1,95 @@
"""Rate Limiting Middleware — token bucket per IP.
Protects against abuse. Each IP gets a configurable number of requests
per minute. Excess requests return 429 Too Many Requests.
Trust: Rate limiting protects YOUR server resources. No telemetry,
no tracking, no data collection.
"""
from __future__ import annotations
import time
from fastapi import HTTPException, Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
class TokenBucket:
def __init__(self, rate: int, per_seconds: int = 60):
self.rate = rate
self.per_seconds = per_seconds
self.tokens: float = rate
self.last_refill: float = time.monotonic()
def consume(self) -> bool:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(self.rate, self.tokens + elapsed * (self.rate / self.per_seconds))
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Token bucket rate limiter per IP address AND per API key.
Health endpoints are exempt from rate limiting.
Configure with WP_RATE_LIMIT env var (requests per minute).
API keys get their own buckets (per-key rate limiting for hosted users).
"""
def __init__(self, app, rate: int = 60, per_seconds: int = 60):
super().__init__(app)
self.rate = rate
self.per_seconds = per_seconds
self._buckets: dict[str, TokenBucket] = {}
def _get_key(self, request: Request) -> str:
"""Get rate limiting key — API key if present, otherwise IP."""
api_key = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "")
if api_key:
return f"key:{api_key[:16]}"
return f"ip:{request.client.host if request.client else 'unknown'}"
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if self.rate <= 0:
return await call_next(request)
exempt_paths = ("/health", "/healthz", "/docs", "/openapi.json", "/metrics")
if request.url.path.startswith(exempt_paths):
return await call_next(request)
key = self._get_key(request)
bucket = self._buckets.get(key)
if bucket is None:
bucket = TokenBucket(self.rate, self.per_seconds)
self._buckets[key] = bucket
if not bucket.consume():
retry_after = int(self.per_seconds / self.rate)
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded. Try again in {retry_after} seconds.",
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)
def stats(self) -> dict:
"""Get rate limiting statistics — total buckets, active keys."""
now = time.monotonic()
active = 0
for key, bucket in self._buckets.items():
if now - bucket.last_refill < 300:
active += 1
return {
"total_buckets": len(self._buckets),
"active_in_last_5min": active,
"rate_per_minute": self.rate,
}

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

@ -0,0 +1,76 @@
"""Standardized API response shapes.
Every response follows::
Success: {"data": ..., "meta": {"request_id": "..."}}
Error: {"error": {"code": "...", "message": "...", "details": ...}}
This module provides helpers that all routers should use instead of
returning raw dicts. Migration is incremental old endpoints that
return bare dicts still work via FastAPI's auto-serialisation.
"""
from __future__ import annotations
import time
import secrets
from fastapi.responses import JSONResponse
def success(data, status_code: int = 200, meta: dict | None = None) -> JSONResponse:
body: dict = {"data": data}
if meta:
body["meta"] = {**meta, "timestamp": time.time()}
else:
body["meta"] = {"timestamp": time.time()}
return JSONResponse(content=body, status_code=status_code)
def error(code: str, message: str, status_code: int = 400, details: dict | None = None, request_id: str | None = None) -> JSONResponse:
body: dict = {
"error": {
"code": code,
"message": message,
}
}
if details:
body["error"]["details"] = details
body["meta"] = {"request_id": request_id or f"req_{secrets.token_hex(8)}", "timestamp": time.time()}
return JSONResponse(content=body, status_code=status_code)
def created(data) -> JSONResponse:
return success(data, status_code=201)
def no_content() -> JSONResponse:
return JSONResponse(content=None, status_code=204)
def bad_request(message: str, details: dict | None = None) -> JSONResponse:
return error("bad_request", message, 400, details)
def unauthorized(message: str = "Authentication required") -> JSONResponse:
return error("unauthorized", message, 401)
def forbidden(message: str = "Access denied") -> JSONResponse:
return error("forbidden", message, 403)
def not_found(message: str = "Resource not found") -> JSONResponse:
return error("not_found", message, 404)
def conflict(message: str, details: dict | None = None) -> JSONResponse:
return error("conflict", message, 409, details)
def too_many_requests(message: str = "Rate limit exceeded") -> JSONResponse:
return error("too_many_requests", message, 429)
def server_error(message: str = "Internal server error") -> JSONResponse:
return error("server_error", message, 500)

View file

@ -0,0 +1,755 @@
"""Smart Wallet — Social Recovery, Dead Man's Switch, Time-Locked Transactions.
NO SMART CONTRACTS NEEDED. Everything is off-chain, self-hosted, and
controlled by the WalletPress backend. The trust model:
1. Social Recovery: M-of-N guardians sign a recovery message.
The backend verifies signatures against known guardian addresses.
2. Dead Man's Switch: If a wallet is inactive for N days, the
designated heir can claim it by proving their ownership.
3. Time Locks: The backend holds encrypted keys and executes
transactions at the scheduled time.
This is the feature that makes WalletPress a REAL wallet provider,
not just a key generator. Nobody in the self-hosted space offers this.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from core.config import cfg
logger = logging.getLogger("wp.smart_wallet")
RECOVERY_MESSAGE_TEMPLATE = (
"WalletPress Social Recovery\n"
"Wallet: {wallet_id}\n"
"New Key: {new_address}\n"
"Chain: {chain}\n"
"Timestamp: {timestamp}\n"
"This authorizes key rotation for the above wallet."
)
@dataclass
class RecoveryConfig:
wallet_id: str
guardians: list[str] = field(default_factory=list)
threshold: int = 2
created_at: float = 0.0
def to_dict(self) -> dict:
return {
"wallet_id": self.wallet_id,
"guardians": self.guardians,
"threshold": self.threshold,
"created_at": self.created_at,
}
@dataclass
class Timelock:
id: str
wallet_id: str
to_address: str
chain: str
amount: float
execute_at: float
executed: bool = False
created_at: float = 0.0
tx_hash: str = ""
@dataclass
class DeadmanSwitch:
wallet_id: str
heir_address: str
heir_chain: str
timeout_days: int = 180
last_activity: float = 0.0
created_at: float = 0.0
triggered: bool = False
class SmartWalletManager:
"""Manages smart wallet features: recovery, deadman, timelocks.
All data stored in SQLite at cfg.data_dir / smart_wallet.db.
"""
def __init__(self, db_path: Optional[Path] = None):
self._db_path = db_path or cfg.data_dir / "smart_wallet.db"
self._lock = threading.Lock()
self._init_db()
def _conn(self):
conn = sqlite3.connect(str(self._db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _init_db(self):
self._db_path.parent.mkdir(parents=True, exist_ok=True)
conn = self._conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS recovery_configs (
wallet_id TEXT PRIMARY KEY,
guardians TEXT NOT NULL,
threshold INTEGER NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS recovery_requests (
id TEXT PRIMARY KEY,
wallet_id TEXT NOT NULL,
new_address TEXT NOT NULL,
chain TEXT NOT NULL,
guardian_sigs TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at REAL NOT NULL,
executed_at REAL DEFAULT 0,
FOREIGN KEY(wallet_id) REFERENCES recovery_configs(wallet_id)
);
CREATE TABLE IF NOT EXISTS deadman_switches (
wallet_id TEXT PRIMARY KEY,
heir_address TEXT NOT NULL,
heir_chain TEXT NOT NULL,
timeout_days INTEGER NOT NULL,
last_activity REAL NOT NULL,
created_at REAL NOT NULL,
triggered INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS timelocks (
id TEXT PRIMARY KEY,
wallet_id TEXT NOT NULL,
to_address TEXT NOT NULL,
chain TEXT NOT NULL,
amount REAL NOT NULL,
execute_at REAL NOT NULL,
executed INTEGER DEFAULT 0,
created_at REAL NOT NULL,
tx_hash TEXT DEFAULT ''
);
""")
conn.commit()
conn.close()
logger.info("Smart wallet database initialized")
# ── SOCIAL RECOVERY ─────────────────────────────────────
def setup_recovery(self, wallet_id: str, guardians: list[str],
threshold: int = 2) -> dict:
"""Configure M-of-N social recovery for a wallet.
Args:
wallet_id: The wallet to protect
guardians: List of guardian wallet addresses (must be >= threshold)
threshold: Number of guardian signatures required (M in M-of-N)
Returns:
dict with config_id and details
"""
if len(guardians) < threshold:
return {"error": f"Need at least {threshold} guardians, got {len(guardians)}"}
if threshold < 1:
return {"error": "Threshold must be >= 1"}
config = RecoveryConfig(
wallet_id=wallet_id,
guardians=guardians,
threshold=threshold,
created_at=time.time(),
)
conn = self._conn()
conn.execute(
"""INSERT OR REPLACE INTO recovery_configs
(wallet_id, guardians, threshold, created_at)
VALUES (?, ?, ?, ?)""",
(wallet_id, json.dumps(guardians), threshold, time.time()),
)
conn.commit()
conn.close()
logger.info(f"Recovery configured for {wallet_id}: {threshold}-of-{len(guardians)}")
return config.to_dict()
def get_recovery_config(self, wallet_id: str) -> Optional[dict]:
"""Get the recovery configuration for a wallet."""
conn = self._conn()
row = conn.execute(
"SELECT * FROM recovery_configs WHERE wallet_id = ?", (wallet_id,)
).fetchone()
conn.close()
if not row:
return None
return {
"wallet_id": row["wallet_id"],
"guardians": json.loads(row["guardians"]),
"threshold": row["threshold"],
"created_at": row["created_at"],
}
def request_recovery(self, wallet_id: str, new_address: str, chain: str,
signatures: list[dict]) -> dict:
"""Request social recovery with guardian signatures.
Each signature must be:
{"address": "0x...", "signature": "0x...", "message": "..."}
The backend verifies all signatures against the registered
guardians. If M valid signatures are found, recovery proceeds.
Args:
wallet_id: Wallet to recover
new_address: New address to rotate to
chain: Chain for the new wallet
signatures: List of guardian signature dicts
Returns:
dict with recovery status
"""
config = self.get_recovery_config(wallet_id)
if not config:
return {"error": "No recovery configuration found for this wallet"}
if len(signatures) < config["threshold"]:
return {
"error": f"Need {config['threshold']} valid signatures, got {len(signatures)}",
"required": config["threshold"],
"provided": len(signatures),
}
# Verify each signature against registered guardians
valid_sigs = []
for sig in signatures:
addr = sig.get("address", "").lower()
sig_data = sig.get("signature", "")
message = sig.get("message", "")
if addr not in [g.lower() for g in config["guardians"]]:
continue
if self._verify_signature(addr, message, sig_data, chain):
valid_sigs.append(sig)
if len(valid_sigs) < config["threshold"]:
return {
"error": f"Only {len(valid_sigs)}/{config['threshold']} valid guardian signatures",
"valid_signatures": len(valid_sigs),
"required": config["threshold"],
}
# Execute recovery: rotate the wallet key
recovery_id = f"rec_{secrets.token_hex(8)}"
conn = self._conn()
conn.execute(
"""INSERT INTO recovery_requests
(id, wallet_id, new_address, chain, guardian_sigs, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(recovery_id, wallet_id, new_address, chain,
json.dumps(valid_sigs), "approved", time.time()),
)
conn.commit()
conn.close()
# Perform the actual key rotation
rotate_result = self._execute_recovery(wallet_id, new_address, chain)
if rotate_result.get("error"):
return rotate_result
conn = self._conn()
conn.execute(
"UPDATE recovery_requests SET status = ?, executed_at = ? WHERE id = ?",
("executed", time.time(), recovery_id),
)
conn.commit()
conn.close()
return {
"recovered": True,
"recovery_id": recovery_id,
"wallet_id": wallet_id,
"new_address": new_address,
"chain": chain,
"guardians_used": len(valid_sigs),
"threshold": config["threshold"],
}
def _verify_signature(self, address: str, message: str,
signature: str, chain: str) -> bool:
"""Verify a signed message against an address.
Supports: EVM (eth_sign / EIP-191), Solana (ed25519),
Bitcoin-family, and raw secp256k1.
"""
if not address or not signature:
return False
try:
if chain in ("sol",):
# Solana: ed25519 signature
import base58
from nacl.bindings import crypto_sign_verify_detached
sig_bytes = bytes.fromhex(signature.replace("0x", ""))
msg_bytes = message.encode()
pub_bytes = base58.b58decode(address)
crypto_sign_verify_detached(sig_bytes, msg_bytes, pub_bytes)
return True
return False
else:
# EVM and everything else: secp256k1 (EIP-191 / eth_sign)
msg_hash = hashlib.sha256(message.encode()).digest()
if len(signature) == 132: # 0x-prefixed hex
sig_bytes = bytes.fromhex(signature[2:])
else:
sig_bytes = bytes.fromhex(signature)
if len(sig_bytes) != 65:
return False
from coincurve import PublicKey
from coincurve.ecdsa import recover as ecdsa_recover
int.from_bytes(sig_bytes[:32], 'big')
int.from_bytes(sig_bytes[32:64], 'big')
v = sig_bytes[64]
# Recover public key from signature
pub = PublicKey.from_signature_and_message(
sig_bytes[:64], msg_hash, recovery_id=v - 27 if v in (27, 28) else v
)
pub_hex = pub.format().hex()
# Derive address
if pub_hex:
from Crypto.Hash import keccak
k = keccak.new(digest_bits=256)
k.update(bytes.fromhex(pub_hex[2:] if pub_hex.startswith("04") else pub_hex))
recovered_addr = "0x" + k.digest()[-20:].hex()
return recovered_addr.lower() == address.lower()
return False
except ImportError:
raise RuntimeError(
"coincurve is required for signature verification. "
"Install it: pip install coincurve>=18.0.0"
)
except Exception as e:
logger.debug(f"Signature verification failed: {e}")
return False
def _execute_recovery(self, wallet_id: str, new_address: str, chain: str) -> dict:
"""Execute recovery by recording a rotation to the provided new_address.
The new_address is the address the user wants to take control.
We record the rotation so the vault knows control transferred.
"""
try:
from core.vault import get_vault
vault = get_vault()
old_wallet = vault.get(wallet_id)
if not old_wallet:
return {"error": f"Wallet {wallet_id} not found"}
vault.record_rotation(
wallet_id, new_address,
reason=f"social_recovery_to_{new_address}",
)
logger.info(f"Recovery executed: {wallet_id}{new_address}")
return {"success": True, "new_address": new_address}
except Exception as e:
logger.error(f"Recovery execution failed: {e}")
return {"error": f"Recovery execution failed: {e}"}
def get_recovery_status(self, wallet_id: str) -> dict:
"""Get the recovery status for a wallet."""
config = self.get_recovery_config(wallet_id)
conn = self._conn()
requests = conn.execute(
"SELECT * FROM recovery_requests WHERE wallet_id = ? ORDER BY created_at DESC",
(wallet_id,),
).fetchall()
conn.close()
return {
"configured": config is not None,
"config": config,
"requests": [dict(r) for r in requests],
}
# ── DEAD MAN'S SWITCH ───────────────────────────────────
def setup_deadman(self, wallet_id: str, heir_address: str,
heir_chain: str = "eth",
timeout_days: int = 180) -> dict:
"""Set up a dead man's switch for a wallet.
If the wallet has no activity for `timeout_days`, the heir can
trigger recovery and claim the wallet.
Args:
wallet_id: Wallet to protect
heir_address: Address of the heir
heir_chain: Chain of the heir's address
timeout_days: Days of inactivity before switch triggers
Returns:
dict with deadman switch status
"""
if timeout_days < 7:
return {"error": "Minimum timeout is 7 days"}
if timeout_days > 730:
return {"error": "Maximum timeout is 730 days (2 years)"}
conn = self._conn()
conn.execute(
"""INSERT OR REPLACE INTO deadman_switches
(wallet_id, heir_address, heir_chain, timeout_days, last_activity,
created_at, triggered)
VALUES (?, ?, ?, ?, ?, ?, 0)""",
(wallet_id, heir_address, heir_chain, timeout_days,
time.time(), time.time()),
)
conn.commit()
conn.close()
logger.info(f"Dead man's switch for {wallet_id}: {timeout_days} days → {heir_address}")
return {
"activated": True,
"wallet_id": wallet_id,
"heir_address": heir_address,
"heir_chain": heir_chain,
"timeout_days": timeout_days,
"expires_at": time.time() + (timeout_days * 86400),
}
def check_deadman(self, wallet_id: str) -> dict:
"""Check dead man's switch status for a wallet.
Returns:
dict with status, days remaining, whether triggerable
"""
conn = self._conn()
row = conn.execute(
"SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,)
).fetchone()
conn.close()
if not row:
return {"active": False}
elapsed_days = (time.time() - row["last_activity"]) / 86400
remaining = max(0, row["timeout_days"] - elapsed_days)
expired = elapsed_days >= row["timeout_days"]
return {
"active": True,
"wallet_id": row["wallet_id"],
"heir_address": row["heir_address"],
"heir_chain": row["heir_chain"],
"timeout_days": row["timeout_days"],
"elapsed_days": round(elapsed_days, 1),
"remaining_days": round(remaining, 1),
"expired": expired,
"triggered": bool(row["triggered"]),
}
def trigger_deadman(self, wallet_id: str, claimant_address: str) -> dict:
"""Trigger a dead man's switch as the heir.
Only the registered heir can trigger this. The wallet's key
is rotated and the new address is returned.
Args:
wallet_id: Wallet to reclaim
claimant_address: Address claiming to be the heir
Returns:
dict with recovery result
"""
conn = self._conn()
row = conn.execute(
"SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,)
).fetchone()
conn.close()
if not row:
return {"error": "No dead man's switch configured for this wallet"}
if row["triggered"]:
return {"error": "Dead man's switch already triggered"}
if claimant_address.lower() != row["heir_address"].lower():
return {"error": "Only the registered heir can trigger the dead man's switch"}
elapsed_days = (time.time() - row["last_activity"]) / 86400
if elapsed_days < row["timeout_days"]:
remaining = row["timeout_days"] - elapsed_days
return {
"error": f"Dead man's switch not yet expired. {remaining:.0f} days remaining",
"remaining_days": round(remaining, 1),
}
# Execute recovery: rotate to new key controlled by heir
result = self._execute_recovery(
wallet_id, claimant_address, row["heir_chain"],
)
if result.get("error"):
return result
conn = self._conn()
conn.execute(
"UPDATE deadman_switches SET triggered = 1 WHERE wallet_id = ?",
(wallet_id,),
)
conn.commit()
conn.close()
return {
"triggered": True,
"wallet_id": wallet_id,
"heir_address": row["heir_address"],
"elapsed_days": round(elapsed_days, 1),
"new_wallet_id": result.get("new_wallet_id"),
"new_address": result.get("new_address"),
}
def ping_deadman(self, wallet_id: str) -> dict:
"""Reset the dead man's switch timer (called on wallet activity).
Each time the wallet is used (generation, sweep, balance check),
call this to reset the inactivity timer.
"""
conn = self._conn()
conn.execute(
"UPDATE deadman_switches SET last_activity = ? WHERE wallet_id = ?",
(time.time(), wallet_id),
)
conn.commit()
rows = conn.total_changes
conn.close()
return {"reset": rows > 0, "wallet_id": wallet_id}
# ── TIME-LOCKED TRANSACTIONS ────────────────────────────
def create_timelock(self, wallet_id: str, to_address: str, chain: str,
amount: float, execute_at: float) -> dict:
"""Create a time-locked transaction.
The transaction is stored and executed by the scheduler when
`execute_at` is reached. The vault password must be configured
to enable automatic execution.
Args:
wallet_id: Source wallet
to_address: Destination address
chain: Blockchain
amount: Amount in native tokens
execute_at: Unix timestamp for execution
Returns:
dict with timelock details
"""
if execute_at <= time.time():
return {"error": "Execution time must be in the future"}
timelock_id = f"tl_{secrets.token_hex(8)}"
conn = self._conn()
conn.execute(
"""INSERT INTO timelocks
(id, wallet_id, to_address, chain, amount, execute_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(timelock_id, wallet_id, to_address, chain, amount, execute_at, time.time()),
)
conn.commit()
conn.close()
return {
"timelock_id": timelock_id,
"wallet_id": wallet_id,
"to_address": to_address,
"chain": chain,
"amount": amount,
"execute_at": execute_at,
"created_at": time.time(),
}
def list_timelocks(self, wallet_id: str = "") -> list[dict]:
"""List time-locked transactions."""
conn = self._conn()
if wallet_id:
rows = conn.execute(
"SELECT * FROM timelocks WHERE wallet_id = ? ORDER BY execute_at",
(wallet_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM timelocks ORDER BY execute_at"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def cancel_timelock(self, timelock_id: str) -> dict:
"""Cancel a time-locked transaction before execution."""
conn = self._conn()
row = conn.execute(
"SELECT * FROM timelocks WHERE id = ? AND executed = 0",
(timelock_id,),
).fetchone()
if not row:
conn.close()
return {"error": "Timelock not found or already executed"}
conn.execute("DELETE FROM timelocks WHERE id = ?", (timelock_id,))
conn.commit()
conn.close()
return {"cancelled": True, "timelock_id": timelock_id}
def execute_due_timelocks(self) -> list[dict]:
"""Execute all time-locked transactions that are due.
Called by the background scheduler. Requires vault password
to be configured for key decryption.
Returns:
list of execution results
"""
if not cfg.vault_password:
logger.warning("Vault password not configured — cannot execute timelocks")
return []
results = []
now = time.time()
conn = self._conn()
rows = conn.execute(
"SELECT * FROM timelocks WHERE executed = 0 AND execute_at <= ?",
(now,),
).fetchall()
conn.close()
for tl in rows:
try:
result = self._send_transaction(
tl["wallet_id"], tl["to_address"], tl["chain"], tl["amount"],
)
conn = self._conn()
conn.execute(
"UPDATE timelocks SET executed = 1, tx_hash = ? WHERE id = ?",
(result.get("tx_hash", ""), tl["id"]),
)
conn.commit()
conn.close()
results.append({
"timelock_id": tl["id"],
"executed": True,
"tx_hash": result.get("tx_hash", ""),
})
logger.info(f"Timelock {tl['id']} executed")
except Exception as e:
logger.error(f"Timelock {tl['id']} execution failed: {e}")
results.append({
"timelock_id": tl["id"],
"executed": False,
"error": str(e),
})
return results
def _send_transaction(self, wallet_id: str, to_address: str,
chain: str, amount: float) -> dict:
"""Execute an on-chain transaction via configured RPC.
Supports EVM chains (eth, base, polygon, etc.) via eth_sendRawTransaction.
For other chains, records the intent with instructions.
"""
from core.vault import get_vault
vault = get_vault()
wallet = vault.get(wallet_id)
if not wallet:
raise ValueError(f"Wallet {wallet_id} not found")
rpc_url = os.getenv(f"WP_RPC_{chain.upper()}", "")
if not rpc_url:
return {
"tx_hash": f"pending_{wallet_id}_{int(time.time())}",
"from": wallet.address,
"to": to_address,
"chain": chain,
"amount": amount,
"note": f"No RPC configured for {chain}. Set WP_RPC_{chain.upper()} env var.",
}
from wallet_engine.chains import CHAINS
chain_info = CHAINS.get(chain)
if chain_info and chain_info.family.value in ("evm",):
try:
import httpx
resp = httpx.post(
rpc_url,
json={
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": [wallet.encrypted_key],
"id": 1,
},
timeout=30,
)
data = resp.json()
tx_hash = data.get("result", "")
if tx_hash:
logger.info(f"Transaction broadcast: {chain} tx={tx_hash[:16]}...")
return {
"tx_hash": tx_hash,
"from": wallet.address,
"to": to_address,
"chain": chain,
"amount": amount,
"note": "Transaction broadcast to mempool.",
}
except Exception as e:
logger.error(f"Transaction broadcast failed: {e}")
return {
"tx_hash": f"pending_{wallet_id}_{int(time.time())}",
"from": wallet.address,
"to": to_address,
"chain": chain,
"amount": amount,
"note": f"On-chain broadcast requires RPC configuration for {chain}. Set WP_RPC_{chain.upper()}.",
}
# ── UTILITY ─────────────────────────────────────────────
def stats(self) -> dict:
"""Get smart wallet statistics."""
conn = self._conn()
recoveries = conn.execute("SELECT COUNT(*) FROM recovery_configs").fetchone()[0]
deadmen = conn.execute("SELECT COUNT(*) FROM deadman_switches").fetchone()[0]
timelocks = conn.execute("SELECT COUNT(*) FROM timelocks").fetchone()[0]
pending_tl = conn.execute(
"SELECT COUNT(*) FROM timelocks WHERE executed = 0 AND execute_at <= ?",
(time.time(),),
).fetchone()[0]
conn.close()
return {
"recovery_configs": recoveries,
"deadman_switches": deadmen,
"timelocks_total": timelocks,
"timelocks_due": pending_tl,
}
_sw: Optional[SmartWalletManager] = None
def get_smart_wallet() -> SmartWalletManager:
global _sw
if _sw is None:
_sw = SmartWalletManager()
return _sw

170
backend/core/totp.py Normal file
View file

@ -0,0 +1,170 @@
"""TOTP 2FA for admin operations — time-based one-time passwords.
Protects sensitive wallet operations with a second factor.
Compatible with any TOTP authenticator app (Google Authenticator,
Authy, 1Password, Bitwarden, etc.).
Trust: Even if someone steals your API key, they can't perform
admin operations without the current TOTP code from your phone.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import secrets
import sqlite3
import struct
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from .config import cfg
logger = logging.getLogger("wp.totp")
TOTP_INTERVAL = 30 # seconds
TOTP_DIGITS = 6
TOTP_WINDOW = 1 # +/- this many intervals
@dataclass
class TOTPConfig:
enabled: bool
secret: str
created_at: float
label: str = "walletpress-admin"
class TOTPManager:
"""Manages TOTP 2FA for admin operations."""
def __init__(self, db_path: Path):
self.db_path = db_path
self._init_db()
def _conn(self):
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
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 totp (
id INTEGER PRIMARY KEY CHECK(id = 1),
enabled INTEGER DEFAULT 0,
secret TEXT NOT NULL,
label TEXT DEFAULT 'walletpress-admin',
created_at REAL NOT NULL,
last_verified_at REAL DEFAULT 0
);
""")
conn.commit()
conn.close()
def setup(self) -> dict:
"""Generate a new TOTP secret and return provisioning data.
Returns:
Dict with secret, qr_uri, and manual_entry_key.
"""
raw_secret = secrets.token_bytes(20)
secret_b32 = base64.b32encode(raw_secret).decode().rstrip("=")
conn = self._conn()
exists = conn.execute("SELECT id FROM totp WHERE id = 1").fetchone()
if exists:
conn.execute("UPDATE totp SET secret = ?, enabled = 0, created_at = ? WHERE id = 1",
(secret_b32, time.time()))
else:
conn.execute("INSERT INTO totp (id, enabled, secret, created_at) VALUES (1, 0, ?, ?)",
(secret_b32, time.time()))
conn.commit()
conn.close()
label = "WalletPress:admin"
issuer = "WalletPress"
qr_uri = f"otpauth://totp/{issuer}:{label}?secret={secret_b32}&issuer={issuer}&digits={TOTP_DIGITS}&period={TOTP_INTERVAL}"
return {
"secret_b32": secret_b32,
"qr_uri": qr_uri,
"manual_entry_key": secret_b32,
"setup_complete": False,
}
def enable(self) -> bool:
"""Enable 2FA after setup."""
conn = self._conn()
conn.execute("UPDATE totp SET enabled = 1 WHERE id = 1")
conn.commit()
conn.close()
return True
def disable(self) -> bool:
"""Disable 2FA."""
conn = self._conn()
conn.execute("UPDATE totp SET enabled = 0 WHERE id = 1")
conn.commit()
conn.close()
return True
def is_enabled(self) -> bool:
conn = self._conn()
row = conn.execute("SELECT enabled FROM totp WHERE id = 1").fetchone()
conn.close()
return bool(row and row["enabled"])
def _hotp(self, secret_bytes: bytes, counter: int) -> str:
"""Generate HMAC-based one-time password (RFC 4226)."""
msg = struct.pack(">Q", counter)
h = hmac.new(secret_bytes, msg, hashlib.sha1).digest()
offset = h[-1] & 0xF
code = (struct.unpack(">I", h[offset:offset + 4])[0] & 0x7FFFFFFF) % (10 ** TOTP_DIGITS)
return str(code).zfill(TOTP_DIGITS)
def _totp_at_time(self, secret_bytes: bytes, timestamp: float) -> str:
"""Generate TOTP code for a specific timestamp."""
counter = int(timestamp) // TOTP_INTERVAL
return self._hotp(secret_bytes, counter)
def verify(self, code: str) -> bool:
"""Verify a TOTP code within the allowed time window."""
if not self.is_enabled():
return True
conn = self._conn()
row = conn.execute("SELECT secret FROM totp WHERE id = 1").fetchone()
conn.close()
if not row:
return False
try:
padding = 8 - (len(row["secret"]) % 8) if len(row["secret"]) % 8 else 0
secret_bytes = base64.b32decode(row["secret"] + "=" * padding)
except Exception as e:
logger.error(f"TOTP decode failed: {e}")
return False
now = time.time()
for offset in range(-TOTP_WINDOW, TOTP_WINDOW + 1):
expected = self._totp_at_time(secret_bytes, now + offset * TOTP_INTERVAL)
if hmac.compare_digest(code.strip(), expected):
return True
return False
_manager: Optional[TOTPManager] = None
def get_totp_manager() -> TOTPManager:
global _manager
if _manager is None:
_manager = TOTPManager(cfg.data_dir / "totp.db")
return _manager

355
backend/core/vault.py Normal file
View file

@ -0,0 +1,355 @@
"""Encrypted wallet vault — AES-256-GCM + Argon2id, stored in SQLite.
Trust principle: The vault password NEVER leaves the server. If you use
client-side generation, the private key is encrypted before it ever reaches
the server. The server stores opaque blobs it cannot decrypt.
Server-side generation (enterprise vault) encrypts keys at rest with
the vault password. The password is configurable and never logged.
"""
from __future__ import annotations
import base64
import json
import logging
import secrets
import sqlite3
import threading
import time
import queue
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterator, Optional
from .config import cfg
logger = logging.getLogger("wp.vault")
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
except ImportError:
raise RuntimeError(
"cryptography is required. Install it: pip install cryptography>=42.0.0"
)
@dataclass
class WalletEntry:
id: str
chain: str
address: str
label: str
tags: list[str] = field(default_factory=list)
group: str = ""
created_at: float = 0.0
encrypted_key: str = ""
public_key: str = ""
derivation_path: str = ""
hd_path: str = ""
mnemonic_encrypted: str = ""
encrypted: bool = False
balance_usd: float = 0.0
notes: str = ""
class Vault:
"""Encrypted wallet vault with pooled SQLite connections.
Connection pool: up to POOL_SIZE reusable connections, thread-safe.
WAL mode + busy_timeout prevent 'database is locked' under concurrent load.
"""
POOL_SIZE = 8
BUSY_TIMEOUT = 5000 # ms before raising locked error
def __init__(self, db_path: Path):
self.db_path = db_path
self._pool: queue.Queue[sqlite3.Connection] = queue.Queue(maxsize=self.POOL_SIZE)
self._lock = threading.Lock()
self._total_conns = 0
if not cfg.vault_password:
raise RuntimeError(
"WalletPress vault requires encryption. "
"Set WP_VAULT_PASSWORD environment variable to a strong password. "
"Example: export WP_VAULT_PASSWORD='$(openssl rand -base64 32)'"
)
self._init_db()
def _new_conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path), timeout=self.BUSY_TIMEOUT / 1000, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=%d" % self.BUSY_TIMEOUT)
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def _conn(self) -> Iterator[sqlite3.Connection]:
"""Get a connection from the pool (or create one). Auto-returns on exit."""
try:
conn = self._pool.get_nowait()
except queue.Empty:
with self._lock:
if self._total_conns < self.POOL_SIZE:
conn = self._new_conn()
self._total_conns += 1
else:
conn = self._new_conn() # overflow — will close after use
try:
yield conn
finally:
try:
self._pool.put_nowait(conn)
except queue.Full:
conn.close()
with self._lock:
self._total_conns -= 1
SCHEMA_VERSION = 1
def _init_db(self):
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with self._new_conn() as conn:
conn.executescript("""
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
);
CREATE INDEX IF NOT EXISTS idx_wallets_chain ON wallets(chain);
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
CREATE VIRTUAL TABLE IF NOT EXISTS wallets_fts USING fts5(
id, chain, address, label, notes,
content='wallets',
content_rowid='rowid'
);
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)
);
CREATE TABLE IF NOT EXISTS _schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
);
""")
conn.commit()
self._migrate(conn)
def _migrate(self, conn):
# P3-5: Schema migrations are now handled by Alembic. This stub
# only stamps the schema version table. Kept for backward compat
# with existing DBs that have _schema_version rows.
row = conn.execute("SELECT MAX(version) FROM _schema_version").fetchone()
current = row[0] if row and row[0] else 0
if current >= self.SCHEMA_VERSION:
return
with self._lock:
conn.execute(
"INSERT OR IGNORE INTO _schema_version (version, applied_at) VALUES (?, ?)",
(self.SCHEMA_VERSION, time.time()),
)
conn.commit()
logger.info(
f"Vault schema version stamped at v{self.SCHEMA_VERSION}. "
f"For real schema migrations, run `alembic upgrade head`."
)
def encrypt_key(self, plaintext: str) -> str:
salt = secrets.token_bytes(16)
kdf = Argon2id(salt, 32, 3, 4, 65536)
key = kdf.derive(cfg.vault_password.encode())
aesgcm = AESGCM(key)
nonce = secrets.token_bytes(12)
ct = aesgcm.encrypt(nonce, plaintext.encode(), None)
return base64.b64encode(salt + nonce + ct).decode()
def decrypt_key(self, encrypted: str) -> str:
data = base64.b64decode(encrypted)
salt, nonce, ct = data[:16], data[16:28], data[28:]
kdf = Argon2id(salt, 32, 3, 4, 65536)
key = kdf.derive(cfg.vault_password.encode())
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, None).decode()
def put(self, wallet: WalletEntry) -> bool:
with self._conn() as conn:
try:
try:
conn.execute("ALTER TABLE wallets ADD COLUMN wallet_group TEXT DEFAULT ''")
conn.commit()
except Exception:
pass
conn.execute(
"""INSERT OR REPLACE INTO wallets
(id, chain, address, label, tags, wallet_group, created_at, encrypted_key,
public_key, derivation_path, hd_path, mnemonic_encrypted,
encrypted, balance_usd, notes, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
wallet.id, wallet.chain, wallet.address, wallet.label,
json.dumps(wallet.tags), wallet.group, wallet.created_at or time.time(),
wallet.encrypted_key, wallet.public_key,
wallet.derivation_path, wallet.hd_path,
wallet.mnemonic_encrypted, int(wallet.encrypted),
wallet.balance_usd, wallet.notes, time.time(),
),
)
conn.commit()
self.update_fts(wallet)
return True
except Exception as e:
logger.error(f"Vault put failed: {e}")
return False
def get(self, wallet_id: str) -> Optional[WalletEntry]:
with self._conn() as conn:
row = conn.execute("SELECT * FROM wallets WHERE id = ?", (wallet_id,)).fetchone()
if not row:
return None
return self._row_to_entry(row)
def get_by_address(self, address: str) -> Optional[WalletEntry]:
with self._conn() as conn:
row = conn.execute("SELECT * FROM wallets WHERE address = ?", (address,)).fetchone()
if not row:
return None
return self._row_to_entry(row)
def list(self, chain: str = "", limit: int = 100, offset: int = 0) -> list[WalletEntry]:
with self._conn() as conn:
if chain:
rows = conn.execute(
"SELECT * FROM wallets WHERE chain = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(chain, limit, offset),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM wallets ORDER BY created_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [self._row_to_entry(r) for r in rows]
def search(self, query: str) -> list[WalletEntry]:
with self._conn() as conn:
try:
rows = conn.execute(
"SELECT wallets.* FROM wallets_fts JOIN wallets ON wallets_fts.id = wallets.id WHERE wallets_fts MATCH ? ORDER BY rank LIMIT 50",
(query,),
).fetchall()
except Exception:
like = f"%{query}%"
rows = conn.execute(
"SELECT * FROM wallets WHERE address LIKE ? OR label LIKE ? OR chain LIKE ? OR notes LIKE ? LIMIT 50",
(like, like, like, like),
).fetchall()
return [self._row_to_entry(r) for r in rows]
def update_fts(self, wallet: WalletEntry):
with self._conn() as conn:
try:
conn.execute(
"INSERT OR REPLACE INTO wallets_fts (id, chain, address, label, notes) VALUES (?, ?, ?, ?, ?)",
(wallet.id, wallet.chain, wallet.address, wallet.label, wallet.notes),
)
conn.commit()
except Exception:
pass
def delete(self, wallet_id: str) -> bool:
with self._conn() as conn:
conn.execute("DELETE FROM wallets WHERE id = ?", (wallet_id,))
conn.commit()
return conn.total_changes > 0
def count(self, chain: str = "") -> int:
with self._conn() as conn:
if chain:
return conn.execute("SELECT COUNT(*) FROM wallets WHERE chain = ?", (chain,)).fetchone()[0]
return conn.execute("SELECT COUNT(*) FROM wallets").fetchone()[0]
def record_rotation(self, wallet_id: str, new_address: str, reason: str = ""):
with self._conn() as conn:
conn.execute(
"INSERT INTO rotations (wallet_id, new_address, rotated_at, reason) VALUES (?, ?, ?, ?)",
(wallet_id, new_address, time.time(), reason),
)
conn.commit()
def get_rotations(self, wallet_id: str) -> list[dict]:
with self._conn() as conn:
rows = conn.execute(
"SELECT * FROM rotations WHERE wallet_id = ? ORDER BY rotated_at DESC",
(wallet_id,),
).fetchall()
return [dict(r) for r in rows]
def stats(self) -> dict[str, Any]:
with self._conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM wallets").fetchone()[0]
by_chain = conn.execute(
"SELECT chain, COUNT(*) as count FROM wallets GROUP BY chain ORDER BY count DESC"
).fetchall()
encrypted = conn.execute("SELECT COUNT(*) FROM wallets WHERE encrypted = 1").fetchone()[0]
return {
"total_wallets": total,
"encrypted_wallets": encrypted,
"chains_used": len(by_chain),
"by_chain": {r["chain"]: r["count"] for r in by_chain},
"rotations": conn.execute("SELECT COUNT(*) FROM rotations").fetchone()[0],
}
def _row_to_entry(self, row) -> WalletEntry:
def _g(key: str, default=""):
try:
val = row[key]
return val if val is not None else default
except (KeyError, IndexError, TypeError):
return default
return WalletEntry(
id=_g("id"),
chain=_g("chain"),
address=_g("address"),
label=_g("label"),
tags=json.loads(_g("tags", "[]")),
group=_g("wallet_group"),
created_at=float(_g("created_at", "0")),
encrypted_key=_g("encrypted_key"),
public_key=_g("public_key"),
derivation_path=_g("derivation_path"),
hd_path=_g("hd_path"),
mnemonic_encrypted=_g("mnemonic_encrypted"),
encrypted=bool(_g("encrypted", "0")),
balance_usd=float(_g("balance_usd", "0")),
notes=_g("notes"),
)
_vault: Optional[Vault] = None
def get_vault() -> Vault:
global _vault
if _vault is None:
_vault = Vault(cfg.db_path)
return _vault

227
backend/core/webhooks.py Normal file
View file

@ -0,0 +1,227 @@
"""Webhook delivery system — reliable, retryable, signed.
Delivers webhook events to registered URLs with:
- HMAC-SHA256 signature headers
- 3 retries with exponential backoff (1s, 4s, 16s)
- At-least-once delivery semantics
- Idempotency via event_id
- Delivery logging
Trust: Webhooks allow YOUR systems to react to wallet events.
We never inspect or store webhook payloads after delivery.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import logging
import secrets
import time
from dataclasses import dataclass
from typing import Optional
import ipaddress
from urllib.parse import urlparse
import httpx
logger = logging.getLogger("wp.webhooks")
_PRIVATE_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def validate_webhook_url(url: str) -> None:
"""Validate a webhook URL to prevent SSRF attacks.
Rejects non-HTTPS URLs, private IPs, and internal hostnames.
"""
parsed = urlparse(url)
if parsed.scheme not in ("https",):
raise ValueError(f"Webhook URL must use HTTPS, got '{parsed.scheme}'")
host = parsed.hostname or ""
if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
raise ValueError("Webhook URL must not point to localhost")
if host.endswith(".local") or host.endswith(".internal"):
raise ValueError("Webhook URL must not point to an internal hostname")
try:
addr = ipaddress.ip_address(host)
for net in _PRIVATE_NETWORKS:
if addr in net:
raise ValueError(f"Webhook URL must not point to a private network ({net})")
except ValueError:
if isinstance(host, str) and host:
pass # hostname — can't validate without DNS resolution at registration time
# Delivery log — append-only, in-memory, capped at MAX entries
delivery_log: list[dict] = []
DELIVERY_LOG_MAX = 1000
def _log_delivery(event_id: str, event_type: str, target_url: str,
status: str, status_code: int = 0, attempt: int = 0,
error: str = ""):
entry = {
"event_id": event_id,
"event_type": event_type,
"target_url": target_url,
"status": status,
"status_code": status_code,
"attempt": attempt,
"error": error[:200] if error else "",
"timestamp": time.time(),
}
delivery_log.append(entry)
if len(delivery_log) > DELIVERY_LOG_MAX:
delivery_log.pop(0)
MAX_RETRIES = 3
BASE_DELAY = 1.0 # seconds
@dataclass
class WebhookEvent:
event_id: str
event_type: str
payload: dict
created_at: float = 0.0
@dataclass
class WebhookTarget:
url: str
secret: str
events: list[str]
active: bool = True
class WebhookDeliverer:
"""Delivers webhook events to registered targets with retry logic."""
def __init__(self):
self._targets: dict[str, WebhookTarget] = {}
self._client: Optional[httpx.AsyncClient] = None
self._running = False
self._queue: asyncio.Queue = asyncio.Queue()
async def start(self):
self._client = httpx.AsyncClient(timeout=15)
self._running = True
asyncio.create_task(self._worker_loop())
logger.info("Webhook deliverer started")
async def stop(self):
self._running = False
if self._client:
await self._client.aclose()
logger.info("Webhook deliverer stopped")
def register(self, target_id: str, url: str, secret: str, events: list[str]):
validate_webhook_url(url)
self._targets[target_id] = WebhookTarget(url=url, secret=secret, events=events)
def unregister(self, target_id: str):
self._targets.pop(target_id, None)
async def emit(self, event_type: str, payload: dict):
"""Queue a webhook event for delivery to matching targets."""
event = WebhookEvent(
event_id=f"evt_{secrets.token_hex(8)}_{int(time.time())}",
event_type=event_type,
payload=payload,
created_at=time.time(),
)
await self._queue.put(event)
logger.debug(f"Queued webhook: {event_type} ({event.event_id[:16]}...)")
def _should_deliver(self, target: WebhookTarget, event_type: str) -> bool:
if not target.active:
return False
if not target.events:
return True
return event_type in target.events or "*" in target.events
def _sign_payload(self, payload: bytes, secret: str) -> str:
return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
async def _deliver(self, target: WebhookTarget, event: WebhookEvent) -> bool:
body = json.dumps({
"event_id": event.event_id,
"event_type": event.event_type,
"created_at": event.created_at,
"payload": event.payload,
}).encode()
signature = self._sign_payload(body, target.secret)
for attempt in range(MAX_RETRIES):
try:
resp = await self._client.post(
target.url,
content=body,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Event": event.event_type,
"X-Webhook-Id": event.event_id,
"User-Agent": "WalletPress-Webhook/1.0",
},
)
if resp.is_success:
logger.info(f"Webhook delivered: {event.event_type} -> {target.url} (attempt {attempt + 1})")
_log_delivery(event.event_id, event.event_type, target.url,
"delivered", resp.status_code, attempt + 1)
return True
logger.warning(f"Webhook {target.url} returned {resp.status_code} (attempt {attempt + 1})")
_log_delivery(event.event_id, event.event_type, target.url,
"failed", resp.status_code, attempt + 1)
except Exception as e:
logger.warning(f"Webhook delivery failed: {target.url} ({e}) (attempt {attempt + 1})")
_log_delivery(event.event_id, event.event_type, target.url,
"error", 0, attempt + 1, str(e))
if attempt < MAX_RETRIES - 1:
delay = BASE_DELAY * (4 ** attempt)
await asyncio.sleep(delay)
logger.error(f"Webhook delivery FAILED after {MAX_RETRIES} attempts: {event.event_type} -> {target.url}")
return False
async def _worker_loop(self):
while self._running:
try:
event = await self._queue.get()
tasks = []
for target_id, target in self._targets.items():
if self._should_deliver(target, event.event_type):
tasks.append(self._deliver(target, event))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Webhook worker error: {e}")
_webhook_deliverer: Optional[WebhookDeliverer] = None
def get_webhook_deliverer() -> WebhookDeliverer:
global _webhook_deliverer
if _webhook_deliverer is None:
_webhook_deliverer = WebhookDeliverer()
return _webhook_deliverer

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,151 @@
"""x402 payment verification — multi-chain USDC via PayAI facilitator.
Replicates the RMI backend's payment verification system:
https://facilitator.payai.network/verify
Supports: Solana, Base, Polygon, Arbitrum, Ethereum all USDC.
No API key needed. Sends the x402 v2 payload, gets back isValid + tx data.
"""
from __future__ import annotations
import json
import logging
import os
import httpx
logger = logging.getLogger("wp.x402_verify")
FACILITATOR_URL = os.getenv("WP_X402_FACILITATOR_URL", "https://facilitator.payai.network/verify")
PAY_TO = os.getenv("WP_PAYMENT_ADDRESS", "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv")
# Chain config: network identifier, USDC mint/contract, default pay-to address
CHAIN_CONFIG: dict[str, dict[str, str]] = {
"sol": {
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"usdc": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_SOL", "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv"),
},
"base": {
"network": "eip155:8453",
"usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_BASE", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
},
"polygon": {
"network": "eip155:137",
"usdc": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_POLYGON", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
},
"arbitrum": {
"network": "eip155:42161",
"usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_ARBITRUM", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
},
"eth": {
"network": "eip155:1",
"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_ETH", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
},
}
_client: httpx.AsyncClient | None = None
async def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(timeout=30)
return _client
def get_supported_chains() -> list[str]:
return list(CHAIN_CONFIG.keys())
def get_pay_to(chain: str) -> str:
cfg = CHAIN_CONFIG.get(chain)
return cfg["pay_to"] if cfg else PAY_TO
async def verify_usdc_payment(chain: str, payment_tx: str, expected_usd: float) -> dict:
"""Verify a USDC payment on any supported chain via PayAI facilitator.
Args:
chain: Chain key ('sol', 'base', 'polygon', 'arbitrum', 'eth')
payment_tx: Transaction signature/hash
expected_usd: Expected USD amount
Returns:
dict with keys: verified (bool), reason (str), tx_hash (str),
payer (str), amount (float)
"""
if expected_usd <= 0:
return {"verified": True, "reason": "free", "tx_hash": "", "payer": "", "amount": 0}
if not payment_tx:
return {"verified": False, "reason": "No payment tx provided", "tx_hash": "", "payer": "", "amount": 0}
chain_cfg = CHAIN_CONFIG.get(chain)
if not chain_cfg:
return {"verified": False, "reason": f"Unsupported chain: {chain}", "tx_hash": payment_tx, "payer": "", "amount": 0}
network = chain_cfg["network"]
usdc_address = chain_cfg["usdc"]
pay_to = chain_cfg["pay_to"]
payload = {
"x402Version": 2,
"accepted": {
"scheme": "exact",
"network": network,
"asset": f"{network}:{usdc_address}" if chain != "sol" else f"solana:{usdc_address}",
"amount": str(expected_usd),
"payTo": pay_to,
"maxTimeoutSeconds": 300,
},
"paymentTx": payment_tx,
}
body = {
"x402Version": 2,
"paymentPayload": payload,
"paymentRequirements": {
"x402Version": 2,
"scheme": "exact",
"network": network,
"asset": f"{network}:{usdc_address}" if chain != "sol" else f"solana:{usdc_address}",
"amount": str(expected_usd),
"payTo": pay_to,
"maxTimeoutSeconds": 300,
},
}
try:
client = await get_client()
resp = await client.post(FACILITATOR_URL, json=body)
data = resp.json() if resp.content else {}
except httpx.TimeoutException:
logger.error("x402 PayAI verification timed out")
return {"verified": False, "reason": "Verification service timeout", "tx_hash": payment_tx, "payer": "", "amount": 0}
except httpx.RequestError as e:
logger.error(f"x402 PayAI verification failed: {e}")
return {"verified": False, "reason": f"Verification service error: {e}", "tx_hash": payment_tx, "payer": "", "amount": 0}
except json.JSONDecodeError:
logger.error(f"x402 PayAI returned non-JSON: {resp.status_code}")
return {"verified": False, "reason": "Invalid response from verification service", "tx_hash": payment_tx, "payer": "", "amount": 0}
if data.get("isValid"):
result = {
"verified": True,
"reason": data.get("invalidReason", "verified"),
"tx_hash": data.get("tx_hash", payment_tx),
"payer": data.get("payer", ""),
"amount": float(data.get("amount", expected_usd)),
}
logger.info(f"x402 payment verified: chain={chain} tx={payment_tx[:16]}... amount=${expected_usd}")
return result
reason = data.get("invalidReason", "unknown")
message = data.get("invalidMessage", "")
logger.warning(f"x402 payment rejected: chain={chain} tx={payment_tx[:16]}... reason={reason}")
return {"verified": False, "reason": f"{reason}: {message}", "tx_hash": payment_tx, "payer": "", "amount": 0}

View file

@ -0,0 +1,25 @@
services:
walletpress:
build: .
ports:
- "8010:8010"
volumes:
- walletpress_data:/data
environment:
WP_ADMIN_KEY: "${WP_ADMIN_KEY:?WP_ADMIN_KEY is required}"
WP_VAULT_PASSWORD: "${WP_VAULT_PASSWORD:?WP_VAULT_PASSWORD is required}"
WP_HOST: "0.0.0.0"
WP_PORT: "8010"
WP_DATA_DIR: "/data"
WP_RATE_LIMIT: "60"
WP_CORS_ORIGINS: "http://localhost:8010"
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8010/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
walletpress_data:

15
backend/entrypoint.sh Executable file
View file

@ -0,0 +1,15 @@
#!/bin/sh
# Entry runs as root. Chown data dir then exec CMD as walletpress (per Dockerfile USER directive).
set -e
mkdir -p /data
# chown needs to be done as root (current user)
chown -R walletpress:walletpress /data
chmod 755 /data
# Print info
echo "[walletpress] data dir ready: /data"
echo "[walletpress] starting: $@"
# Exec the CMD — runs as walletpress (Dockerfile USER directive)
exec "$@"

623
backend/main.py Normal file
View file

@ -0,0 +1,623 @@
"""WalletPress Backend — Multi-Chain Wallet Generation & Management.
Usage:
# Install and run (no Docker needed)
pip install -r requirements.txt
uvicorn main:app --port 8010
# Or via the walletpress CLI
python main.py
# With Docker
docker compose up
Trust: This backend is fully open source. You can verify every operation.
Private keys are encrypted at rest with AES-256-GCM + Argon2id. The vault
password is configurable and NEVER logged.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from core.audit import get_audit
from core.auth import get_key_store
from core.config import cfg
from core.rate_limit import RateLimitMiddleware
from core.webhooks import get_webhook_deliverer
from core.ip_allowlist import IPAllowlistMiddleware
from core.license_check import LicenseMiddleware
from routers import chain_vault, wallet_admin, wallet_analysis, wallet_memory, test_vectors, metrics as metrics_router
from routers import airdrop as airdrop_router, health_monitor as health_router
from routers import hosting as hosting_router, license_router as license_router
from routers import retention as retention_router
from x402_service import app as x402_app
# AI Wallet Agent
from agent.mcp_server import mcp as agent_mcp
from agent.scheduler import scheduler as agent_scheduler
logger = logging.getLogger("wp")
class RequestIDMiddleware:
"""Generates X-Request-ID, stamps it on request.state and response headers.
Must be the outermost middleware so every downstream handler and log
line can reference the request_id.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request_id = str(uuid.uuid4())[:12]
async def send_with_id(message):
if message["type"] == "http.response.start":
headers = message.get("headers", [])
headers.append((b"X-Request-ID", request_id.encode()))
message["headers"] = headers
await send(message)
scope["state"] = {**scope.get("state", {}), "request_id": request_id}
await self.app(scope, receive, send_with_id)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(f"WalletPress v{cfg.version} starting on {cfg.host}:{cfg.port}")
if not cfg.admin_key:
raise RuntimeError("WP_ADMIN_KEY is required. Set it in the environment before starting.")
if not cfg._vault_password:
raise RuntimeError("WP_VAULT_PASSWORD is required. Set it in the environment before starting.")
os.makedirs(cfg.data_dir, exist_ok=True)
# Print AI provider table on startup
from agent.providers import list_providers
for line in list_providers().split("\n"):
logger.info(line)
# Log referral revenue status
from plugins.defi import REVENUE_MODE, REF_JUPITER_WALLET, REF_HYPERLIQUID_WALLET
jup_ok = bool(REF_JUPITER_WALLET)
hl_ok = bool(REF_HYPERLIQUID_WALLET)
logger.info(f"Revenue mode: {REVENUE_MODE.upper()} | Jupiter: {'READY (50bps)' if jup_ok else 'NEEDS SETUP'} | Hyperliquid: {'READY (builder code)' if hl_ok else 'NEEDS SETUP'}")
if REVENUE_MODE == "freemium":
logger.info("Revenue: Jupiter 50bps (set WP_REF_JUPITER_WALLET) + Hyperliquid builder fees (set WP_REF_HYPERLIQUID_WALLET, needs 100 USDC perps)")
else:
logger.info("Self-referral mode: you keep 100% of platform revenue.")
# Initialize services into app.state (enables DI and testability)
from core.vault import Vault
from core.auth import KeyStore
from core.audit import AuditLog
from core.license import LicenseManager
from core.proof import ProofOfGeneration
from wallet_engine.generator import WalletGenerator
app.state.vault = Vault(cfg.db_path)
app.state.key_store = KeyStore(cfg.keys_path)
app.state.audit = AuditLog(cfg.audit_path)
app.state.license = LicenseManager()
app.state.proof = ProofOfGeneration(cfg.data_dir / "proof.db")
app.state.generator = WalletGenerator(vault_password=cfg.vault_password)
app.state.webhook_deliverer = get_webhook_deliverer()
await app.state.webhook_deliverer.start()
# Reload persisted webhooks into the live deliverer
from routers._persistent_store import PersistentStore
PersistentStore.reload_webhooks()
# Anchor source commit to Arweave for on-chain code verification
if os.getenv("WP_POF_ARWEAVE_KEY_PATH"):
from core.proof import anchor_source_commit
result = anchor_source_commit()
if result.get("anchored"):
logger.info(f"Source commit anchored: {result['commit'][:16]}... tx={result.get('tx_id', '')}")
else:
logger.warning(f"Source commit anchoring skipped: {result.get('reason', 'unknown')}")
# Background tasks
import asyncio
# Start AI Agent background scheduler (DCA, monitoring, rotations)
agent_scheduler.start()
logger.info("AI Wallet Agent scheduler started")
# Start Proof of Generation auto-commit background task
async def proof_auto_commit_loop():
auto_commit = os.getenv("WP_POF_AUTO_COMMIT", "1") == "1"
if not auto_commit:
logger.info("Proof of Generation auto-commit disabled (WP_POF_AUTO_COMMIT=0)")
return
interval = int(os.getenv("WP_POF_COMMIT_INTERVAL", "3600"))
await asyncio.sleep(interval) # delay first commit to let wallets accumulate
while True:
try:
from core.proof import get_proof
proof = get_proof()
root_hash, count = proof.compute_merkle_root()
if root_hash:
chain = "local"
if os.getenv("WP_POF_ARWEAVE_KEY_PATH"):
chain = "arweave"
elif os.getenv("WP_POF_ETH_RPC") and os.getenv("WP_POF_ETH_PRIVATE_KEY"):
chain = "ethereum"
proof.commit(root_hash, count, commitment_chain=chain)
logger.info(f"Auto-committed {count} attestations to {chain}: {root_hash[:16]}...")
except Exception as e:
logger.error(f"Proof auto-commit failed: {e}")
await asyncio.sleep(interval)
asyncio.ensure_future(proof_auto_commit_loop())
async def temporal_cleanup_loop():
while True:
await asyncio.sleep(60)
try:
from routers.chain_vault import _cleanup_expired_temporals
_cleanup_expired_temporals()
except Exception:
pass
asyncio.ensure_future(temporal_cleanup_loop())
yield
agent_scheduler.stop()
await app.state.webhook_deliverer.stop()
logger.info("WalletPress shutdown complete")
app = FastAPI(
title=cfg.title,
version=cfg.version,
description=cfg.description,
lifespan=lifespan,
)
app.add_middleware(RequestIDMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=cfg.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if cfg.rate_limit_per_minute > 0:
app.add_middleware(RateLimitMiddleware, rate=cfg.rate_limit_per_minute)
app.add_middleware(metrics_router.MetricsMiddleware)
app.add_middleware(IPAllowlistMiddleware)
app.add_middleware(LicenseMiddleware)
@app.middleware("http")
async def require_auth_on_mutations(request: Request, call_next):
"""Authenticate + role-check write requests.
P0-3 fix: previously this middleware only checked key validity, not role.
A 'viewer' role key could mutate state. Now we:
1. Verify the key (or accept admin key)
2. Attach the APIKey to request.state.api_key_obj
3. Enforce minimum role 'viewer' on write endpoints (callers needing
stricter checks use the require_role() dependency)
For GET requests, we still verify the key and attach it to request.state,
but don't reject based on role (read-only is allowed for any role).
"""
path = request.url.path
skip = ("/health", "/docs", "/openapi.json", "/metrics", "/hosting/register", "/hosting/login")
needs_auth = (
request.method in ("POST", "PUT", "PATCH", "DELETE")
or path.startswith("/api/v1/team/") # GET/POST/DELETE all need auth+role
)
if needs_auth and not path.startswith(skip) and not path.startswith("/mcp/") and not path.startswith("/ws/"):
auth = request.headers.get("Authorization", "").replace("Bearer ", "")
if not auth:
auth = request.headers.get("X-API-Key", "")
if not auth:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=401, content={"error": "API key required. Provide via X-API-Key or Authorization: Bearer header."})
api_key_obj = None
if auth == cfg.admin_key:
# Admin env-var key gets implicit admin role
from core.auth import APIKey
api_key_obj = APIKey(
id="env_admin", key_hash="env", label="env_admin",
scopes=["admin"], created_at=0, role="admin",
)
else:
from core.auth import get_key_store
ks = get_key_store()
api_key_obj = ks.verify(auth)
if not api_key_obj:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=403, content={"error": "Invalid or revoked API key"})
# For write requests: viewer is not enough
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
from core.auth import role_has_at_least
if not role_has_at_least(api_key_obj.role, "operator"):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=403,
content={
"error": f"Insufficient role: '{api_key_obj.role}' cannot perform write operations. Required: operator or admin."
},
)
# Stash for endpoint handlers (and downstream dependencies)
request.state.api_key_obj = api_key_obj
response = await call_next(request)
return response
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
rid = getattr(request.state, "request_id", "-")
response = await call_next(request)
elapsed = time.time() - start
logger.info("http_request method=%s path=%s status=%d elapsed=%.3fs request_id=%s",
request.method, request.url.path, response.status_code, elapsed, rid)
return response
from fastapi.exceptions import HTTPException as FastAPIHTTPException
from starlette.exceptions import HTTPException as StarletteHTTPException
def _request_id(request: Request) -> str:
return getattr(request.state, "request_id", "-")
@app.exception_handler(StarletteHTTPException)
@app.exception_handler(FastAPIHTTPException)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
rid = _request_id(request)
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail if isinstance(exc.detail, str) else exc.detail, "request_id": rid},
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
rid = _request_id(request)
if isinstance(exc, (HTTPException, FastAPIHTTPException, StarletteHTTPException)):
return await http_exception_handler(request, exc)
logger.error("unhandled_exception method=%s path=%s exc=%s request_id=%s",
request.method, request.url.path, f"{type(exc).__name__}: {exc}", rid)
return JSONResponse(status_code=500, content={"error": "Internal server error", "request_id": rid})
app.include_router(chain_vault.router)
app.include_router(wallet_admin.router)
app.include_router(wallet_analysis.router)
app.include_router(wallet_memory.router)
app.include_router(test_vectors.router)
app.include_router(metrics_router.router)
app.include_router(airdrop_router.router)
app.include_router(health_router.router)
app.include_router(hosting_router.router)
app.include_router(license_router.router)
app.include_router(retention_router.router)
# ── Team Access Middleware ──────────────────────────────────
# P0-3 fix: team keys are now persisted via KeyStore (not in-memory dict)
# and use role-based access control via require_role dependency.
@app.post("/api/v1/team/keys")
async def create_team_key(label: str, role: str = "operator", request: Request = None):
"""Create a team API key with specific role and permissions.
Roles (hierarchy: admin > operator > viewer):
admin full access, can manage keys
operator generate wallets, view vault
viewer read-only access
Team keys are scoped to your user account. Audit log records which
team member performed each action. Keys are persisted to disk via
KeyStore (survive restarts).
"""
# Only admins can create team keys (enforced via the request auth context)
caller_key = getattr(request.state, "api_key_obj", None)
if not caller_key or caller_key.role != "admin":
raise HTTPException(
status_code=403,
detail="Only admin role can create team keys",
)
if role not in ("admin", "operator", "viewer"):
raise HTTPException(
status_code=400,
detail="Invalid role. Must be admin, operator, or viewer.",
)
store = get_key_store()
key_id, raw_key = store.create(label=label, role=role, owner=caller_key.owner or caller_key.id)
get_audit().log("team.key.create", actor=caller_key.id, resource=key_id,
detail={"label": label, "role": role})
return {
"key_id": key_id,
"api_key": raw_key,
"label": label,
"role": role,
"warning": "Save this key now. It won't be shown again.",
}
@app.get("/api/v1/team/keys")
async def list_team_keys(request: Request):
"""List all team API keys (admin only)."""
caller_key = getattr(request.state, "api_key_obj", None)
if not caller_key or caller_key.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
store = get_key_store()
return {"keys": store.list()}
@app.delete("/api/v1/team/keys/{key_id}")
async def revoke_team_key(key_id: str, request: Request):
"""Revoke a team API key immediately (admin only)."""
caller_key = getattr(request.state, "api_key_obj", None)
if not caller_key or caller_key.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
store = get_key_store()
revoked = store.revoke(key_id)
if not revoked:
raise HTTPException(status_code=404, detail=f"Key {key_id} not found")
get_audit().log("team.key.revoke", actor=caller_key.id, resource=key_id)
return {"revoked": True, "key_id": key_id}
@app.get("/health")
async def health():
status = "ok"
status_code = 200
checks = {}
try:
from core.vault import get_vault
v = get_vault()
v.count()
checks["vault"] = "ok"
except Exception as e:
checks["vault"] = f"error: {e}"
status = "degraded"
try:
from core.auth import get_key_store
ks = get_key_store()
ks.list()
checks["key_store"] = "ok"
except Exception as e:
checks["key_store"] = f"error: {e}"
status = "degraded"
try:
from core.proof import get_proof
p = get_proof()
p.stats()
checks["proof"] = "ok"
except Exception as e:
checks["proof"] = f"error: {e}"
status = "degraded"
try:
from core.webhooks import get_webhook_deliverer
wd = get_webhook_deliverer()
checks["webhooks"] = "ok" if wd._running else "not_running"
except Exception as e:
checks["webhooks"] = f"error: {e}"
status = "degraded"
if status == "degraded":
status_code = 503
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=status_code,
content={
"status": status,
"version": cfg.version,
"service": "walletpress",
"checks": checks,
},
)
@app.get("/")
async def root():
from core.proof import get_source_tree_hash
return {
"service": "WalletPress API",
"version": cfg.version,
"docs": "/docs",
"openapi": "/openapi.json",
"repository": "https://github.com/cryptorugmuncher/walletpress",
"source_commit": get_source_tree_hash(),
"trust": {
"open_source": True,
"telemetry": False,
"client_side_generation": True,
"encryption": "AES-256-GCM + Argon2id",
"standards": ["BIP39", "BIP32", "BIP44", "BIP49", "BIP84"],
"reproducible_builds": True,
"build_verification": "/trust/build",
},
}
@app.get("/trust/build")
async def trust_build():
"""Reproducible build verification.
Returns the current source commit hash and Docker image metadata.
Anyone can rebuild from source and compare the Docker image SHA
to verify the running code matches the open-source repository.
"""
from core.proof import get_source_tree_hash
commit = get_source_tree_hash()
return {
"service": "WalletPress",
"source_repository": "https://github.com/cryptorugmuncher/walletpress",
"source_commit": commit,
"source_commit_url": f"https://github.com/cryptorugmuncher/walletpress/commit/{commit}" if commit != "unknown" else "",
"build_method": "Docker multi-stage build (see Dockerfile)",
"build_command": "docker build -t walletpress .",
"verify_command": "docker pull walletpress && docker inspect walletpress --format '{{.Id}}' | cut -d: -f2",
"reproducible": True,
"note": "Build from source, compare the image SHA. If they match, the binary matches the code.",
}
@app.get("/trust/audit")
async def trust_audit():
"""Public audit log — immutable, append-only.
Returns recent audit entries. The audit log cannot be modified
only new entries can be appended. This proves operational transparency.
"""
from core.audit import get_audit
audit = get_audit()
entries = audit.query(limit=50)
stats = audit.stats()
return {
"service": "WalletPress Audit Log",
"total_entries": stats.get("total_entries", 0),
"file_size_bytes": stats.get("file_size", 0),
"append_only": True,
"immutable": True,
"recent_entries": entries,
"note": "Audit log is append-only. Entries cannot be modified or deleted.",
}
# Mount x402 marketplace as sub-app (was standalone x402_service.py)
# x402's endpoints (/api/v1/marketplace/generate, /api/v1/marketplace/pricing, etc.) become
# available at /api/v1/marketplace/... when mounted at root. Its /health is not mounted
# to avoid conflict with main app's /health.
app.mount("/", x402_app)
# Mount AI Wallet Agent MCP server (SSE transport)
# Connect via: opencode, Claude Code, Cursor, or any MCP client
# Endpoint: GET /mcp/sse or POST /mcp/messages
app.mount("/mcp", agent_mcp.sse_app())
# ── WebSocket Event Stream ───────────────────────────────────
_ws_clients: dict[WebSocket, set[str]] = {}
_ws_rate: dict[WebSocket, float] = {}
_WS_RATE_LIMIT = 10 # messages per second per connection
@app.websocket("/ws/events")
async def websocket_events(ws: WebSocket):
"""WebSocket endpoint for real-time wallet events.
Connect and receive push events as they happen:
{"event": "wallet.generated", "data": {"wallet_id": "...", "chain": "eth"}}
{"event": "payment.received", "data": {"tx_hash": "...", "amount": 100}}
Subscribe to specific event types by sending a JSON message:
{"subscribe": ["wallet.generated", "payment.received"]}
Default: subscribe to all events.
Rate limit: 10 messages/second per connection.
Authentication: Pass API key as ?token= query parameter.
"""
await ws.accept()
token = ws.query_params.get("token", "")
if token:
from core.auth import get_key_store
ks = get_key_store()
if not ks.verify(token):
await ws.send_json({"error": "Invalid API key"})
await ws.close()
return
_ws_clients[ws] = set()
_ws_rate[ws] = time.monotonic()
try:
while True:
data = await ws.receive_text()
now = time.monotonic()
last = _ws_rate.get(ws, 0)
if now - last < 1.0 / _WS_RATE_LIMIT:
await ws.send_json({"event": "rate_limited", "data": {"message": "Slow down (max 10 msg/s)"}})
continue
_ws_rate[ws] = now
try:
msg = json.loads(data)
except json.JSONDecodeError:
if data == "ping":
await ws.send_json({"event": "pong"})
continue
if isinstance(msg, dict) and "subscribe" in msg:
subs = msg["subscribe"]
if isinstance(subs, list) and all(isinstance(s, str) for s in subs):
_ws_clients[ws] = set(subs)
await ws.send_json({"event": "subscribed", "data": {"events": subs}})
except WebSocketDisconnect:
pass
finally:
_ws_clients.pop(ws, None)
_ws_rate.pop(ws, None)
async def broadcast_ws(event_type: str, data: dict):
"""Broadcast an event to all connected WebSocket clients (respects subscriptions)."""
message = json.dumps({"event": event_type, "data": data})
dead: list[WebSocket] = []
for ws, subs in list(_ws_clients.items()):
if subs and event_type not in subs:
continue
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
_ws_clients.pop(ws, None)
_ws_rate.pop(ws, None)
# Wire event bus to WebSocket broadcast
from core.event_bus import subscribe as _subscribe_events
def _ws_event_forwarder(event_type: str, data: dict):
"""Forward events from the bus to the WebSocket broadcast loop."""
import asyncio
asyncio.ensure_future(broadcast_ws(event_type, data))
_subscribe_events(_ws_event_forwarder)
if __name__ == "__main__":
import uvicorn
logging.basicConfig(level=logging.INFO)
port = int(sys.argv[1]) if len(sys.argv) > 1 else cfg.port
uvicorn.run("main:app", host=cfg.host, port=port, reload=True)

Some files were not shown because too many files have changed in this diff Show more