docs: apply fleet-template (16-artifact scaffold)
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:
commit
e13bd4d774
203 changed files with 31140 additions and 0 deletions
126
backend/.env.example
Normal file
126
backend/.env.example
Normal 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
58
backend/.github/workflows/ci.yml
vendored
Normal 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
|
||||
BIN
backend/.mypy_cache/3.12/cache.0.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.0.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.0.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.0.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.0.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.0.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.1.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.1.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.1.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.1.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.1.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.1.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.10.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.10.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.10.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.10.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.10.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.10.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.11.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.11.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.11.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.11.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.11.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.11.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.12.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.12.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.12.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.12.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.12.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.12.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.13.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.13.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.13.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.13.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.13.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.13.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.14.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.14.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.14.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.14.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.14.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.14.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.15.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.15.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.15.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.15.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.15.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.15.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.2.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.2.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.2.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.2.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.2.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.2.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.3.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.3.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.3.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.3.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.3.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.3.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.4.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.4.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.4.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.4.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.4.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.4.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.5.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.5.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.5.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.5.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.5.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.5.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.6.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.6.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.6.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.6.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.6.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.6.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.7.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.7.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.7.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.7.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.7.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.7.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.8.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.8.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.8.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.8.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.8.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.8.db-wal
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.9.db
Normal file
BIN
backend/.mypy_cache/3.12/cache.9.db
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.9.db-shm
Normal file
BIN
backend/.mypy_cache/3.12/cache.9.db-shm
Normal file
Binary file not shown.
BIN
backend/.mypy_cache/3.12/cache.9.db-wal
Normal file
BIN
backend/.mypy_cache/3.12/cache.9.db-wal
Normal file
Binary file not shown.
44
backend/Dockerfile
Normal file
44
backend/Dockerfile
Normal 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
60
backend/README.md
Normal 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
1
backend/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.1.0
|
||||
23
backend/adapters/crewai.py
Normal file
23
backend/adapters/crewai.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""CrewAI Adapter — Use WalletPress tools in CrewAI agents."""
|
||||
from __future__ import annotations
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
class WalletPressCrewTool(BaseTool):
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
_fn = None
|
||||
|
||||
def __init__(self, name: str, description: str, fn):
|
||||
super().__init__(name=name, description=description)
|
||||
self._fn = fn
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
import json
|
||||
result = self._fn(**kwargs) if callable(self._fn) else {"error": "no fn"}
|
||||
return json.dumps(result, default=str)
|
||||
|
||||
|
||||
def get_tools() -> list[BaseTool]:
|
||||
from agent.mcp_server import mcp
|
||||
return [WalletPressCrewTool(name=t.name, description=t.description or t.name, fn=t.fn)
|
||||
for t in mcp._tool_manager.list_tools()]
|
||||
28
backend/adapters/eliza.py
Normal file
28
backend/adapters/eliza.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Eliza OS Adapter (plugin) — Use WalletPress tools from Eliza agents."""
|
||||
from __future__ import annotations
|
||||
# Eliza plugins import via: https://github.com/elizaos/eliza/tree/main/packages/plugin-goat
|
||||
# This adapter provides the Eliza-compatible tool format for WalletPress.
|
||||
|
||||
def get_actions() -> list[dict]:
|
||||
"""Get MCP tools as Eliza action format."""
|
||||
from agent.mcp_server import mcp
|
||||
actions = []
|
||||
for t in mcp._tool_manager.list_tools():
|
||||
actions.append({
|
||||
"name": t.name,
|
||||
"description": t.description or t.name,
|
||||
"similes": [t.name.replace("_", " ")],
|
||||
"supports_async": True,
|
||||
"handler": lambda params, tool=t: _handle(tool.name, params),
|
||||
})
|
||||
return actions
|
||||
|
||||
def _handle(tool_name: str, params: dict) -> dict:
|
||||
import asyncio
|
||||
from agent.mcp_server import mcp
|
||||
for t in mcp._tool_manager.list_tools():
|
||||
if t.name == tool_name:
|
||||
if asyncio.iscoroutinefunction(t.fn):
|
||||
return asyncio.run(t.fn(**params))
|
||||
return t.fn(**params)
|
||||
return {"error": f"Tool {tool_name} not found"}
|
||||
35
backend/adapters/langchain.py
Normal file
35
backend/adapters/langchain.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""LangChain Adapter — Use WalletPress tools in LangChain agents."""
|
||||
from __future__ import annotations
|
||||
from langchain.tools import BaseTool
|
||||
|
||||
|
||||
class WalletPressTool(BaseTool):
|
||||
"""LangChain tool wrapping a WalletPress MCP tool."""
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
_fn = None
|
||||
|
||||
def __init__(self, name: str, description: str, fn, **kwargs):
|
||||
super().__init__(name=name, description=description, **kwargs)
|
||||
self._fn = fn
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
import json
|
||||
result = self._fn(**kwargs) if callable(self._fn) else {"error": "no fn"}
|
||||
return json.dumps(result, default=str)
|
||||
|
||||
async def _arun(self, **kwargs) -> str:
|
||||
import json
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(self._fn):
|
||||
result = await self._fn(**kwargs)
|
||||
else:
|
||||
result = self._fn(**kwargs)
|
||||
return json.dumps(result, default=str)
|
||||
|
||||
|
||||
def get_tools() -> list[BaseTool]:
|
||||
"""Get all WalletPress MCP tools as LangChain tools."""
|
||||
from agent.mcp_server import mcp
|
||||
return [WalletPressTool(name=t.name, description=t.description or t.name, fn=t.fn)
|
||||
for t in mcp._tool_manager.list_tools()]
|
||||
21
backend/adapters/openai_agents.py
Normal file
21
backend/adapters/openai_agents.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""OpenAI Agents SDK Adapter — Use WalletPress tools with OpenAI Agents SDK."""
|
||||
from __future__ import annotations
|
||||
|
||||
def get_tools() -> list[dict]:
|
||||
"""Get all MCP tools as OpenAI function-calling format."""
|
||||
from agent.mcp_server import mcp
|
||||
functions = []
|
||||
for t in mcp._tool_manager.list_tools():
|
||||
functions.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description or t.name,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {k: {"type": "string"} for k in (t.parameters or {}).keys()},
|
||||
"required": list((t.parameters or {}).keys())[:3] if t.parameters else [],
|
||||
},
|
||||
},
|
||||
})
|
||||
return functions
|
||||
16
backend/adapters/vercel_ai.py
Normal file
16
backend/adapters/vercel_ai.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Vercel AI SDK Adapter — Use WalletPress tools with Vercel AI SDK."""
|
||||
from __future__ import annotations
|
||||
|
||||
def get_tools() -> list[dict]:
|
||||
"""Get all MCP tools as Vercel AI SDK tool format."""
|
||||
from agent.mcp_server import mcp
|
||||
tools = {}
|
||||
for t in mcp._tool_manager.list_tools():
|
||||
tools[t.name] = {
|
||||
"description": t.description or t.name,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {k: {"type": "string"} for k in (t.parameters or {}).keys()},
|
||||
},
|
||||
}
|
||||
return tools
|
||||
1
backend/agent/__init__.py
Normal file
1
backend/agent/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""WalletPress AI Wallet Agent — MCP-native, multi-provider, self-hosted."""
|
||||
245
backend/agent/detector.py
Normal file
245
backend/agent/detector.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Anomaly Detection — Identifies suspicious wallet activity.
|
||||
|
||||
Checks performed:
|
||||
- Unusually large transactions (>3x average)
|
||||
- First-time interactions with new addresses
|
||||
- Rapid-fire transactions (>5 in 1 minute)
|
||||
- Unusual transaction timing (e.g. 3 AM in user's timezone)
|
||||
- Dusting attacks (many tiny incoming transactions)
|
||||
- Sudden balance drops
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import statistics
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("wp.agent.detector")
|
||||
|
||||
|
||||
async def scan_wallet(address: str, chain: str = "eth") -> dict:
|
||||
"""Scan a wallet for anomalous patterns.
|
||||
|
||||
Uses available on-chain data providers. Falls back gracefully
|
||||
if the chain's RPC is not configured.
|
||||
|
||||
Args:
|
||||
address: Wallet address to analyze
|
||||
chain: Chain key (eth, sol, trx, btc, ...)
|
||||
|
||||
Returns:
|
||||
dict with risk_score, anomalies list, and metadata
|
||||
"""
|
||||
from wallet_engine.chains import CHAINS
|
||||
|
||||
chain_info = CHAINS.get(chain.lower())
|
||||
if not chain_info:
|
||||
return {"error": f"Unsupported chain: {chain}", "anomalies": [], "risk_score": 0}
|
||||
|
||||
anomalies: list[dict] = []
|
||||
warnings: list[str] = []
|
||||
risk_score = 0
|
||||
|
||||
# 1. Fetch recent transactions (best-effort)
|
||||
txs = await _fetch_recent_txs(chain, address)
|
||||
if txs:
|
||||
tx_anomalies = _analyze_transactions(txs)
|
||||
anomalies.extend(tx_anomalies)
|
||||
risk_score += len(tx_anomalies) * 15
|
||||
|
||||
# 2. Fetch balance (best-effort)
|
||||
balance = await _fetch_balance(chain, address)
|
||||
if balance is not None:
|
||||
if balance > 0 and not txs:
|
||||
warnings.append("Positive balance but no transaction history — possible dormant wallet")
|
||||
if balance == 0 and txs:
|
||||
warnings.append("Zero balance with transaction history — wallet may be drained")
|
||||
else:
|
||||
warnings.append(f"Could not fetch balance for {chain}:{address}")
|
||||
|
||||
# 3. Address format check
|
||||
from wallet_engine.chains import validate_address as va
|
||||
if not va(chain, address):
|
||||
warnings.append("Address format is invalid")
|
||||
risk_score += 30
|
||||
|
||||
# 4. Check if it looks like a known scam pattern
|
||||
if _looks_like_dusting(txs):
|
||||
anomalies.append({
|
||||
"type": "dusting_attack",
|
||||
"severity": "medium",
|
||||
"description": "Wallet received multiple tiny transactions — possible dusting attack",
|
||||
"count": sum(1 for t in txs if _is_dust(t)),
|
||||
})
|
||||
risk_score += 20
|
||||
|
||||
risk_score = min(risk_score, 100)
|
||||
|
||||
return {
|
||||
"address": address,
|
||||
"chain": chain,
|
||||
"risk_score": risk_score,
|
||||
"risk_level": _risk_label(risk_score),
|
||||
"anomalies": anomalies,
|
||||
"warnings": warnings,
|
||||
"checked_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def _risk_label(score: int) -> str:
|
||||
if score >= 70:
|
||||
return "critical"
|
||||
if score >= 40:
|
||||
return "high"
|
||||
if score >= 20:
|
||||
return "medium"
|
||||
if score > 0:
|
||||
return "low"
|
||||
return "none"
|
||||
|
||||
|
||||
async def _fetch_recent_txs(chain: str, address: str, limit: int = 50) -> list[dict]:
|
||||
"""Fetch recent transactions for a wallet. Best-effort per chain."""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
if chain == "eth":
|
||||
r = await c.get(f"https://api.etherscan.io/api?module=account&action=txlist&address={address}&sort=desc&offset={limit}")
|
||||
data = r.json()
|
||||
if data.get("status") == "1":
|
||||
return data["result"]
|
||||
elif chain == "sol":
|
||||
r = await c.post("https://api.mainnet-beta.solana.com", json={
|
||||
"jsonrpc": "2.0", "id": 1,
|
||||
"method": "getSignaturesForAddress",
|
||||
"params": [address, {"limit": min(limit, 50)}],
|
||||
})
|
||||
data = r.json()
|
||||
if "result" in data:
|
||||
return data["result"]
|
||||
elif chain == "btc":
|
||||
r = await c.get(f"https://blockchain.info/rawaddr/{address}?limit={limit}")
|
||||
data = r.json()
|
||||
if "txs" in data:
|
||||
return data["txs"]
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch txs for {chain}:{address}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def _fetch_balance(chain: str, address: str) -> float | None:
|
||||
"""Fetch wallet balance. Returns float or None on failure."""
|
||||
try:
|
||||
from routers.balance_fetcher import fetch_balance
|
||||
result = await fetch_balance(chain, address)
|
||||
return result.get("balance_decimal", 0)
|
||||
except Exception as e:
|
||||
logger.debug(f"Balance fetch failed for {chain}:{address}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _analyze_transactions(txs: list[dict]) -> list[dict]:
|
||||
"""Analyze a list of transactions for anomalies."""
|
||||
anomalies: list[dict] = []
|
||||
if not txs:
|
||||
return anomalies
|
||||
|
||||
# Extract values
|
||||
amounts = []
|
||||
timestamps = []
|
||||
unique_addresses: set[str] = set()
|
||||
address_interactions: dict[str, int] = {}
|
||||
|
||||
for tx in txs[:100]:
|
||||
# Parse value
|
||||
value_str = tx.get("value", tx.get("amount", tx.get("lamports", "0")))
|
||||
try:
|
||||
val = int(value_str) if isinstance(value_str, str) else float(value_str)
|
||||
except (ValueError, TypeError):
|
||||
val = 0
|
||||
amounts.append(val)
|
||||
|
||||
# Parse timestamp
|
||||
ts = tx.get("timeStamp", tx.get("blockTime", tx.get("time", 0)))
|
||||
try:
|
||||
timestamps.append(int(ts))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Track interaction addresses
|
||||
for field in ("from", "to", "address"):
|
||||
addr = tx.get(field, "")
|
||||
if addr and len(addr) > 10:
|
||||
unique_addresses.add(addr)
|
||||
address_interactions[addr] = address_interactions.get(addr, 0) + 1
|
||||
|
||||
# Check 1: Unusually large transactions
|
||||
if len(amounts) >= 3:
|
||||
mean_amt = statistics.mean(amounts)
|
||||
stdev_amt = statistics.stdev(amounts) if len(amounts) > 1 else 0
|
||||
for i, amt in enumerate(amounts):
|
||||
if stdev_amt > 0 and amt > mean_amt + 3 * stdev_amt:
|
||||
anomalies.append({
|
||||
"type": "large_transaction",
|
||||
"severity": "high",
|
||||
"description": f"Transaction {i} is {amt:.2f} ({amt / mean_amt:.1f}x the average of {mean_amt:.2f})",
|
||||
"tx": txs[i].get("hash", txs[i].get("signature", ""))[:16],
|
||||
})
|
||||
|
||||
# Check 2: Rapid-fire transactions (>5 in 1 minute)
|
||||
if len(timestamps) >= 5:
|
||||
timestamps.sort()
|
||||
clusters = 0
|
||||
for i in range(len(timestamps) - 4):
|
||||
if timestamps[i + 4] - timestamps[i] <= 60:
|
||||
clusters += 1
|
||||
if clusters > 0:
|
||||
anomalies.append({
|
||||
"type": "rapid_transactions",
|
||||
"severity": "medium",
|
||||
"description": f"Found {clusters} clusters of 5+ transactions within 60 seconds",
|
||||
})
|
||||
|
||||
# Check 3: Many unique first-time interactions
|
||||
if len(unique_addresses) > 20:
|
||||
anomalies.append({
|
||||
"type": "many_counterparties",
|
||||
"severity": "low",
|
||||
"description": f"Wallet interacted with {len(unique_addresses)} unique addresses",
|
||||
})
|
||||
|
||||
# Check 4: High volume of zero-value transactions
|
||||
zero_count = sum(1 for a in amounts if a == 0)
|
||||
if len(amounts) > 0 and zero_count / len(amounts) > 0.5:
|
||||
anomalies.append({
|
||||
"type": "zero_value_txs",
|
||||
"severity": "low",
|
||||
"description": f"{zero_count}/{len(amounts)} transactions have zero value — possible spam or dusting",
|
||||
})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def _is_dust(tx: dict) -> bool:
|
||||
"""Check if a transaction is a dusting attempt."""
|
||||
value_str = tx.get("value", tx.get("amount", tx.get("lamports", "0")))
|
||||
try:
|
||||
val = int(value_str) if isinstance(value_str, str) else float(value_str)
|
||||
return 0 < val < 1000 # Dust: very small amounts
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_dusting(txs: list[dict]) -> bool:
|
||||
"""Check if the wallet was targeted by a dusting attack."""
|
||||
dust_count = sum(1 for tx in txs[-20:] if _is_dust(tx))
|
||||
unique_senders = set()
|
||||
for tx in txs[-20:]:
|
||||
sender = tx.get("from", "")
|
||||
if sender and _is_dust(tx):
|
||||
unique_senders.add(sender)
|
||||
# Dusting: many tiny txs from many different senders
|
||||
return dust_count >= 5 and len(unique_senders) >= 3
|
||||
1188
backend/agent/mcp_server.py
Normal file
1188
backend/agent/mcp_server.py
Normal file
File diff suppressed because it is too large
Load diff
200
backend/agent/orchestrator.py
Normal file
200
backend/agent/orchestrator.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""Agent Orchestrator — Natural language → multi-step wallet operation plans.
|
||||
|
||||
The orchestrator uses the configured AI provider to translate plain English
|
||||
instructions into structured plans. Plans are then executed by the MCP tools.
|
||||
|
||||
This is the "brain" of the AI Wallet Agent. It handles:
|
||||
- Parsing intent from natural language
|
||||
- Decomposing complex ops into step-by-step tool calls
|
||||
- Confirmation/approval workflow
|
||||
- Error recovery and rollback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.providers import get_provider, get_llm_client
|
||||
|
||||
logger = logging.getLogger("wp.agent.orchestrator")
|
||||
|
||||
SYSTEM_PROMPT = """You are the WalletPress AI Wallet Agent. You help users manage their crypto wallets across 55 blockchains.
|
||||
|
||||
You have these tools available to execute wallet operations:
|
||||
- wallet_generate(chain, label, count) — generate wallets
|
||||
- wallet_from_mnemonic(mnemonic, chains) — derive from seed phrase
|
||||
- vault_list(chain, limit) — list wallets
|
||||
- vault_get(wallet_id) — get wallet details + private key
|
||||
- vault_delete(wallet_id) — delete a wallet
|
||||
- vault_stats() — vault statistics
|
||||
- balance_check(chain, address) — check balance
|
||||
- vault_balances(chain) — all wallet balances
|
||||
- wallet_rotate(wallet_id, reason) — rotate keys
|
||||
- wallet_sweep(from_wallet_id, to_address) — sweep funds
|
||||
- chains_list() — list supported chains
|
||||
- validate_address(chain, address) — validate address format
|
||||
- schedule_dca(chain, amount, frequency, to_address, label) — set up DCA
|
||||
- schedule_list() — list scheduled tasks
|
||||
- anomaly_scan(address, chain) — scan for anomalies
|
||||
- anomaly_monitor(wallet_id, webhook_url) — monitor wallet
|
||||
|
||||
Your task: Analyze the user's request and create a step-by-step plan.
|
||||
|
||||
Return ONLY valid JSON with this structure:
|
||||
{
|
||||
"intent": "brief description of what the user wants",
|
||||
"steps": [
|
||||
{
|
||||
"tool": "tool_name",
|
||||
"args": {"arg1": "value1"},
|
||||
"description": "what this step does"
|
||||
}
|
||||
],
|
||||
"warnings": ["any security concerns"],
|
||||
"needs_confirmation": true/false
|
||||
}
|
||||
|
||||
Rules:
|
||||
1. ALWAYS prefer existing wallets over generating new ones
|
||||
2. Flag anything that involves moving funds for confirmation
|
||||
3. If the request is ambiguous, ask clarifying questions in "warnings"
|
||||
4. Keep private keys SAFE — never expose them unnecessarily
|
||||
5. For "show me everything" queries, use vault_list + vault_balances"""
|
||||
|
||||
|
||||
def _call_llm(system: str, user: str) -> str:
|
||||
"""Call the configured LLM and return the response text."""
|
||||
provider = get_provider()
|
||||
client = get_llm_client(provider)
|
||||
model = provider.default_model
|
||||
logger.info(f"Planning via {provider.name} ({model})")
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
temperature=0.1,
|
||||
max_tokens=2000,
|
||||
)
|
||||
return resp.choices[0].message.content or ""
|
||||
except Exception as e:
|
||||
logger.error(f"LLM call failed: {e}")
|
||||
return json.dumps({
|
||||
"intent": "failed to plan",
|
||||
"steps": [],
|
||||
"warnings": [f"AI provider error: {e}. Check your WP_AI_PROVIDER and WP_AI_API_KEY configuration."],
|
||||
"needs_confirmation": False,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
|
||||
def plan_operation(prompt: str, context: str = "") -> dict:
|
||||
"""Take natural language and return a structured operation plan."""
|
||||
user = prompt
|
||||
if context:
|
||||
user = f"Context:\n{context}\n\nRequest:\n{prompt}"
|
||||
raw = _call_llm(SYSTEM_PROMPT, user)
|
||||
|
||||
# Strip markdown fences if present
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
|
||||
if raw.endswith("```"):
|
||||
raw = raw.rsplit("```", 1)[0]
|
||||
raw = raw.strip()
|
||||
|
||||
try:
|
||||
plan = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
# LLM didn't return valid JSON — return as explanation
|
||||
return {
|
||||
"intent": "raw response",
|
||||
"steps": [],
|
||||
"explanation": raw,
|
||||
"warnings": ["Could not parse structured plan. Raw response shown above."],
|
||||
"needs_confirmation": False,
|
||||
}
|
||||
return plan
|
||||
|
||||
|
||||
def execute_plan(plan: list[dict] | str, confirm: bool = False) -> dict:
|
||||
"""Execute a plan by calling MCP tools sequentially.
|
||||
|
||||
Args:
|
||||
plan: List of steps or JSON string
|
||||
confirm: If True, requires confirmation
|
||||
"""
|
||||
import ast
|
||||
|
||||
if isinstance(plan, str):
|
||||
try:
|
||||
plan_data = json.loads(plan)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
plan_data = ast.literal_eval(plan) if isinstance(plan, str) and plan.strip().startswith("[") else {"steps": []}
|
||||
if isinstance(plan_data, dict):
|
||||
plan = plan_data.get("steps", [])
|
||||
else:
|
||||
plan = plan_data
|
||||
|
||||
if not plan:
|
||||
return {"error": "No steps in plan", "results": []}
|
||||
|
||||
if confirm:
|
||||
return {
|
||||
"status": "awaiting_confirmation",
|
||||
"steps": [s.get("description", s.get("tool", "unknown")) for s in plan],
|
||||
"message": "Confirm execution via agent_execute(plan=<same_plan>, confirm=False)",
|
||||
}
|
||||
|
||||
results = []
|
||||
for i, step in enumerate(plan):
|
||||
tool_name = step.get("tool", "")
|
||||
args = step.get("args", {})
|
||||
description = step.get("description", tool_name)
|
||||
logger.info(f"Step {i + 1}/{len(plan)}: {description}")
|
||||
try:
|
||||
result = _call_tool(tool_name, args)
|
||||
results.append({"step": i, "tool": tool_name, "result": result, "success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Step {i + 1} failed: {e}")
|
||||
results.append({"step": i, "tool": tool_name, "error": str(e), "success": False})
|
||||
return {"status": "failed", "completed": i, "total": len(plan), "results": results, "error": f"Step '{description}' failed: {e}"}
|
||||
|
||||
return {"status": "completed", "total": len(plan), "results": results}
|
||||
|
||||
|
||||
def _call_tool(tool_name: str, args: dict) -> Any:
|
||||
"""Call an MCP tool function by name."""
|
||||
import importlib
|
||||
|
||||
# Map tool names to their module functions
|
||||
tool_map = {
|
||||
"wallet_generate": ("agent.mcp_server", "wallet_generate"),
|
||||
"wallet_from_mnemonic": ("agent.mcp_server", "wallet_from_mnemonic"),
|
||||
"vault_list": ("agent.mcp_server", "vault_list"),
|
||||
"vault_get": ("agent.mcp_server", "vault_get"),
|
||||
"vault_delete": ("agent.mcp_server", "vault_delete"),
|
||||
"vault_stats": ("agent.mcp_server", "vault_stats"),
|
||||
"balance_check": ("agent.mcp_server", "balance_check"),
|
||||
"vault_balances": ("agent.mcp_server", "vault_balances"),
|
||||
"wallet_rotate": ("agent.mcp_server", "wallet_rotate"),
|
||||
"wallet_sweep": ("agent.mcp_server", "wallet_sweep"),
|
||||
"chains_list": ("agent.mcp_server", "chains_list"),
|
||||
"validate_address": ("agent.mcp_server", "validate_address"),
|
||||
"schedule_dca": ("agent.mcp_server", "schedule_dca"),
|
||||
"schedule_list": ("agent.mcp_server", "schedule_list"),
|
||||
"anomaly_scan": ("agent.mcp_server", "anomaly_scan"),
|
||||
"anomaly_monitor": ("agent.mcp_server", "anomaly_monitor"),
|
||||
}
|
||||
|
||||
mod_path, fn_name = tool_map.get(tool_name, (None, None))
|
||||
if not mod_path:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
mod = importlib.import_module(mod_path)
|
||||
fn = getattr(mod, fn_name)
|
||||
return fn(**args)
|
||||
431
backend/agent/providers.py
Normal file
431
backend/agent/providers.py
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
"""AI Provider Configuration — 20 verified providers, BYOK, custom endpoints, auto-detect.
|
||||
|
||||
Every provider is verified against their official documentation to work
|
||||
with the OpenAI Python SDK's standard interface:
|
||||
|
||||
client = OpenAI(api_key=key, base_url=url)
|
||||
client.chat.completions.create(model=model, messages=[...])
|
||||
|
||||
The SDK adds /chat/completions to base_url automatically. Each config
|
||||
below has a base_url such that {base_url}/chat/completions is the
|
||||
correct chat endpoint for that provider.
|
||||
|
||||
Prints a table of available providers on startup so users always know
|
||||
what's available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIProvider:
|
||||
name: str
|
||||
base_url: str
|
||||
api_key: str = ""
|
||||
default_model: str = ""
|
||||
env_key: str = ""
|
||||
env_base_url: str = ""
|
||||
env_model: str = ""
|
||||
docs_url: str = ""
|
||||
key_prefix: str = ""
|
||||
notes: str = ""
|
||||
supports_streaming: bool = True
|
||||
|
||||
def resolve(self) -> AIProvider:
|
||||
key = os.getenv(self.env_key, self.api_key) if self.env_key else self.api_key
|
||||
base = os.getenv(self.env_base_url, self.base_url) if self.env_base_url else self.base_url
|
||||
model = os.getenv(self.env_model, self.default_model) if self.env_model else self.default_model
|
||||
return AIProvider(
|
||||
name=self.name,
|
||||
base_url=base.rstrip("/"),
|
||||
api_key=key,
|
||||
default_model=model,
|
||||
notes=self.notes,
|
||||
docs_url=self.docs_url,
|
||||
supports_streaming=self.supports_streaming,
|
||||
)
|
||||
|
||||
|
||||
def _ollama_auto_detect() -> Optional[AIProvider]:
|
||||
"""Auto-detect a running Ollama instance on localhost or common ports."""
|
||||
import socket
|
||||
for port in [11434, 11435]:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.5)
|
||||
result = sock.connect_ex(("127.0.0.1", port))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
base = os.getenv("WP_AI_OLLAMA_URL", f"http://127.0.0.1:{port}/v1")
|
||||
model = os.getenv("WP_AI_OLLAMA_MODEL", "")
|
||||
return AIProvider(
|
||||
name=f"Ollama (detected on :{port})",
|
||||
base_url=base.rstrip("/"),
|
||||
api_key=os.getenv("WP_AI_OLLAMA_KEY", "ollama"),
|
||||
default_model=model or "",
|
||||
notes="Auto-detected! Chat endpoint: /chat/completions",
|
||||
supports_streaming=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
PROVIDERS: dict[str, AIProvider] = {
|
||||
# ── BIG THREE ───────────────────────────────────────────
|
||||
"openai": AIProvider(
|
||||
name="OpenAI",
|
||||
base_url="https://api.openai.com/v1",
|
||||
default_model="gpt-4o",
|
||||
env_key="WP_AI_OPENAI_KEY",
|
||||
docs_url="https://platform.openai.com/api-keys",
|
||||
key_prefix="sk-",
|
||||
notes="Industry standard. gpt-4o, o3, o4-mini. Requires paid API key.",
|
||||
),
|
||||
"anthropic": AIProvider(
|
||||
name="Anthropic (OpenAI Relay)",
|
||||
base_url="https://api.anthropic.com/v1/openai",
|
||||
default_model="claude-sonnet-4-20250514",
|
||||
env_key="WP_AI_ANTHROPIC_KEY",
|
||||
docs_url="https://console.anthropic.com/",
|
||||
key_prefix="sk-ant-",
|
||||
notes="Uses Anthropic's OpenAI-compatible relay at /v1/openai. Models: claude-sonnet-4, claude-haiku-3-5.",
|
||||
),
|
||||
"google": AIProvider(
|
||||
name="Google Gemini (OpenAI Relay)",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
default_model="gemini-2.5-flash",
|
||||
env_key="WP_AI_GEMINI_KEY",
|
||||
docs_url="https://aistudio.google.com/apikey",
|
||||
key_prefix="AIza",
|
||||
notes="Google's OpenAI-compatible endpoint. Free tier available. 1M context. Models: gemini-2.5-flash, gemini-2.5-pro.",
|
||||
),
|
||||
"xai": AIProvider(
|
||||
name="xAI (Grok)",
|
||||
base_url="https://api.x.ai/v1",
|
||||
default_model="grok-2-1212",
|
||||
env_key="WP_AI_XAI_KEY",
|
||||
docs_url="https://console.x.ai/",
|
||||
key_prefix="",
|
||||
notes="Grok models. OpenAI compatible. grok-2, grok-3 available. Requires paid key.",
|
||||
),
|
||||
"mistral": AIProvider(
|
||||
name="Mistral AI",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
default_model="mistral-large-latest",
|
||||
env_key="WP_AI_MISTRAL_KEY",
|
||||
docs_url="https://console.mistral.ai/api-keys/",
|
||||
key_prefix="",
|
||||
notes="European provider. Models: mistral-large-latest, mistral-small-latest, pixtral. Strong multilingual.",
|
||||
),
|
||||
|
||||
# ── GATEWAYS / AGGREGATORS ──────────────────────────────
|
||||
"openrouter": AIProvider(
|
||||
name="OpenRouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
default_model="openai/gpt-4o",
|
||||
env_key="WP_AI_OPENROUTER_KEY",
|
||||
docs_url="https://openrouter.ai/keys",
|
||||
key_prefix="sk-or-v1-",
|
||||
notes="300+ models from every provider. Many free models. Set model name as provider/model (e.g. openai/gpt-4o).",
|
||||
),
|
||||
|
||||
# ── FAST INFERENCE ──────────────────────────────────────
|
||||
"groq": AIProvider(
|
||||
name="Groq",
|
||||
base_url="https://api.groq.com/openai/v1",
|
||||
default_model="llama-3.3-70b-versatile",
|
||||
env_key="WP_AI_GROQ_KEY",
|
||||
docs_url="https://console.groq.com/keys",
|
||||
key_prefix="gsk_",
|
||||
notes="Fastest inference. Free tier: 30 req/min. Models: llama-3.3-70b, deepseek-r1, mixtral. No logprobs.",
|
||||
),
|
||||
"cerebras": AIProvider(
|
||||
name="Cerebras",
|
||||
base_url="https://api.cerebras.ai/v1",
|
||||
default_model="llama-3.3-70b",
|
||||
env_key="WP_AI_CEREBRAS_KEY",
|
||||
docs_url="https://inference.cerebras.ai/",
|
||||
key_prefix="",
|
||||
notes="Fastest inference (~1800 tok/s). Limited free tier. Models: llama-3.3-70b, llama-4-scout.",
|
||||
),
|
||||
|
||||
# ── OPEN-SOURCE FOCUSED ─────────────────────────────────
|
||||
"deepseek": AIProvider(
|
||||
name="DeepSeek",
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
default_model="deepseek-chat",
|
||||
env_key="WP_AI_DEEPSEEK_KEY",
|
||||
docs_url="https://platform.deepseek.com/api_keys",
|
||||
key_prefix="sk-",
|
||||
notes="Excellent coding. ~$0.14/M tokens. 1M context. Models: deepseek-chat (V3), deepseek-reasoner (R1).",
|
||||
),
|
||||
"together": AIProvider(
|
||||
name="Together AI",
|
||||
base_url="https://api.together.xyz/v1",
|
||||
default_model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
env_key="WP_AI_TOGETHER_KEY",
|
||||
docs_url="https://api.together.xyz/settings/api-keys",
|
||||
key_prefix="",
|
||||
notes="Broad open-source selection. Llama, DeepSeek, Qwen, Mixtral. Good free credits to start.",
|
||||
),
|
||||
"fireworks": AIProvider(
|
||||
name="Fireworks AI",
|
||||
base_url="https://api.fireworks.ai/inference/v1",
|
||||
default_model="accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
env_key="WP_AI_FIREWORKS_KEY",
|
||||
docs_url="https://fireworks.ai/api-keys",
|
||||
key_prefix="",
|
||||
notes="Fast open-source inference. Models prefixed with accounts/fireworks/models/. Free tier.",
|
||||
),
|
||||
"deepinfra": AIProvider(
|
||||
name="DeepInfra",
|
||||
base_url="https://api.deepinfra.com/v1/openai",
|
||||
default_model="meta-llama/Meta-Llama-3.1-70B-Instruct",
|
||||
env_key="WP_AI_DEEPINFRA_KEY",
|
||||
docs_url="https://deepinfra.com/dash/api_keys",
|
||||
key_prefix="",
|
||||
notes="Very broad model selection. Llama, Qwen, DeepSeek, Mixtral, Phi. Serverless pricing.",
|
||||
),
|
||||
|
||||
# ── SEARCH-AUGMENTED ────────────────────────────────────
|
||||
"perplexity": AIProvider(
|
||||
name="Perplexity",
|
||||
base_url="https://api.perplexity.ai",
|
||||
default_model="sonar-pro",
|
||||
env_key="WP_AI_PERPLEXITY_KEY",
|
||||
docs_url="https://docs.perplexity.ai/",
|
||||
key_prefix="pplx-",
|
||||
notes="Web-search augmented models. NO /v1 in base_url. Models: sonar-pro, sonar-deep-research. Paid API.",
|
||||
),
|
||||
|
||||
# ── ADDITIONAL PROVIDERS ─────────────────────────────────
|
||||
"cohere": AIProvider(
|
||||
name="Cohere",
|
||||
base_url="https://api.cohere.ai/v1",
|
||||
default_model="command-r-plus",
|
||||
env_key="WP_AI_COHERE_KEY",
|
||||
docs_url="https://dashboard.cohere.com/api-keys",
|
||||
key_prefix="",
|
||||
notes="Enterprise-focused. Strong RAG. Models: command-r-plus, command-r. OpenAI-compatible chat endpoint.",
|
||||
),
|
||||
"replicate": AIProvider(
|
||||
name="Replicate",
|
||||
base_url="https://api.replicate.com/v1",
|
||||
default_model="meta/meta-llama-3-70b-instruct",
|
||||
env_key="WP_AI_REPLICATE_KEY",
|
||||
docs_url="https://replicate.com/account/api-tokens",
|
||||
key_prefix="r8_",
|
||||
notes="Run open models on demand. Pay-per-second. Models: meta/meta-llama-3-70b-instruct, stability-ai/stable-diffusion.",
|
||||
),
|
||||
"hyperbolic": AIProvider(
|
||||
name="Hyperbolic",
|
||||
base_url="https://api.hyperbolic.xyz/v1",
|
||||
default_model="meta-llama/Meta-Llama-3.1-70B-Instruct",
|
||||
env_key="WP_AI_HYPERBOLIC_KEY",
|
||||
docs_url="https://app.hyperbolic.xyz/settings",
|
||||
key_prefix="",
|
||||
notes="GPU cloud + inference. OpenAI compatible. Models: Llama, Qwen, DeepSeek. Free trial credits.",
|
||||
),
|
||||
|
||||
# ── CLOUD PROVIDERS ──────────────────────────────────────
|
||||
"github": AIProvider(
|
||||
name="GitHub Models",
|
||||
base_url="https://models.inference.ai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
env_key="WP_AI_GITHUB_KEY",
|
||||
docs_url="https://github.com/settings/tokens",
|
||||
key_prefix="ghp_",
|
||||
notes="FREE with GitHub Copilot. Use your GitHub PAT. Models: gpt-4o, gpt-4o-mini, DeepSeek-R1, Phi-4. Rate limited.",
|
||||
),
|
||||
"cloudflare": AIProvider(
|
||||
name="Cloudflare Workers AI",
|
||||
base_url="https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1",
|
||||
default_model="@cf/meta/llama-3.3-70b-instruct",
|
||||
env_key="WP_AI_CLOUDFLARE_KEY",
|
||||
env_base_url="WP_AI_CLOUDFLARE_URL",
|
||||
docs_url="https://developers.cloudflare.com/workers-ai/",
|
||||
key_prefix="",
|
||||
notes="Runs on Cloudflare's edge network. Set WP_AI_CLOUDFLARE_URL with your account_id baked in. Models: @cf/meta/llama-*.",
|
||||
),
|
||||
|
||||
# ── LOCAL ────────────────────────────────────────────────
|
||||
"ollama": AIProvider(
|
||||
name="Ollama (Local)",
|
||||
base_url="http://localhost:11434/v1",
|
||||
default_model="llama3.2",
|
||||
env_key="WP_AI_OLLAMA_KEY",
|
||||
env_base_url="WP_AI_OLLAMA_URL",
|
||||
env_model="WP_AI_OLLAMA_MODEL",
|
||||
docs_url="https://ollama.ai/",
|
||||
key_prefix="",
|
||||
notes="FULLY LOCAL. No API key needed. Run any open model. Set WP_AI_OLLAMA_URL for remote instances.",
|
||||
),
|
||||
"custom": AIProvider(
|
||||
name="Custom Endpoint",
|
||||
base_url="https://your-endpoint.com/v1",
|
||||
default_model="your-model-name",
|
||||
env_key="WP_AI_CUSTOM_KEY",
|
||||
env_base_url="WP_AI_CUSTOM_BASE_URL",
|
||||
env_model="WP_AI_CUSTOM_MODEL",
|
||||
docs_url="",
|
||||
key_prefix="",
|
||||
notes="Any OpenAI-compatible endpoint. Set: WP_AI_CUSTOM_BASE_URL, WP_AI_CUSTOM_KEY, WP_AI_CUSTOM_MODEL.",
|
||||
),
|
||||
}
|
||||
|
||||
# Sort: local first, then alphabetically
|
||||
SORT_ORDER = {
|
||||
"ollama": "00_ollama",
|
||||
"custom": "01_custom",
|
||||
}
|
||||
|
||||
|
||||
def _get_provider(name: str | None = None) -> AIProvider:
|
||||
"""Get the configured AI provider with auto-detection and fallbacks.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit name argument (e.g. get_provider('groq'))
|
||||
2. WP_AI_PROVIDER env var
|
||||
3. WP_AI_API_KEY + WP_AI_BASE_URL (legacy env vars) → Custom
|
||||
4. Auto-detect running Ollama instance on localhost:11434
|
||||
5. OpenRouter (best free model availability)
|
||||
"""
|
||||
name = name or os.getenv("WP_AI_PROVIDER", "").lower().strip()
|
||||
|
||||
# Explicit name
|
||||
if name:
|
||||
provider = PROVIDERS.get(name)
|
||||
if not provider:
|
||||
available = ", ".join(sorted(PROVIDERS.keys()))
|
||||
raise ValueError(
|
||||
f"Unknown provider '{name}'. Available: {available}\n"
|
||||
f"Set WP_AI_PROVIDER to one of the above, or WP_AI_API_KEY + WP_AI_BASE_URL for custom."
|
||||
)
|
||||
resolved = provider.resolve()
|
||||
|
||||
# Only raise on missing key if the provider actually requires keys
|
||||
key_required = resolved.env_key and not resolved.api_key
|
||||
custom_key = os.getenv("WP_AI_API_KEY", "")
|
||||
if key_required and not custom_key:
|
||||
# Don't raise — just resolve with empty key. The LLM call will
|
||||
# fail with a clear auth error when actually used.
|
||||
pass
|
||||
|
||||
# If custom_key is set but provider-specific key is not, use custom_key
|
||||
if not resolved.api_key and custom_key:
|
||||
resolved.api_key = custom_key
|
||||
|
||||
return resolved
|
||||
|
||||
# Legacy env vars
|
||||
api_key = os.getenv("WP_AI_API_KEY", "")
|
||||
base_url = os.getenv("WP_AI_BASE_URL", "")
|
||||
if api_key and base_url:
|
||||
return AIProvider(
|
||||
name="Custom (Legacy)",
|
||||
base_url=base_url.rstrip("/"),
|
||||
api_key=api_key,
|
||||
default_model=os.getenv("WP_AI_MODEL", ""),
|
||||
notes="Configured via WP_AI_API_KEY + WP_AI_BASE_URL.",
|
||||
).resolve()
|
||||
|
||||
# Auto-detect Ollama
|
||||
ollama = _ollama_auto_detect()
|
||||
if ollama:
|
||||
return ollama
|
||||
|
||||
# Fallback: OpenRouter (best free tier)
|
||||
return PROVIDERS["openrouter"].resolve()
|
||||
|
||||
|
||||
def get_provider(name: str | None = None) -> AIProvider:
|
||||
"""Get provider with friendly error wrapping."""
|
||||
try:
|
||||
return _get_provider(name)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to configure AI provider: {e}")
|
||||
|
||||
|
||||
def test_connection(provider: AIProvider) -> dict:
|
||||
"""Test the provider connection by listing available models.
|
||||
|
||||
Returns dict with success status, model count, and error message.
|
||||
This is called when the user first configures a provider to validate
|
||||
that the endpoint, key, and model are all correct.
|
||||
"""
|
||||
from openai import OpenAI, APIError, AuthenticationError, NotFoundError, RateLimitError
|
||||
try:
|
||||
client = OpenAI(api_key=provider.api_key, base_url=provider.base_url)
|
||||
models = client.models.list()
|
||||
model_list = [m.id for m in models][:20]
|
||||
return {
|
||||
"connected": True,
|
||||
"provider": provider.name,
|
||||
"base_url": provider.base_url,
|
||||
"models_available": len(model_list),
|
||||
"model_samples": model_list[:5],
|
||||
}
|
||||
except AuthenticationError as e:
|
||||
return {
|
||||
"connected": False,
|
||||
"error": f"Authentication failed. Check your API key.\n Provider: {provider.name}\n URL: {provider.base_url}\n Key prefix: {provider.api_key[:12] if provider.api_key else '(empty)'}...\n Detail: {e}",
|
||||
}
|
||||
except NotFoundError:
|
||||
return {
|
||||
"connected": False,
|
||||
"error": f"Endpoint not found. Check base_url.\n URL: {provider.base_url}\n Some providers don't support the /models endpoint but still work for chat.",
|
||||
}
|
||||
except RateLimitError as e:
|
||||
return {
|
||||
"connected": True,
|
||||
"warning": f"Connected but rate limited: {e}",
|
||||
"provider": provider.name,
|
||||
}
|
||||
except APIError as e:
|
||||
return {
|
||||
"connected": True,
|
||||
"warning": f"Connected (API responded, status={e.status_code}): {e}",
|
||||
"provider": provider.name,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
"error": f"Connection failed: {type(e).__name__}: {e}",
|
||||
}
|
||||
|
||||
|
||||
def list_providers() -> str:
|
||||
"""Return a formatted table of all available providers."""
|
||||
lines = []
|
||||
lines.append("─" * 78)
|
||||
lines.append(f" Available AI Providers ({len(PROVIDERS)})")
|
||||
lines.append("─" * 78)
|
||||
lines.append(f" {'Name':<14} {'Default Model':<30} {'Key Env Var':<22}")
|
||||
lines.append("─" * 78)
|
||||
for pname in sorted(PROVIDERS.keys()):
|
||||
p = PROVIDERS[pname]
|
||||
key_var = p.env_key or "(none)"
|
||||
model = p.default_model[:28] if p.default_model else "(auto)"
|
||||
lines.append(f" {pname:<14} {model:<30} {key_var:<22}")
|
||||
lines.append("─" * 78)
|
||||
lines.append(" Set WP_AI_PROVIDER=<name> and the corresponding key env var.")
|
||||
lines.append(" Or just WP_AI_API_KEY + WP_AI_BASE_URL for quick custom setup.")
|
||||
lines.append(" Ollama is auto-detected if running on localhost:11434.")
|
||||
lines.append("─" * 78)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_llm_client(provider: AIProvider):
|
||||
"""Create an OpenAI-compatible client for the given provider."""
|
||||
from openai import OpenAI
|
||||
return OpenAI(api_key=provider.api_key, base_url=provider.base_url)
|
||||
|
||||
|
||||
def get_embedding_client(provider: AIProvider):
|
||||
"""Create an OpenAI-compatible client for embeddings."""
|
||||
from openai import OpenAI
|
||||
return OpenAI(api_key=provider.api_key, base_url=provider.base_url)
|
||||
301
backend/agent/scheduler.py
Normal file
301
backend/agent/scheduler.py
Normal 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
36
backend/alembic.ini
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = sqlite:///./data/walletpress.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
56
backend/alembic/env.py
Normal file
56
backend/alembic/env.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Alembic migration environment for WalletPress.
|
||||
|
||||
The app uses raw sqlite3 (not SQLAlchemy). Migrations use raw SQL
|
||||
via op.execute(). This keeps the app free of SQLAlchemy overhead
|
||||
while still benefiting from Alembic's migration management.
|
||||
|
||||
The sqlalchemy.url in alembic.ini is the fallback. This env.py
|
||||
resolves cfg.db_path at runtime so migrations hit the same DB
|
||||
the app uses (respecting WP_DATA_DIR).
|
||||
|
||||
Usage:
|
||||
alembic revision --autogenerate -m "description"
|
||||
alembic upgrade head
|
||||
alembic downgrade -1
|
||||
"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import create_engine, pool
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Resolve the actual database path from app config at runtime.
|
||||
# Fall back to alembic.ini value if import fails (offline/CI).
|
||||
try:
|
||||
from core.config import cfg
|
||||
db_url = f"sqlite:///{cfg.db_path}"
|
||||
except Exception:
|
||||
db_url = config.get_main_option("sqlalchemy.url")
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(url=db_url, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
# Ensure the parent directory exists (SQLAlchemy won't create it)
|
||||
db_file = db_url.replace("sqlite:///", "")
|
||||
Path(db_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
connectable = create_engine(db_url, poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
68
backend/alembic/versions/001_initial_schema.py
Normal file
68
backend/alembic/versions/001_initial_schema.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Initial vault schema — wallets, rotations, FTS, version tracking.
|
||||
|
||||
Revises: None (first migration)
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "001_initial_schema"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
chain TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
label TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '[]',
|
||||
wallet_group TEXT DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
encrypted_key TEXT DEFAULT '',
|
||||
public_key TEXT DEFAULT '',
|
||||
derivation_path TEXT DEFAULT '',
|
||||
hd_path TEXT DEFAULT '',
|
||||
mnemonic_encrypted TEXT DEFAULT '',
|
||||
encrypted INTEGER DEFAULT 0,
|
||||
balance_usd REAL DEFAULT 0.0,
|
||||
notes TEXT DEFAULT '',
|
||||
updated_at REAL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_wallets_chain ON wallets(chain)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address)")
|
||||
op.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS wallets_fts USING fts5(
|
||||
id, chain, address, label, notes,
|
||||
content='wallets',
|
||||
content_rowid='rowid'
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet_id TEXT NOT NULL,
|
||||
new_address TEXT NOT NULL,
|
||||
rotated_at REAL NOT NULL,
|
||||
reason TEXT DEFAULT '',
|
||||
FOREIGN KEY(wallet_id) REFERENCES wallets(id)
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS _schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS rotations")
|
||||
op.execute("DROP TABLE IF EXISTS wallets_fts")
|
||||
op.execute("DROP TABLE IF EXISTS wallets")
|
||||
op.execute("DROP TABLE IF EXISTS _schema_version")
|
||||
1
backend/build.hash
Normal file
1
backend/build.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
c8c59b19976c4e63fdddbbf9e92ccc4c64743b2573d734ac9c52190bf8b526fb
|
||||
169
backend/cli/verify_receipt.py
Normal file
169
backend/cli/verify_receipt.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env python3
|
||||
"""WalletPress Receipt Verifier — CLI tool to verify x402 order receipts.
|
||||
|
||||
Usage:
|
||||
# Verify an order receipt
|
||||
python3 verify_receipt.py --order-id x402_abc123 \\
|
||||
--signature "base64signature..." \\
|
||||
--pubkey "ed25519pubkeyhex"
|
||||
|
||||
# Verify a key deletion attestation
|
||||
python3 verify_receipt.py --order-id x402_abc123 \\
|
||||
--deletion-sig "base64signature..." \\
|
||||
--pubkey "ed25519pubkeyhex" \\
|
||||
--addresses addr1,addr2,addr3
|
||||
|
||||
# Fetch and verify from a running WalletPress instance
|
||||
python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010
|
||||
|
||||
Trust: This tool is open source. It does NOT phone home. It does NOT
|
||||
send your keys anywhere. It only verifies cryptographic signatures.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
httpx = None # type: ignore
|
||||
|
||||
try:
|
||||
from nacl.bindings import crypto_sign_open
|
||||
except ImportError:
|
||||
crypto_sign_open = None # type: ignore
|
||||
|
||||
|
||||
def verify_receipt(order_id: str, chain: str, count: int, total_usd: float,
|
||||
timestamp: float, signature: str, pubkey_hex: str) -> bool:
|
||||
"""Verify an Ed25519-signed order receipt."""
|
||||
if crypto_sign_open is None:
|
||||
print("ERROR: pynacl is required. Install: pip install pynacl")
|
||||
return False
|
||||
message = f"walletpress:receipt:{order_id}:{chain}:{count}:{total_usd}:{timestamp}".encode()
|
||||
import base64
|
||||
sig_bytes = base64.b64decode(signature)
|
||||
try:
|
||||
crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def verify_key_deletion(order_id: str, chain: str, count: int,
|
||||
addresses: list[str], signature: str, pubkey_hex: str) -> bool:
|
||||
"""Verify a key deletion attestation — proves keys were wiped."""
|
||||
if crypto_sign_open is None:
|
||||
print("ERROR: pynacl is required. Install: pip install pynacl")
|
||||
return False
|
||||
addr_hash = hashlib.sha256("".join(sorted(addresses)).encode()).hexdigest()[:16]
|
||||
message = f"walletpress:key-deletion:{order_id}:{chain}:{count}:{addr_hash}:{int(timestamp)}".encode()
|
||||
import base64
|
||||
sig_bytes = base64.b64decode(signature)
|
||||
try:
|
||||
crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def fetch_and_verify(server_url: str, order_id: str) -> dict[str, Any]:
|
||||
"""Fetch order details from a WalletPress server and verify the receipt."""
|
||||
if httpx is None:
|
||||
return {"error": "httpx is required. Install: pip install httpx"}
|
||||
try:
|
||||
resp = httpx.get(f"{server_url}/api/v1/marketplace/verify-receipt", params={
|
||||
"order_id": order_id,
|
||||
}, timeout=10)
|
||||
if resp.status_code == 404:
|
||||
return {"error": f"Order {order_id} not found on {server_url}"}
|
||||
data = resp.json()
|
||||
return data
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"Failed to connect to {server_url}: {e}"}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="WalletPress Receipt Verifier — prove your order was fulfilled",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Verify a receipt
|
||||
python3 verify_receipt.py --order-id x402_abc123 --signature "sig" --pubkey "key"
|
||||
|
||||
# Verify key deletion
|
||||
python3 verify_receipt.py --order-id x402_abc123 --deletion-sig "sig" --pubkey "key" --addresses addr1,addr2
|
||||
|
||||
# Fetch and verify from server
|
||||
python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--order-id", required=True, help="Order ID from the generate response")
|
||||
parser.add_argument("--signature", help="Receipt signature (base64)")
|
||||
parser.add_argument("--deletion-sig", help="Key deletion attestation signature (base64)")
|
||||
parser.add_argument("--pubkey", help="Server's Ed25519 public key (hex)")
|
||||
parser.add_argument("--addresses", help="Comma-separated wallet addresses (for deletion verification)")
|
||||
parser.add_argument("--server", help="WalletPress server URL to fetch order details from")
|
||||
parser.add_argument("--chain", default="sol", help="Chain the wallets were generated on")
|
||||
parser.add_argument("--count", type=int, default=1, help="Number of wallets generated")
|
||||
parser.add_argument("--total-usd", type=float, default=0, help="Total amount paid in USD")
|
||||
parser.add_argument("--timestamp", type=float, default=0, help="Order timestamp (Unix)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.server:
|
||||
result = fetch_and_verify(args.server, args.order_id)
|
||||
if "error" in result:
|
||||
print(f"FAIL: {result['error']}")
|
||||
sys.exit(1)
|
||||
print(f"Order: {result.get('order_id', '?')}")
|
||||
print(f"Chain: {result.get('chain', '?')}")
|
||||
print(f"Count: {result.get('count', '?')}")
|
||||
print(f"Amount: ${result.get('amount_usd', '?')}")
|
||||
print(f"Valid: {result.get('valid', '?')}")
|
||||
print(f"Status: {'VERIFIED' if result.get('valid') else 'INVALID'}")
|
||||
sys.exit(0 if result.get('valid') else 1)
|
||||
|
||||
if args.signature and args.pubkey:
|
||||
if not args.timestamp:
|
||||
print("ERROR: --timestamp is required for receipt verification")
|
||||
sys.exit(1)
|
||||
valid = verify_receipt(
|
||||
args.order_id, args.chain, args.count, args.total_usd,
|
||||
args.timestamp, args.signature, args.pubkey,
|
||||
)
|
||||
print(f"Receipt: {'VALID' if valid else 'INVALID'}")
|
||||
print(f" Order: {args.order_id}")
|
||||
print(f" Chain: {args.chain}")
|
||||
print(f" Count: {args.count}")
|
||||
print(f" Amount: ${args.total_usd}")
|
||||
print(" Algorithm: Ed25519")
|
||||
if not valid:
|
||||
sys.exit(1)
|
||||
|
||||
if args.deletion_sig and args.pubkey and args.addresses:
|
||||
addresses = [a.strip() for a in args.addresses.split(",")]
|
||||
valid = verify_key_deletion(
|
||||
args.order_id, args.chain, args.count, addresses,
|
||||
args.deletion_sig, args.pubkey,
|
||||
)
|
||||
print(f"Key Deletion: {'VERIFIED' if valid else 'FAILED'}")
|
||||
print(f" Order: {args.order_id}")
|
||||
print(f" Addresses: {len(addresses)} wallets")
|
||||
print(" Algorithm: Ed25519")
|
||||
if not valid:
|
||||
print(" WARNING: Key deletion attestation is INVALID. The server may have kept your keys.")
|
||||
sys.exit(1)
|
||||
print(" Keys were generated in memory and wiped. We did not store them.")
|
||||
|
||||
if not args.signature and not args.deletion_sig and not args.server:
|
||||
print("ERROR: Provide --signature, --deletion-sig, or --server")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
160
backend/client_sdk.py
Normal file
160
backend/client_sdk.py
Normal 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
0
backend/core/__init__.py
Normal file
741
backend/core/agent_safety.py
Normal file
741
backend/core/agent_safety.py
Normal 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
111
backend/core/arweave.py
Normal 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
101
backend/core/audit.py
Normal 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
225
backend/core/auth.py
Normal 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
167
backend/core/config.py
Normal 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
133
backend/core/db_pool.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Shared SQLite connection pool — WAL mode, thread-safe, connection reuse.
|
||||
|
||||
Every module that needs SQLite should use this instead of creating
|
||||
ad-hoc connections. Fixes the "database is locked" errors from
|
||||
connection-per-call patterns across agent_safety, smart_wallet,
|
||||
scheduler, and x402 modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class _PooledConn:
|
||||
__slots__ = ("conn", "last_used", "in_use")
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection):
|
||||
self.conn = conn
|
||||
self.last_used = time.monotonic()
|
||||
self.in_use = False
|
||||
|
||||
|
||||
class DbPool:
|
||||
"""Thread-safe SQLite connection pool with WAL mode and LRU eviction.
|
||||
|
||||
Usage:
|
||||
pool = DbPool("/data/myapp.db", max_size=8)
|
||||
with pool.connect() as conn:
|
||||
conn.execute("SELECT 1")
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str, max_size: int = 8, busy_timeout: int = 5000):
|
||||
self._path = Path(db_path)
|
||||
self._max_size = max_size
|
||||
self._busy_timeout = busy_timeout
|
||||
self._lock = threading.Lock()
|
||||
self._pool: OrderedDict[str, _PooledConn] = OrderedDict()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _new_conn(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self._path), timeout=self._busy_timeout / 1000, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(f"PRAGMA busy_timeout={self._busy_timeout}")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._path
|
||||
|
||||
def connect(self):
|
||||
"""Context manager that returns a pooled connection."""
|
||||
return _DbConnection(self)
|
||||
|
||||
def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
|
||||
with self.connect() as conn:
|
||||
cur = conn.execute(sql, params)
|
||||
conn.commit()
|
||||
return cur
|
||||
|
||||
def executescript(self, sql: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.executescript(sql)
|
||||
|
||||
def _acquire(self) -> sqlite3.Connection:
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
for key in list(self._pool.keys()):
|
||||
pc = self._pool[key]
|
||||
if not pc.in_use:
|
||||
pc.in_use = True
|
||||
pc.last_used = now
|
||||
self._pool.move_to_end(key)
|
||||
return pc.conn
|
||||
|
||||
if len(self._pool) < self._max_size:
|
||||
key = f"conn_{len(self._pool)}"
|
||||
conn = self._new_conn()
|
||||
pc = _PooledConn(conn)
|
||||
pc.in_use = True
|
||||
self._pool[key] = pc
|
||||
return conn
|
||||
|
||||
oldest_key = next(iter(self._pool.keys()))
|
||||
pc = self._pool[oldest_key]
|
||||
try:
|
||||
pc.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = self._new_conn()
|
||||
pc.conn = conn
|
||||
pc.in_use = True
|
||||
pc.last_used = now
|
||||
self._pool.move_to_end(oldest_key)
|
||||
return conn
|
||||
|
||||
def _release(self, conn: sqlite3.Connection) -> None:
|
||||
with self._lock:
|
||||
for pc in self._pool.values():
|
||||
if pc.conn is conn:
|
||||
pc.in_use = False
|
||||
pc.last_used = time.monotonic()
|
||||
return
|
||||
|
||||
def close_all(self) -> None:
|
||||
with self._lock:
|
||||
for pc in self._pool.values():
|
||||
try:
|
||||
pc.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._pool.clear()
|
||||
|
||||
|
||||
class _DbConnection:
|
||||
def __init__(self, pool: DbPool):
|
||||
self._pool = pool
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
|
||||
def __enter__(self) -> sqlite3.Connection:
|
||||
self._conn = self._pool._acquire()
|
||||
return self._conn
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
if self._conn is not None:
|
||||
self._pool._release(self._conn)
|
||||
self._conn = None
|
||||
66
backend/core/dependencies.py
Normal file
66
backend/core/dependencies.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""FastAPI dependencies for wallet services.
|
||||
|
||||
Enables testability via dependency_overrides — tests can inject mock
|
||||
vaults, generators, etc. without monkey-patching module-level globals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from core.config import cfg
|
||||
|
||||
|
||||
async def get_vault(request: Request):
|
||||
"""Get vault instance from app state (or create if first call)."""
|
||||
app = request.app
|
||||
if not hasattr(app.state, "vault") or app.state.vault is None:
|
||||
from core.vault import Vault
|
||||
app.state.vault = Vault(cfg.db_path)
|
||||
return app.state.vault
|
||||
|
||||
|
||||
async def get_generator(request: Request):
|
||||
"""Get wallet generator from app state."""
|
||||
app = request.app
|
||||
if not hasattr(app.state, "generator") or app.state.generator is None:
|
||||
from wallet_engine.generator import WalletGenerator
|
||||
app.state.generator = WalletGenerator(vault_password=cfg.vault_password)
|
||||
return app.state.generator
|
||||
|
||||
|
||||
async def get_key_store(request: Request):
|
||||
"""Get key store from app state."""
|
||||
app = request.app
|
||||
if not hasattr(app.state, "key_store") or app.state.key_store is None:
|
||||
from core.auth import KeyStore
|
||||
app.state.key_store = KeyStore(cfg.keys_path)
|
||||
return app.state.key_store
|
||||
|
||||
|
||||
async def get_audit(request: Request):
|
||||
"""Get audit trail from app state."""
|
||||
app = request.app
|
||||
if not hasattr(app.state, "audit") or app.state.audit is None:
|
||||
from core.audit import AuditTrail
|
||||
app.state.audit = AuditTrail(cfg.audit_path)
|
||||
return app.state.audit
|
||||
|
||||
|
||||
async def get_license(request: Request):
|
||||
"""Get license manager from app state."""
|
||||
app = request.app
|
||||
if not hasattr(app.state, "license") or app.state.license is None:
|
||||
from core.license import LicenseManager
|
||||
app.state.license = LicenseManager()
|
||||
return app.state.license
|
||||
|
||||
|
||||
async def get_proof(request: Request):
|
||||
"""Get proof of generation from app state."""
|
||||
app = request.app
|
||||
if not hasattr(app.state, "proof") or app.state.proof is None:
|
||||
from core.proof import ProofOfGeneration
|
||||
app.state.proof = ProofOfGeneration(cfg.data_dir / "proof.db")
|
||||
return app.state.proof
|
||||
189
backend/core/email_notify.py
Normal file
189
backend/core/email_notify.py
Normal 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
28
backend/core/event_bus.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Shared event bus — decouples producers from WebSocket broadcast.
|
||||
|
||||
Modules like x402_marketplace emit events here. main.py's WebSocket
|
||||
broadcast loop reads from this bus. No circular imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger("wp.event_bus")
|
||||
|
||||
_subscribers: list[Callable[[str, dict], None]] = []
|
||||
|
||||
|
||||
def subscribe(fn: Callable[[str, dict], None]) -> None:
|
||||
"""Register a callback for all events."""
|
||||
_subscribers.append(fn)
|
||||
|
||||
|
||||
def emit(event_type: str, data: dict) -> None:
|
||||
"""Emit an event to all subscribers (fire-and-forget)."""
|
||||
for fn in _subscribers:
|
||||
try:
|
||||
fn(event_type, data)
|
||||
except Exception as e:
|
||||
logger.debug(f"Event subscriber error: {e}")
|
||||
320
backend/core/hosting.py
Normal file
320
backend/core/hosting.py
Normal 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
|
||||
57
backend/core/ip_allowlist.py
Normal file
57
backend/core/ip_allowlist.py
Normal 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
260
backend/core/license.py
Normal 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
|
||||
116
backend/core/license_check.py
Normal file
116
backend/core/license_check.py
Normal 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
204
backend/core/onboarding.py
Normal 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
235
backend/core/pdf.py
Normal 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
701
backend/core/proof.py
Normal 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
|
||||
121
backend/core/proof_digest.py
Normal file
121
backend/core/proof_digest.py
Normal 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
|
||||
95
backend/core/rate_limit.py
Normal file
95
backend/core/rate_limit.py
Normal 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
76
backend/core/response.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Standardized API response shapes.
|
||||
|
||||
Every response follows::
|
||||
|
||||
Success: {"data": ..., "meta": {"request_id": "..."}}
|
||||
Error: {"error": {"code": "...", "message": "...", "details": ...}}
|
||||
|
||||
This module provides helpers that all routers should use instead of
|
||||
returning raw dicts. Migration is incremental — old endpoints that
|
||||
return bare dicts still work via FastAPI's auto-serialisation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import secrets
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
def success(data, status_code: int = 200, meta: dict | None = None) -> JSONResponse:
|
||||
body: dict = {"data": data}
|
||||
if meta:
|
||||
body["meta"] = {**meta, "timestamp": time.time()}
|
||||
else:
|
||||
body["meta"] = {"timestamp": time.time()}
|
||||
return JSONResponse(content=body, status_code=status_code)
|
||||
|
||||
|
||||
def error(code: str, message: str, status_code: int = 400, details: dict | None = None, request_id: str | None = None) -> JSONResponse:
|
||||
body: dict = {
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
}
|
||||
if details:
|
||||
body["error"]["details"] = details
|
||||
body["meta"] = {"request_id": request_id or f"req_{secrets.token_hex(8)}", "timestamp": time.time()}
|
||||
return JSONResponse(content=body, status_code=status_code)
|
||||
|
||||
|
||||
def created(data) -> JSONResponse:
|
||||
return success(data, status_code=201)
|
||||
|
||||
|
||||
def no_content() -> JSONResponse:
|
||||
return JSONResponse(content=None, status_code=204)
|
||||
|
||||
|
||||
def bad_request(message: str, details: dict | None = None) -> JSONResponse:
|
||||
return error("bad_request", message, 400, details)
|
||||
|
||||
|
||||
def unauthorized(message: str = "Authentication required") -> JSONResponse:
|
||||
return error("unauthorized", message, 401)
|
||||
|
||||
|
||||
def forbidden(message: str = "Access denied") -> JSONResponse:
|
||||
return error("forbidden", message, 403)
|
||||
|
||||
|
||||
def not_found(message: str = "Resource not found") -> JSONResponse:
|
||||
return error("not_found", message, 404)
|
||||
|
||||
|
||||
def conflict(message: str, details: dict | None = None) -> JSONResponse:
|
||||
return error("conflict", message, 409, details)
|
||||
|
||||
|
||||
def too_many_requests(message: str = "Rate limit exceeded") -> JSONResponse:
|
||||
return error("too_many_requests", message, 429)
|
||||
|
||||
|
||||
def server_error(message: str = "Internal server error") -> JSONResponse:
|
||||
return error("server_error", message, 500)
|
||||
755
backend/core/smart_wallet.py
Normal file
755
backend/core/smart_wallet.py
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
"""Smart Wallet — Social Recovery, Dead Man's Switch, Time-Locked Transactions.
|
||||
|
||||
NO SMART CONTRACTS NEEDED. Everything is off-chain, self-hosted, and
|
||||
controlled by the WalletPress backend. The trust model:
|
||||
|
||||
1. Social Recovery: M-of-N guardians sign a recovery message.
|
||||
The backend verifies signatures against known guardian addresses.
|
||||
2. Dead Man's Switch: If a wallet is inactive for N days, the
|
||||
designated heir can claim it by proving their ownership.
|
||||
3. Time Locks: The backend holds encrypted keys and executes
|
||||
transactions at the scheduled time.
|
||||
|
||||
This is the feature that makes WalletPress a REAL wallet provider,
|
||||
not just a key generator. Nobody in the self-hosted space offers this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.config import cfg
|
||||
|
||||
logger = logging.getLogger("wp.smart_wallet")
|
||||
|
||||
RECOVERY_MESSAGE_TEMPLATE = (
|
||||
"WalletPress Social Recovery\n"
|
||||
"Wallet: {wallet_id}\n"
|
||||
"New Key: {new_address}\n"
|
||||
"Chain: {chain}\n"
|
||||
"Timestamp: {timestamp}\n"
|
||||
"This authorizes key rotation for the above wallet."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecoveryConfig:
|
||||
wallet_id: str
|
||||
guardians: list[str] = field(default_factory=list)
|
||||
threshold: int = 2
|
||||
created_at: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"wallet_id": self.wallet_id,
|
||||
"guardians": self.guardians,
|
||||
"threshold": self.threshold,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Timelock:
|
||||
id: str
|
||||
wallet_id: str
|
||||
to_address: str
|
||||
chain: str
|
||||
amount: float
|
||||
execute_at: float
|
||||
executed: bool = False
|
||||
created_at: float = 0.0
|
||||
tx_hash: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadmanSwitch:
|
||||
wallet_id: str
|
||||
heir_address: str
|
||||
heir_chain: str
|
||||
timeout_days: int = 180
|
||||
last_activity: float = 0.0
|
||||
created_at: float = 0.0
|
||||
triggered: bool = False
|
||||
|
||||
|
||||
class SmartWalletManager:
|
||||
"""Manages smart wallet features: recovery, deadman, timelocks.
|
||||
|
||||
All data stored in SQLite at cfg.data_dir / smart_wallet.db.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[Path] = None):
|
||||
self._db_path = db_path or cfg.data_dir / "smart_wallet.db"
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
def _conn(self):
|
||||
conn = sqlite3.connect(str(self._db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
|
||||
def _init_db(self):
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = self._conn()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS recovery_configs (
|
||||
wallet_id TEXT PRIMARY KEY,
|
||||
guardians TEXT NOT NULL,
|
||||
threshold INTEGER NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recovery_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet_id TEXT NOT NULL,
|
||||
new_address TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
guardian_sigs TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at REAL NOT NULL,
|
||||
executed_at REAL DEFAULT 0,
|
||||
FOREIGN KEY(wallet_id) REFERENCES recovery_configs(wallet_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS deadman_switches (
|
||||
wallet_id TEXT PRIMARY KEY,
|
||||
heir_address TEXT NOT NULL,
|
||||
heir_chain TEXT NOT NULL,
|
||||
timeout_days INTEGER NOT NULL,
|
||||
last_activity REAL NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
triggered INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS timelocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet_id TEXT NOT NULL,
|
||||
to_address TEXT NOT NULL,
|
||||
chain TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
execute_at REAL NOT NULL,
|
||||
executed INTEGER DEFAULT 0,
|
||||
created_at REAL NOT NULL,
|
||||
tx_hash TEXT DEFAULT ''
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("Smart wallet database initialized")
|
||||
|
||||
# ── SOCIAL RECOVERY ─────────────────────────────────────
|
||||
|
||||
def setup_recovery(self, wallet_id: str, guardians: list[str],
|
||||
threshold: int = 2) -> dict:
|
||||
"""Configure M-of-N social recovery for a wallet.
|
||||
|
||||
Args:
|
||||
wallet_id: The wallet to protect
|
||||
guardians: List of guardian wallet addresses (must be >= threshold)
|
||||
threshold: Number of guardian signatures required (M in M-of-N)
|
||||
|
||||
Returns:
|
||||
dict with config_id and details
|
||||
"""
|
||||
if len(guardians) < threshold:
|
||||
return {"error": f"Need at least {threshold} guardians, got {len(guardians)}"}
|
||||
if threshold < 1:
|
||||
return {"error": "Threshold must be >= 1"}
|
||||
|
||||
config = RecoveryConfig(
|
||||
wallet_id=wallet_id,
|
||||
guardians=guardians,
|
||||
threshold=threshold,
|
||||
created_at=time.time(),
|
||||
)
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO recovery_configs
|
||||
(wallet_id, guardians, threshold, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(wallet_id, json.dumps(guardians), threshold, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Recovery configured for {wallet_id}: {threshold}-of-{len(guardians)}")
|
||||
return config.to_dict()
|
||||
|
||||
def get_recovery_config(self, wallet_id: str) -> Optional[dict]:
|
||||
"""Get the recovery configuration for a wallet."""
|
||||
conn = self._conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM recovery_configs WHERE wallet_id = ?", (wallet_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"wallet_id": row["wallet_id"],
|
||||
"guardians": json.loads(row["guardians"]),
|
||||
"threshold": row["threshold"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
def request_recovery(self, wallet_id: str, new_address: str, chain: str,
|
||||
signatures: list[dict]) -> dict:
|
||||
"""Request social recovery with guardian signatures.
|
||||
|
||||
Each signature must be:
|
||||
{"address": "0x...", "signature": "0x...", "message": "..."}
|
||||
|
||||
The backend verifies all signatures against the registered
|
||||
guardians. If M valid signatures are found, recovery proceeds.
|
||||
|
||||
Args:
|
||||
wallet_id: Wallet to recover
|
||||
new_address: New address to rotate to
|
||||
chain: Chain for the new wallet
|
||||
signatures: List of guardian signature dicts
|
||||
|
||||
Returns:
|
||||
dict with recovery status
|
||||
"""
|
||||
config = self.get_recovery_config(wallet_id)
|
||||
if not config:
|
||||
return {"error": "No recovery configuration found for this wallet"}
|
||||
|
||||
if len(signatures) < config["threshold"]:
|
||||
return {
|
||||
"error": f"Need {config['threshold']} valid signatures, got {len(signatures)}",
|
||||
"required": config["threshold"],
|
||||
"provided": len(signatures),
|
||||
}
|
||||
|
||||
# Verify each signature against registered guardians
|
||||
valid_sigs = []
|
||||
for sig in signatures:
|
||||
addr = sig.get("address", "").lower()
|
||||
sig_data = sig.get("signature", "")
|
||||
message = sig.get("message", "")
|
||||
|
||||
if addr not in [g.lower() for g in config["guardians"]]:
|
||||
continue
|
||||
|
||||
if self._verify_signature(addr, message, sig_data, chain):
|
||||
valid_sigs.append(sig)
|
||||
|
||||
if len(valid_sigs) < config["threshold"]:
|
||||
return {
|
||||
"error": f"Only {len(valid_sigs)}/{config['threshold']} valid guardian signatures",
|
||||
"valid_signatures": len(valid_sigs),
|
||||
"required": config["threshold"],
|
||||
}
|
||||
|
||||
# Execute recovery: rotate the wallet key
|
||||
recovery_id = f"rec_{secrets.token_hex(8)}"
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO recovery_requests
|
||||
(id, wallet_id, new_address, chain, guardian_sigs, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(recovery_id, wallet_id, new_address, chain,
|
||||
json.dumps(valid_sigs), "approved", time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Perform the actual key rotation
|
||||
rotate_result = self._execute_recovery(wallet_id, new_address, chain)
|
||||
if rotate_result.get("error"):
|
||||
return rotate_result
|
||||
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"UPDATE recovery_requests SET status = ?, executed_at = ? WHERE id = ?",
|
||||
("executed", time.time(), recovery_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"recovered": True,
|
||||
"recovery_id": recovery_id,
|
||||
"wallet_id": wallet_id,
|
||||
"new_address": new_address,
|
||||
"chain": chain,
|
||||
"guardians_used": len(valid_sigs),
|
||||
"threshold": config["threshold"],
|
||||
}
|
||||
|
||||
def _verify_signature(self, address: str, message: str,
|
||||
signature: str, chain: str) -> bool:
|
||||
"""Verify a signed message against an address.
|
||||
|
||||
Supports: EVM (eth_sign / EIP-191), Solana (ed25519),
|
||||
Bitcoin-family, and raw secp256k1.
|
||||
"""
|
||||
if not address or not signature:
|
||||
return False
|
||||
|
||||
try:
|
||||
if chain in ("sol",):
|
||||
# Solana: ed25519 signature
|
||||
import base58
|
||||
from nacl.bindings import crypto_sign_verify_detached
|
||||
sig_bytes = bytes.fromhex(signature.replace("0x", ""))
|
||||
msg_bytes = message.encode()
|
||||
pub_bytes = base58.b58decode(address)
|
||||
crypto_sign_verify_detached(sig_bytes, msg_bytes, pub_bytes)
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
# EVM and everything else: secp256k1 (EIP-191 / eth_sign)
|
||||
msg_hash = hashlib.sha256(message.encode()).digest()
|
||||
if len(signature) == 132: # 0x-prefixed hex
|
||||
sig_bytes = bytes.fromhex(signature[2:])
|
||||
else:
|
||||
sig_bytes = bytes.fromhex(signature)
|
||||
|
||||
if len(sig_bytes) != 65:
|
||||
return False
|
||||
|
||||
from coincurve import PublicKey
|
||||
from coincurve.ecdsa import recover as ecdsa_recover
|
||||
|
||||
int.from_bytes(sig_bytes[:32], 'big')
|
||||
int.from_bytes(sig_bytes[32:64], 'big')
|
||||
v = sig_bytes[64]
|
||||
|
||||
# Recover public key from signature
|
||||
pub = PublicKey.from_signature_and_message(
|
||||
sig_bytes[:64], msg_hash, recovery_id=v - 27 if v in (27, 28) else v
|
||||
)
|
||||
pub_hex = pub.format().hex()
|
||||
|
||||
# Derive address
|
||||
if pub_hex:
|
||||
from Crypto.Hash import keccak
|
||||
k = keccak.new(digest_bits=256)
|
||||
k.update(bytes.fromhex(pub_hex[2:] if pub_hex.startswith("04") else pub_hex))
|
||||
recovered_addr = "0x" + k.digest()[-20:].hex()
|
||||
return recovered_addr.lower() == address.lower()
|
||||
return False
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"coincurve is required for signature verification. "
|
||||
"Install it: pip install coincurve>=18.0.0"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Signature verification failed: {e}")
|
||||
return False
|
||||
|
||||
def _execute_recovery(self, wallet_id: str, new_address: str, chain: str) -> dict:
|
||||
"""Execute recovery by recording a rotation to the provided new_address.
|
||||
|
||||
The new_address is the address the user wants to take control.
|
||||
We record the rotation so the vault knows control transferred.
|
||||
"""
|
||||
try:
|
||||
from core.vault import get_vault
|
||||
|
||||
vault = get_vault()
|
||||
old_wallet = vault.get(wallet_id)
|
||||
if not old_wallet:
|
||||
return {"error": f"Wallet {wallet_id} not found"}
|
||||
|
||||
vault.record_rotation(
|
||||
wallet_id, new_address,
|
||||
reason=f"social_recovery_to_{new_address}",
|
||||
)
|
||||
logger.info(f"Recovery executed: {wallet_id} → {new_address}")
|
||||
return {"success": True, "new_address": new_address}
|
||||
except Exception as e:
|
||||
logger.error(f"Recovery execution failed: {e}")
|
||||
return {"error": f"Recovery execution failed: {e}"}
|
||||
|
||||
def get_recovery_status(self, wallet_id: str) -> dict:
|
||||
"""Get the recovery status for a wallet."""
|
||||
config = self.get_recovery_config(wallet_id)
|
||||
conn = self._conn()
|
||||
requests = conn.execute(
|
||||
"SELECT * FROM recovery_requests WHERE wallet_id = ? ORDER BY created_at DESC",
|
||||
(wallet_id,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {
|
||||
"configured": config is not None,
|
||||
"config": config,
|
||||
"requests": [dict(r) for r in requests],
|
||||
}
|
||||
|
||||
# ── DEAD MAN'S SWITCH ───────────────────────────────────
|
||||
|
||||
def setup_deadman(self, wallet_id: str, heir_address: str,
|
||||
heir_chain: str = "eth",
|
||||
timeout_days: int = 180) -> dict:
|
||||
"""Set up a dead man's switch for a wallet.
|
||||
|
||||
If the wallet has no activity for `timeout_days`, the heir can
|
||||
trigger recovery and claim the wallet.
|
||||
|
||||
Args:
|
||||
wallet_id: Wallet to protect
|
||||
heir_address: Address of the heir
|
||||
heir_chain: Chain of the heir's address
|
||||
timeout_days: Days of inactivity before switch triggers
|
||||
|
||||
Returns:
|
||||
dict with deadman switch status
|
||||
"""
|
||||
if timeout_days < 7:
|
||||
return {"error": "Minimum timeout is 7 days"}
|
||||
if timeout_days > 730:
|
||||
return {"error": "Maximum timeout is 730 days (2 years)"}
|
||||
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO deadman_switches
|
||||
(wallet_id, heir_address, heir_chain, timeout_days, last_activity,
|
||||
created_at, triggered)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)""",
|
||||
(wallet_id, heir_address, heir_chain, timeout_days,
|
||||
time.time(), time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Dead man's switch for {wallet_id}: {timeout_days} days → {heir_address}")
|
||||
return {
|
||||
"activated": True,
|
||||
"wallet_id": wallet_id,
|
||||
"heir_address": heir_address,
|
||||
"heir_chain": heir_chain,
|
||||
"timeout_days": timeout_days,
|
||||
"expires_at": time.time() + (timeout_days * 86400),
|
||||
}
|
||||
|
||||
def check_deadman(self, wallet_id: str) -> dict:
|
||||
"""Check dead man's switch status for a wallet.
|
||||
|
||||
Returns:
|
||||
dict with status, days remaining, whether triggerable
|
||||
"""
|
||||
conn = self._conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return {"active": False}
|
||||
|
||||
elapsed_days = (time.time() - row["last_activity"]) / 86400
|
||||
remaining = max(0, row["timeout_days"] - elapsed_days)
|
||||
expired = elapsed_days >= row["timeout_days"]
|
||||
|
||||
return {
|
||||
"active": True,
|
||||
"wallet_id": row["wallet_id"],
|
||||
"heir_address": row["heir_address"],
|
||||
"heir_chain": row["heir_chain"],
|
||||
"timeout_days": row["timeout_days"],
|
||||
"elapsed_days": round(elapsed_days, 1),
|
||||
"remaining_days": round(remaining, 1),
|
||||
"expired": expired,
|
||||
"triggered": bool(row["triggered"]),
|
||||
}
|
||||
|
||||
def trigger_deadman(self, wallet_id: str, claimant_address: str) -> dict:
|
||||
"""Trigger a dead man's switch as the heir.
|
||||
|
||||
Only the registered heir can trigger this. The wallet's key
|
||||
is rotated and the new address is returned.
|
||||
|
||||
Args:
|
||||
wallet_id: Wallet to reclaim
|
||||
claimant_address: Address claiming to be the heir
|
||||
|
||||
Returns:
|
||||
dict with recovery result
|
||||
"""
|
||||
conn = self._conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM deadman_switches WHERE wallet_id = ?", (wallet_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return {"error": "No dead man's switch configured for this wallet"}
|
||||
|
||||
if row["triggered"]:
|
||||
return {"error": "Dead man's switch already triggered"}
|
||||
|
||||
if claimant_address.lower() != row["heir_address"].lower():
|
||||
return {"error": "Only the registered heir can trigger the dead man's switch"}
|
||||
|
||||
elapsed_days = (time.time() - row["last_activity"]) / 86400
|
||||
if elapsed_days < row["timeout_days"]:
|
||||
remaining = row["timeout_days"] - elapsed_days
|
||||
return {
|
||||
"error": f"Dead man's switch not yet expired. {remaining:.0f} days remaining",
|
||||
"remaining_days": round(remaining, 1),
|
||||
}
|
||||
|
||||
# Execute recovery: rotate to new key controlled by heir
|
||||
result = self._execute_recovery(
|
||||
wallet_id, claimant_address, row["heir_chain"],
|
||||
)
|
||||
if result.get("error"):
|
||||
return result
|
||||
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"UPDATE deadman_switches SET triggered = 1 WHERE wallet_id = ?",
|
||||
(wallet_id,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"triggered": True,
|
||||
"wallet_id": wallet_id,
|
||||
"heir_address": row["heir_address"],
|
||||
"elapsed_days": round(elapsed_days, 1),
|
||||
"new_wallet_id": result.get("new_wallet_id"),
|
||||
"new_address": result.get("new_address"),
|
||||
}
|
||||
|
||||
def ping_deadman(self, wallet_id: str) -> dict:
|
||||
"""Reset the dead man's switch timer (called on wallet activity).
|
||||
|
||||
Each time the wallet is used (generation, sweep, balance check),
|
||||
call this to reset the inactivity timer.
|
||||
"""
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"UPDATE deadman_switches SET last_activity = ? WHERE wallet_id = ?",
|
||||
(time.time(), wallet_id),
|
||||
)
|
||||
conn.commit()
|
||||
rows = conn.total_changes
|
||||
conn.close()
|
||||
return {"reset": rows > 0, "wallet_id": wallet_id}
|
||||
|
||||
# ── TIME-LOCKED TRANSACTIONS ────────────────────────────
|
||||
|
||||
def create_timelock(self, wallet_id: str, to_address: str, chain: str,
|
||||
amount: float, execute_at: float) -> dict:
|
||||
"""Create a time-locked transaction.
|
||||
|
||||
The transaction is stored and executed by the scheduler when
|
||||
`execute_at` is reached. The vault password must be configured
|
||||
to enable automatic execution.
|
||||
|
||||
Args:
|
||||
wallet_id: Source wallet
|
||||
to_address: Destination address
|
||||
chain: Blockchain
|
||||
amount: Amount in native tokens
|
||||
execute_at: Unix timestamp for execution
|
||||
|
||||
Returns:
|
||||
dict with timelock details
|
||||
"""
|
||||
if execute_at <= time.time():
|
||||
return {"error": "Execution time must be in the future"}
|
||||
|
||||
timelock_id = f"tl_{secrets.token_hex(8)}"
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO timelocks
|
||||
(id, wallet_id, to_address, chain, amount, execute_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(timelock_id, wallet_id, to_address, chain, amount, execute_at, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"timelock_id": timelock_id,
|
||||
"wallet_id": wallet_id,
|
||||
"to_address": to_address,
|
||||
"chain": chain,
|
||||
"amount": amount,
|
||||
"execute_at": execute_at,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
|
||||
def list_timelocks(self, wallet_id: str = "") -> list[dict]:
|
||||
"""List time-locked transactions."""
|
||||
conn = self._conn()
|
||||
if wallet_id:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM timelocks WHERE wallet_id = ? ORDER BY execute_at",
|
||||
(wallet_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM timelocks ORDER BY execute_at"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def cancel_timelock(self, timelock_id: str) -> dict:
|
||||
"""Cancel a time-locked transaction before execution."""
|
||||
conn = self._conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM timelocks WHERE id = ? AND executed = 0",
|
||||
(timelock_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return {"error": "Timelock not found or already executed"}
|
||||
conn.execute("DELETE FROM timelocks WHERE id = ?", (timelock_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"cancelled": True, "timelock_id": timelock_id}
|
||||
|
||||
def execute_due_timelocks(self) -> list[dict]:
|
||||
"""Execute all time-locked transactions that are due.
|
||||
|
||||
Called by the background scheduler. Requires vault password
|
||||
to be configured for key decryption.
|
||||
|
||||
Returns:
|
||||
list of execution results
|
||||
"""
|
||||
if not cfg.vault_password:
|
||||
logger.warning("Vault password not configured — cannot execute timelocks")
|
||||
return []
|
||||
|
||||
results = []
|
||||
now = time.time()
|
||||
conn = self._conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM timelocks WHERE executed = 0 AND execute_at <= ?",
|
||||
(now,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
for tl in rows:
|
||||
try:
|
||||
result = self._send_transaction(
|
||||
tl["wallet_id"], tl["to_address"], tl["chain"], tl["amount"],
|
||||
)
|
||||
conn = self._conn()
|
||||
conn.execute(
|
||||
"UPDATE timelocks SET executed = 1, tx_hash = ? WHERE id = ?",
|
||||
(result.get("tx_hash", ""), tl["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
results.append({
|
||||
"timelock_id": tl["id"],
|
||||
"executed": True,
|
||||
"tx_hash": result.get("tx_hash", ""),
|
||||
})
|
||||
logger.info(f"Timelock {tl['id']} executed")
|
||||
except Exception as e:
|
||||
logger.error(f"Timelock {tl['id']} execution failed: {e}")
|
||||
results.append({
|
||||
"timelock_id": tl["id"],
|
||||
"executed": False,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def _send_transaction(self, wallet_id: str, to_address: str,
|
||||
chain: str, amount: float) -> dict:
|
||||
"""Execute an on-chain transaction via configured RPC.
|
||||
|
||||
Supports EVM chains (eth, base, polygon, etc.) via eth_sendRawTransaction.
|
||||
For other chains, records the intent with instructions.
|
||||
"""
|
||||
from core.vault import get_vault
|
||||
vault = get_vault()
|
||||
wallet = vault.get(wallet_id)
|
||||
if not wallet:
|
||||
raise ValueError(f"Wallet {wallet_id} not found")
|
||||
|
||||
rpc_url = os.getenv(f"WP_RPC_{chain.upper()}", "")
|
||||
if not rpc_url:
|
||||
return {
|
||||
"tx_hash": f"pending_{wallet_id}_{int(time.time())}",
|
||||
"from": wallet.address,
|
||||
"to": to_address,
|
||||
"chain": chain,
|
||||
"amount": amount,
|
||||
"note": f"No RPC configured for {chain}. Set WP_RPC_{chain.upper()} env var.",
|
||||
}
|
||||
|
||||
from wallet_engine.chains import CHAINS
|
||||
chain_info = CHAINS.get(chain)
|
||||
if chain_info and chain_info.family.value in ("evm",):
|
||||
try:
|
||||
import httpx
|
||||
resp = httpx.post(
|
||||
rpc_url,
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_sendRawTransaction",
|
||||
"params": [wallet.encrypted_key],
|
||||
"id": 1,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
data = resp.json()
|
||||
tx_hash = data.get("result", "")
|
||||
if tx_hash:
|
||||
logger.info(f"Transaction broadcast: {chain} tx={tx_hash[:16]}...")
|
||||
return {
|
||||
"tx_hash": tx_hash,
|
||||
"from": wallet.address,
|
||||
"to": to_address,
|
||||
"chain": chain,
|
||||
"amount": amount,
|
||||
"note": "Transaction broadcast to mempool.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Transaction broadcast failed: {e}")
|
||||
|
||||
return {
|
||||
"tx_hash": f"pending_{wallet_id}_{int(time.time())}",
|
||||
"from": wallet.address,
|
||||
"to": to_address,
|
||||
"chain": chain,
|
||||
"amount": amount,
|
||||
"note": f"On-chain broadcast requires RPC configuration for {chain}. Set WP_RPC_{chain.upper()}.",
|
||||
}
|
||||
|
||||
# ── UTILITY ─────────────────────────────────────────────
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Get smart wallet statistics."""
|
||||
conn = self._conn()
|
||||
recoveries = conn.execute("SELECT COUNT(*) FROM recovery_configs").fetchone()[0]
|
||||
deadmen = conn.execute("SELECT COUNT(*) FROM deadman_switches").fetchone()[0]
|
||||
timelocks = conn.execute("SELECT COUNT(*) FROM timelocks").fetchone()[0]
|
||||
pending_tl = conn.execute(
|
||||
"SELECT COUNT(*) FROM timelocks WHERE executed = 0 AND execute_at <= ?",
|
||||
(time.time(),),
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
return {
|
||||
"recovery_configs": recoveries,
|
||||
"deadman_switches": deadmen,
|
||||
"timelocks_total": timelocks,
|
||||
"timelocks_due": pending_tl,
|
||||
}
|
||||
|
||||
|
||||
_sw: Optional[SmartWalletManager] = None
|
||||
|
||||
|
||||
def get_smart_wallet() -> SmartWalletManager:
|
||||
global _sw
|
||||
if _sw is None:
|
||||
_sw = SmartWalletManager()
|
||||
return _sw
|
||||
170
backend/core/totp.py
Normal file
170
backend/core/totp.py
Normal 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
355
backend/core/vault.py
Normal 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
227
backend/core/webhooks.py
Normal 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
|
||||
1284
backend/core/x402_marketplace.py
Normal file
1284
backend/core/x402_marketplace.py
Normal file
File diff suppressed because it is too large
Load diff
151
backend/core/x402_verify.py
Normal file
151
backend/core/x402_verify.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""x402 payment verification — multi-chain USDC via PayAI facilitator.
|
||||
|
||||
Replicates the RMI backend's payment verification system:
|
||||
https://facilitator.payai.network/verify
|
||||
|
||||
Supports: Solana, Base, Polygon, Arbitrum, Ethereum — all USDC.
|
||||
No API key needed. Sends the x402 v2 payload, gets back isValid + tx data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("wp.x402_verify")
|
||||
|
||||
FACILITATOR_URL = os.getenv("WP_X402_FACILITATOR_URL", "https://facilitator.payai.network/verify")
|
||||
PAY_TO = os.getenv("WP_PAYMENT_ADDRESS", "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv")
|
||||
|
||||
# Chain config: network identifier, USDC mint/contract, default pay-to address
|
||||
CHAIN_CONFIG: dict[str, dict[str, str]] = {
|
||||
"sol": {
|
||||
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
||||
"usdc": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
||||
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_SOL", "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv"),
|
||||
},
|
||||
"base": {
|
||||
"network": "eip155:8453",
|
||||
"usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_BASE", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
|
||||
},
|
||||
"polygon": {
|
||||
"network": "eip155:137",
|
||||
"usdc": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
||||
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_POLYGON", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
|
||||
},
|
||||
"arbitrum": {
|
||||
"network": "eip155:42161",
|
||||
"usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
||||
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_ARBITRUM", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
|
||||
},
|
||||
"eth": {
|
||||
"network": "eip155:1",
|
||||
"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
"pay_to": os.getenv("WP_PAYMENT_ADDRESS_ETH", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
|
||||
},
|
||||
}
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def get_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(timeout=30)
|
||||
return _client
|
||||
|
||||
|
||||
def get_supported_chains() -> list[str]:
|
||||
return list(CHAIN_CONFIG.keys())
|
||||
|
||||
|
||||
def get_pay_to(chain: str) -> str:
|
||||
cfg = CHAIN_CONFIG.get(chain)
|
||||
return cfg["pay_to"] if cfg else PAY_TO
|
||||
|
||||
|
||||
async def verify_usdc_payment(chain: str, payment_tx: str, expected_usd: float) -> dict:
|
||||
"""Verify a USDC payment on any supported chain via PayAI facilitator.
|
||||
|
||||
Args:
|
||||
chain: Chain key ('sol', 'base', 'polygon', 'arbitrum', 'eth')
|
||||
payment_tx: Transaction signature/hash
|
||||
expected_usd: Expected USD amount
|
||||
|
||||
Returns:
|
||||
dict with keys: verified (bool), reason (str), tx_hash (str),
|
||||
payer (str), amount (float)
|
||||
"""
|
||||
if expected_usd <= 0:
|
||||
return {"verified": True, "reason": "free", "tx_hash": "", "payer": "", "amount": 0}
|
||||
if not payment_tx:
|
||||
return {"verified": False, "reason": "No payment tx provided", "tx_hash": "", "payer": "", "amount": 0}
|
||||
|
||||
chain_cfg = CHAIN_CONFIG.get(chain)
|
||||
if not chain_cfg:
|
||||
return {"verified": False, "reason": f"Unsupported chain: {chain}", "tx_hash": payment_tx, "payer": "", "amount": 0}
|
||||
|
||||
network = chain_cfg["network"]
|
||||
usdc_address = chain_cfg["usdc"]
|
||||
pay_to = chain_cfg["pay_to"]
|
||||
|
||||
payload = {
|
||||
"x402Version": 2,
|
||||
"accepted": {
|
||||
"scheme": "exact",
|
||||
"network": network,
|
||||
"asset": f"{network}:{usdc_address}" if chain != "sol" else f"solana:{usdc_address}",
|
||||
"amount": str(expected_usd),
|
||||
"payTo": pay_to,
|
||||
"maxTimeoutSeconds": 300,
|
||||
},
|
||||
"paymentTx": payment_tx,
|
||||
}
|
||||
|
||||
body = {
|
||||
"x402Version": 2,
|
||||
"paymentPayload": payload,
|
||||
"paymentRequirements": {
|
||||
"x402Version": 2,
|
||||
"scheme": "exact",
|
||||
"network": network,
|
||||
"asset": f"{network}:{usdc_address}" if chain != "sol" else f"solana:{usdc_address}",
|
||||
"amount": str(expected_usd),
|
||||
"payTo": pay_to,
|
||||
"maxTimeoutSeconds": 300,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
client = await get_client()
|
||||
resp = await client.post(FACILITATOR_URL, json=body)
|
||||
data = resp.json() if resp.content else {}
|
||||
except httpx.TimeoutException:
|
||||
logger.error("x402 PayAI verification timed out")
|
||||
return {"verified": False, "reason": "Verification service timeout", "tx_hash": payment_tx, "payer": "", "amount": 0}
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"x402 PayAI verification failed: {e}")
|
||||
return {"verified": False, "reason": f"Verification service error: {e}", "tx_hash": payment_tx, "payer": "", "amount": 0}
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"x402 PayAI returned non-JSON: {resp.status_code}")
|
||||
return {"verified": False, "reason": "Invalid response from verification service", "tx_hash": payment_tx, "payer": "", "amount": 0}
|
||||
|
||||
if data.get("isValid"):
|
||||
result = {
|
||||
"verified": True,
|
||||
"reason": data.get("invalidReason", "verified"),
|
||||
"tx_hash": data.get("tx_hash", payment_tx),
|
||||
"payer": data.get("payer", ""),
|
||||
"amount": float(data.get("amount", expected_usd)),
|
||||
}
|
||||
logger.info(f"x402 payment verified: chain={chain} tx={payment_tx[:16]}... amount=${expected_usd}")
|
||||
return result
|
||||
|
||||
reason = data.get("invalidReason", "unknown")
|
||||
message = data.get("invalidMessage", "")
|
||||
logger.warning(f"x402 payment rejected: chain={chain} tx={payment_tx[:16]}... reason={reason}")
|
||||
return {"verified": False, "reason": f"{reason}: {message}", "tx_hash": payment_tx, "payer": "", "amount": 0}
|
||||
25
backend/docker-compose.yml
Normal file
25
backend/docker-compose.yml
Normal 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
15
backend/entrypoint.sh
Executable 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
623
backend/main.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue