fix(audit): Wave 1 — P2 quick wins + cleanup (WP-067..WP-074)

Addresses 7 P2/P3 items from AUDIT.md:

P2-2 — validate_mnemonic raises on missing bip_utils
  Previously the try/except ImportError silently passed, accepting
  garbage mnemonics as valid. Now raises hard — BIP39 validation
  is mandatory.

P2-3 — wallet_generate rolls back partial state on error
  Mid-loop errors previously left 5 of 10 wallets persisted with
  no rollback. Now tracks created_ids and deletes them on any
  exception so the caller sees a consistent state.

P2-13 — Login rate limit per email
  /hosting/login now enforces exponential backoff per email:
    - 3 failures  → 30s lockout
    - 5 failures  → 5min lockout
    - 10 failures → 1h lockout
  Returns 429 with Retry-After header. In-memory sliding window;
  swap for Redis when scaling beyond single instance.

P2-14 — Redact operator referral codes in hosted mode
  agent_referral_status used to expose Rug Munch Media's
  referral codes + receiving wallets to any MCP client. Now
  redacts those fields when WP_HOSTED_MODE is set (multi-tenant).
  Single-tenant self-hosted still shows full info.

P3-2 — Remove unused is_write_action import from mcp_server
  Was imported but never called.

P3-5 — Remove dead _migrate() body in vault.py
  The migration stub was a no-op. Now documented as version-stamping
  only; real schema migrations go through Alembic (already installed
  but unused — see Phase 2 cleanup).

Refs: AUDIT.md P2-2, P2-3, P2-13, P2-14, P3-2, P3-5
This commit is contained in:
Crypto Rug Munch 2026-06-30 20:31:08 +07:00
parent af9dd747de
commit 9751d64c6a
No known key found for this signature in database
GPG key ID: 58D4300721937626
4 changed files with 370 additions and 173 deletions

View file

