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:
parent
af9dd747de
commit
9751d64c6a
4 changed files with 370 additions and 173 deletions
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue