walletpress/backend/agent/detector.py
Rug Munch Media LLC 85d8ef5eac
refactor(routers): split chain_vault.py god router — WP-085..WP-087
Extracted admin endpoints from chain_vault.py (2,178 lines) into
wallet_admin.py (768 lines). chain_vault.py is now 1,469 lines.

What moved to wallet_admin.py (29 routes):
- API keys: /api-keys, /api-keys/revoke
- Alerts: /alerts, /alerts/delete
- Webhooks: /webhooks, /webhooks/{id}, /webhooks/{id}/test,
  /webhooks/{id}/retry, /webhooks/deliveries
- Audit trail: /audit-trail
- Bulk ops: /bulk/filter, /bulk/delete, /bulk/export, /export
- 2FA: /admin/2fa/{setup,verify-setup,disable,status}
- Config: /config
- Proof of Generation: /proof/{commit,provenance,verify,roots,stats}

What stayed in chain_vault.py (32 routes):
- Chain metadata: /chains, /stats, /healthz, /health-score
- RPC config: /rpc-chains
- Wallet gen: /generate, /generate/batch, /generate/all,
  /import, /from-mnemonic, /derive-address, /hd-wallet
- Vault CRUD: /vault, /vault/{id}, /vault/{id}/full, DELETE
- Wallet ops: /tree, /cluster, /rotate, /rotate-sweep, /rotations,
  /distribute, /sweep, /escrow, /escrow/release, /paper-wallet, /tx
- Validation: /validate/{chain}/{address}, /validate/all
- Balances: /balances, /balances/snapshot
- PDF: /paper-wallet/{id}/pdf, /wallet/{id}/birth-certificate

Helpers extracted to _persistent_store.py:
- _PersistentStore class (webhooks/alerts/payments SQLite store)
  P3-7 fix: removed dead _webhooks global references in test/retry
  endpoints — now uses _PersistentStore.all('webhooks')

Added to wallet_admin.py:
- _require_totp() helper (also kept in chain_vault.py for the
  /vault/{id}/full endpoint that needs it)
- WebhookCreateRequest, BulkFilterRequest, BulkDeleteRequest models
  (these were inlined in original chain_vault.py body — now in
  the request schemas section)

P3-10 — WP plugin supported_chains() rewritten
  Plugin used to advertise chains the backend doesn't support
  ('bitcoin' vs backend 'btc', 'ethereum' vs 'eth', etc.). Rewrote
  to use correct backend keys + added a 'backend' field for clarity.
  Now matches ADDRESS_GENERATION.md truth table.

main.py: updated import + include_router for wallet_admin.router.

Test results: 80 passed, 5 skipped (no regressions).

Refs: AUDIT.md P2-16, P3-7, P3-10, P3-17
2026-06-30 21:40:45 +07:00

245 lines
8.4 KiB
Python

"""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