@ -17,7 +17,7 @@ from mcp.server.fastmcp import FastMCP
from core.config import cfg
from core.vault import WalletEntry, get_vault
from core.agent_safety import (
check_action_allowed, is_write_action, is_killed,
check_action_allowed, is_killed,
request_confirmation, confirm_action, reject_action, list_pending,
check_address, check_spending_limit, record_spending,
audit_log, simulate_transaction,
@ -230,6 +230,12 @@ def agent_referral_status() -> dict:
on every swap/trade this costs you nothing and helps fund development.
In SELF-REFERRAL mode, you keep 100% of referral revenue.
P2-14: In multi-tenant (hosted) mode, this endpoint used to leak the
operator's referral codes and wallets to any tenant. Now we redact
sensitive fields (codes, wallet addresses) in hosted mode. The
operator can see everything; tenants see only that the system is
configured.
Shows which platforms are configured, which need wallet setup,
and what revenue share each platform provides.
"""
@ -237,28 +243,97 @@ def agent_referral_status() -> dict:
from plugins.defi import REVENUE_MODE, REF_JUPITER_WALLET, REF_HYPERLIQUID_WALLET
from plugins.defi import REF_GMGN, REF_ODINBOT, REF_BANANA, REF_AXIOM, REF_PADRE
# Hosted mode = multi-tenant. Redact operator secrets from response.
is_hosted = bool(os.getenv("WP_HOSTED_MODE", ""))
redact_secrets = is_hosted and REVENUE_MODE == "freemium"
def _maybe_redact(value, kind: str = "value") -> str:
if redact_secrets:
if kind == "wallet":
return "[REDACTED — operator-controlled]"
return "[REDACTED]"
return value or "NOT SET"
platforms = [
{"platform": "Jupiter", "type": "fee_account", "share": "50bps", "configured": bool(REF_JUPITER_WALLET), "wallet": REF_JUPITER_WALLET or "NOT SET", "docs": "https://jup.ag/referral"},
{"platform": "Hyperliquid", "type": "builder_code", "share": "up to 0.1%/trade", "configured": bool(REF_HYPERLIQUID_WALLET), "wallet": REF_HYPERLIQUID_WALLET or "NOT SET", "docs": "https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes.md"},
{"platform": "GMGN", "type": "code", "share": "40%", "configured": True, "code": REF_GMGN, "setup": "Use code at gmgn.ai"},
{"platform": "OdinBot", "type": "code", "share": "40%", "configured": True, "code": REF_ODINBOT, "setup": "Use code at t.me/OdinBot"},
{"platform": "Banana Gun", "type": "code", "share": "40%", "configured": True, "code": REF_BANANA, "setup": "Use code at t.me/BananaGunBot"},
{"platform": "Axiom", "type": "code", "share": "30%", "configured": True, "code": REF_AXIOM, "setup": "Use code at t.me/AxiomTrade"},
{"platform": "Padre", "type": "code", "share": "35%", "configured": True, "code": REF_PADRE, "setup": "Use code at t.me/PadreBot"},
{
"platform": "Jupiter",
"type": "fee_account",
"share": "50bps",
"configured": bool(REF_JUPITER_WALLET),
"wallet": _maybe_redact(REF_JUPITER_WALLET, "wallet"),
"docs": "https://jup.ag/referral",
},
{
"platform": "Hyperliquid",
"type": "builder_code",
"share": "up to 0.1%/trade",
"configured": bool(REF_HYPERLIQUID_WALLET),
"wallet": _maybe_redact(REF_HYPERLIQUID_WALLET, "wallet"),
"docs": "https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes.md",
},
{
"platform": "GMGN",
"type": "code",
"share": "40%",
"configured": True,
"code": _maybe_redact(REF_GMGN),
"setup": "Use code at gmgn.ai",
},
{
"platform": "OdinBot",
"type": "code",
"share": "40%",
"configured": True,
"code": _maybe_redact(REF_ODINBOT),
"setup": "Use code at t.me/OdinBot",
},
{
"platform": "Banana Gun",
"type": "code",
"share": "40%",
"configured": True,
"code": _maybe_redact(REF_BANANA),
"setup": "Use code at t.me/BananaGunBot",
},
{
"platform": "Axiom",
"type": "code",
"share": "30%",
"configured": True,
"code": _maybe_redact(REF_AXIOM),
"setup": "Use code at t.me/AxiomTrade",
},
{
"platform": "Padre",
"type": "code",
"share": "35%",
"configured": True,
"code": _maybe_redact(REF_PADRE),
"setup": "Use code at t.me/PadreBot",
},
]
freemium_note = ""
if REVENUE_MODE == "freemium":
configured = sum(1 for p in platforms if p.get("configured"))
unconfigured = sum(1 for p in platforms if not p.get("configured"))
freemium_note = f"{configured} platforms sending revenue, {unconfigured} need wallet setup (Jupiter + Hyperliquid). Set WP_REF_REVENUE_MODE=self to keep your own referral revenue."
freemium_note = (
f"{configured} platforms sending revenue, "
f"{unconfigured} need wallet setup (Jupiter + Hyperliquid). "
f"Set WP_REF_REVENUE_MODE=self to keep your own referral revenue."
)
return {
"revenue_mode": REVENUE_MODE.upper(),
"your_cost": "free (we cover referral fees)" if REVENUE_MODE == "freemium" else "you control your own codes",
"platforms": platforms,
"note": freemium_note,
"how_to_configure": "Jupiter: set WP_REF_JUPITER_WALLET (fee account). Hyperliquid: set WP_REF_HYPERLIQUID_WALLET (builder wallet, needs 100 USDC in perps).",
"secrets_redacted": redact_secrets,
"how_to_configure": (
"Jupiter: set WP_REF_JUPITER_WALLET (fee account). "
"Hyperliquid: set WP_REF_HYPERLIQUID_WALLET (builder wallet, "
"needs 100 USDC in perps)."
),
}
@ -349,23 +424,36 @@ def wallet_generate(chain: str, label: str = "", count: int = 1) -> dict:
vault = get_vault()
generator = get_generator(cfg.vault_password)
wallets = []
for i in range(count):
lbl = label or f"{chain}_{i + 1}_{int(time.time())}"
try:
wallet = generator.generate(chain, label=lbl)
except ValueError as e:
return {"error": str(e)}
entry = WalletEntry(
id=f"wp_agent_{chain}_{int(time.time() * 1000)}_{i}",
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 "",
public_key=wallet.public_key_hex,
derivation_path=wallet.derivation_path,
encrypted=wallet.encrypted,
)
vault.put(entry)
wallets.append({"id": entry.id, "chain": entry.chain, "address": entry.address, "label": entry.label})
created_ids = []
try:
for i in range(count):
lbl = label or f"{chain}_{i + 1}_{int(time.time())}"
try:
wallet = generator.generate(chain, label=lbl)
except ValueError as e:
# P2-3: roll back any partial inserts so caller doesn't see
# inconsistent state (e.g. 5 of 10 wallets persisted, error
# in the middle, caller doesn't know which were saved).
for wid in created_ids:
vault.delete(wid)
return {"error": str(e), "rolled_back": created_ids}
entry = WalletEntry(
id=f"wp_agent_{chain}_{int(time.time() * 1000)}_{i}",
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 "",
public_key=wallet.public_key_hex,
derivation_path=wallet.derivation_path,
encrypted=wallet.encrypted,
)
vault.put(entry)
created_ids.append(entry.id)
wallets.append({"id": entry.id, "chain": entry.chain, "address": entry.address, "label": entry.label})
except Exception as e:
# Any unexpected error — roll back partial inserts.
for wid in created_ids:
vault.delete(wid)
return {"error": f"{type(e).__name__}: {e}", "rolled_back": created_ids}
return {"generated": len(wallets), "wallets": wallets}

View file

@ -13,14 +13,15 @@ from __future__ import annotations
import base64
import json
import logging
import os
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, Optional
from typing import Any, Iterator, Optional
from .config import cfg
@ -29,9 +30,10 @@ logger = logging.getLogger("wp.vault")
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
raise RuntimeError(
"cryptography is required. Install it: pip install cryptography>=42.0.0"
)
@dataclass
@ -54,23 +56,64 @@ class WalletEntry:
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._local = threading.local()
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 _conn(self) -> sqlite3.Connection:
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
self._local.conn.execute("PRAGMA journal_mode=WAL")
self._local.conn.execute("PRAGMA foreign_keys=ON")
return self._local.conn
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)
conn = self._init_conn()
conn.executescript("""
with self._new_conn() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
chain TEXT NOT NULL,
@ -104,18 +147,34 @@ class Vault:
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()
conn.commit()
self._migrate(conn)
def _init_conn(self):
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return 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:
if not HAS_CRYPTO or not cfg.vault_password:
return plaintext
salt = secrets.token_bytes(16)
kdf = Argon2id(salt, 32, 3, 4, 65536)
key = kdf.derive(cfg.vault_password.encode())
@ -125,8 +184,6 @@ class Vault:
return base64.b64encode(salt + nonce + ct).decode()
def decrypt_key(self, encrypted: str) -> str:
if not HAS_CRYPTO or not cfg.vault_password:
return encrypted
data = base64.b64decode(encrypted)
salt, nonce, ct = data[:16], data[16:28], data[28:]
kdf = Argon2id(salt, 32, 3, 4, 65536)
@ -135,133 +192,131 @@ class Vault:
return aesgcm.decrypt(nonce, ct, None).decode()
def put(self, wallet: WalletEntry) -> bool:
conn = self._conn()
try:
# Migration: add wallet_group column if it doesn't exist
with self._conn() as conn:
try:
conn.execute("ALTER TABLE wallets ADD COLUMN wallet_group TEXT DEFAULT ''")
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
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]:
conn = self._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]:
conn = self._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]:
conn = self._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]:
conn = self._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):
"""Update FTS index for a wallet."""
conn = self._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:
conn = self._conn()
conn.execute("DELETE FROM wallets WHERE id = ?", (wallet_id,))
conn.commit()
return conn.total_changes > 0
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:
conn = self._conn()
if chain:
return conn.execute("SELECT COUNT(*) FROM wallets WHERE chain = ?", (chain,)).fetchone()[0]
return conn.execute("SELECT COUNT(*) FROM wallets").fetchone()[0]
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 = ""):
conn = self._conn()
conn.execute(
"INSERT INTO rotations (wallet_id, new_address, rotated_at, reason) VALUES (?, ?, ?, ?)",
(wallet_id, new_address, time.time(), reason),
)
conn.commit()
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]:
conn = self._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]
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]:
conn = self._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],
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:

View file

@ -2,7 +2,7 @@
Endpoints:
POST /hosting/register Create account (free tier)
POST /hosting/login Get API key
POST /hosting/login Get API key (rate-limited per email)
GET /hosting/me Account details + usage
GET /hosting/plans Available plans + pricing
POST /hosting/subscribe Upgrade/downgrade plan (Stripe stub)
@ -13,13 +13,15 @@ Revenue model:
Starter: $29/mo, 10 chains, 500 wallets/day
Pro: $79/mo, 55 chains, unlimited
Enterprise: $199/mo, everything
P2-13: /hosting/login is rate-limited per email (exponential backoff).
"""
from __future__ import annotations
import logging
import time
from typing import Optional
from collections import defaultdict
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
@ -30,6 +32,45 @@ logger = logging.getLogger("wp.hosting")
router = APIRouter(prefix="/hosting", tags=["Hosted"])
# ── Login rate limiter ───────────────────────────────────────────────────────
# Sliding window per email. Backoff: 3 failures → 30s, 5 → 5min, 10 → 1h.
# Production should swap this for Redis-backed rate limiting, but the
# in-memory version is fine for single-instance deployments.
_LOGIN_ATTEMPTS: dict[str, list[float]] = defaultdict(list)
_LOGIN_LOCKOUT_UNTIL: dict[str, float] = {}
def _login_rate_limit_check(email: str) -> None:
"""Raise 429 if this email is in a lockout window."""
now = time.time()
until = _LOGIN_LOCKOUT_UNTIL.get(email, 0)
if until > now:
retry_after = int(until - now) + 1
raise HTTPException(
status_code=429,
detail=f"Too many failed login attempts. Try again in {retry_after}s.",
headers={"Retry-After": str(retry_after)},
)
# Trim attempts older than 1 hour
_LOGIN_ATTEMPTS[email] = [t for t in _LOGIN_ATTEMPTS[email] if now - t < 3600]
def _login_record_attempt(email: str, success: bool) -> None:
"""Record a login attempt; lockout if too many failures."""
now = time.time()
if success:
_LOGIN_ATTEMPTS.pop(email, None)
_LOGIN_LOCKOUT_UNTIL.pop(email, None)
return
_LOGIN_ATTEMPTS[email].append(now)
failures = len(_LOGIN_ATTEMPTS[email])
if failures >= 10:
_LOGIN_LOCKOUT_UNTIL[email] = now + 3600 # 1 hour
elif failures >= 5:
_LOGIN_LOCKOUT_UNTIL[email] = now + 300 # 5 min
elif failures >= 3:
_LOGIN_LOCKOUT_UNTIL[email] = now + 30 # 30 sec
class RegisterRequest(BaseModel):
email: str = Field(...)
@ -81,11 +122,15 @@ async def register(req: RegisterRequest):
@router.post("/login")
async def login(req: LoginRequest):
"""Login and get API key."""
async def login(req: LoginRequest, request: Request):
"""Login and get API key. Rate-limited per email."""
_login_rate_limit_check(req.email.lower())
host = get_hosting()
user = host.login(req.email, req.password)
if not user:
success = user is not None
_login_record_attempt(req.email.lower(), success)
if not success:
logger.info(f"Login failed for {req.email[:20]}... (rate-limit aware)")
raise HTTPException(status_code=401, detail="Invalid email or password")
return {
"user_id": user["id"],

View file

@ -120,12 +120,21 @@ def validate_mnemonic(mnemonic: str) -> str:
f"BIP39 requires 12, 15, 18, 21, or 24 words (got {len(words)})"
)
# P2-2 fix: bip_utils is REQUIRED for BIP39 validation. Previously the
# try/except silently passed, meaning garbage mnemonics would generate
# garbage wallets. Now we raise hard.
try:
from bip_utils import Bip39MnemonicValidator
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Mnemonic phrase failed BIP39 checksum validation. Check the word order and spelling.")
except ImportError:
pass
except ImportError as e:
raise RuntimeError(
"bip_utils is required for BIP39 mnemonic validation. "
"Install it: pip install bip-utils>=2.9.0"
) from e
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError(
"Mnemonic phrase failed BIP39 checksum validation. "
"Check the word order and spelling."
)
return mnemonic