rmi-backend/app/fallback_engine.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

779 lines
25 KiB
Python

"""
RMI Fallback Data Engine - Maximum Data Retrieval
====================================================
When primary APIs fail, this engine tries EVERY publicly available method
to get the data before triggering a refund. Uses scraping, AI search,
browser automation, public archives, community APIs, and cached results.
Priority order:
1. Primary APIs (DexScreener, RPCs, etc.)
2. Secondary APIs (Birdeye, Jupiter, Coingecko, etc.)
3. Tertiary APIs (DeFiLlama, GeckoTerminal, DexLab, etc.)
4. Web scraping (Solscan, Etherscan, Basescan HTML)
5. Browser automation (JS-heavy sites: Pump.fun, RugCheck, etc.)
6. AI web search (search for token info, recent news, analysis)
7. Public data archives (BigQuery public crypto datasets)
8. Community/unofficial APIs (Jito, Orca, Raydium, etc.)
9. RSS/feeds (crypto news, launch trackers)
10. Social media (Twitter/X public posts for sentiment)
11. Cache layer (previous successful results, even if stale)
Author: RMI Development
Date: 2026-05-03
"""
import hashlib
import json
import logging
import re
import time
from html.parser import HTMLParser
logger = logging.getLogger("fallback_engine")
# ── Cache Layer ───────────────────────────────────────────────
class FallbackCache:
"""
Store and retrieve cached results from previous successful tool calls.
Even stale data is better than no data.
"""
def __init__(self, redis=None):
self.redis = redis
async def get(self, key: str, max_age_hours: int = 24) -> dict | None:
"""Get cached result if not too old."""
if not self.redis:
return None
try:
raw = await self.redis.get(f"x402:cache:{key}")
if not raw:
return None
data = json.loads(raw)
age = time.time() - data.get("cached_at", 0)
if age > max_age_hours * 3600:
return None # Too old
data["cache_age_hours"] = round(age / 3600, 1)
data["is_cached"] = True
return data
except Exception:
return None
async def set(self, key: str, data: dict, ttl: int = 86400):
"""Cache a result."""
if not self.redis:
return
try:
data["cached_at"] = time.time()
await self.redis.set(f"x402:cache:{key}", json.dumps(data), ex=ttl)
except Exception:
pass
def make_key(self, tool: str, **params) -> str:
"""Create a cache key from tool name and parameters."""
param_str = json.dumps(params, sort_keys=True)
return hashlib.md5(f"{tool}:{param_str}".encode()).hexdigest()[:16]
# ── Web Scraping ──────────────────────────────────────────────
class SimpleHTMLParser(HTMLParser):
"""Extract text content from HTML pages."""
def __init__(self):
super().__init__()
self.text = []
self.in_data = False
def handle_data(self, data):
stripped = data.strip()
if stripped:
self.text.append(stripped)
def handle_starttag(self, tag, attrs):
dict(attrs)
if tag in ["script", "style", "noscript"]:
self.in_data = False
elif "data-" in str(attrs) or tag in ["meta", "h1", "h2", "h3"]:
self.in_data = True
def handle_endtag(self, tag):
if tag in ["script", "style", "noscript"]:
self.in_data = False
async def fetch_page_text(url: str, timeout: int = 10) -> str | None:
"""Fetch and extract text from a web page."""
try:
import aiohttp
async with (
aiohttp.ClientSession() as session,
session.get(
url,
timeout=aiohttp.ClientTimeout(total=timeout),
headers={"User-Agent": "Mozilla/5.0 (compatible; RMI-Bot/1.0; +https://rugmunch.io/bot)"},
) as resp,
):
if resp.status == 200:
html = await resp.text()
parser = SimpleHTMLParser()
parser.feed(html)
return "\n".join(parser.text[:200]) # Limit to first 200 text blocks
except Exception as e:
logger.debug(f"Page fetch failed: {url} - {e}")
return None
async def scrape_solscan(address: str, chain: str = "solana") -> dict:
"""
Scrape Solscan public pages for token/wallet info.
Uses their public HTML pages (no API key needed).
"""
result = {}
urls = [
f"https://solscan.io/token/{address}",
f"https://solscan.io/account/{address}",
]
for url in urls:
text = await fetch_page_text(url)
if text:
# Extract common patterns
# Price patterns
price_match = re.search(r"\$[\d,]+\.?\d*", text)
if price_match:
result["scraped_price"] = price_match.group()
# Liquidity patterns
liq_match = re.search(r"liquidity.*?[\$]?([\d,]+)", text, re.IGNORECASE)
if liq_match:
result["scraped_liquidity"] = liq_match.group(1)
# Market cap
mcap_match = re.search(r"market cap.*?[\$]?([\d,]+)", text, re.IGNORECASE)
if mcap_match:
result["scraped_market_cap"] = mcap_match.group(1)
# Holders
holders_match = re.search(r"(\d[\d,]*)\s*holders?", text, re.IGNORECASE)
if holders_match:
result["scraped_holders"] = holders_match.group(1)
if result:
result["source"] = f"solscan:{url}"
return result
return {}
async def scrape_etherscan(address: str, chain: str = "ethereum") -> dict:
"""
Scrape Etherscan/Basescan public pages for contract info.
"""
result = {}
explorers = {
"ethereum": "https://etherscan.io",
"base": "https://basescan.org",
"bsc": "https://bscscan.com",
}
base_url = explorers.get(chain, "https://etherscan.io")
urls = [
f"{base_url}/token/{address}",
f"{base_url}/address/{address}",
]
for url in urls:
text = await fetch_page_text(url)
if text:
# Price
price_match = re.search(r"\$[\d,.]+", text)
if price_match:
result["scraped_price"] = price_match.group()
# Market cap
mcap_match = re.search(r"Market Cap.*?\$([\d,]+)", text)
if mcap_match:
result["scraped_market_cap"] = mcap_match.group(1)
# Verified status
if "contract source code verified" in text.lower():
result["verified"] = True
elif "not verified" in text.lower():
result["verified"] = False
# Token name/symbol from page title
title_match = re.search(r"<title>(.*?)</title>", text)
if title_match:
result["page_title"] = title_match.group(1)
if result:
result["source"] = f"{chain}_scan:{url}"
return result
return {}
async def scrape_dexscreener_web(address: str) -> dict:
"""
Scrape DexScreener web page for token info.
Their API sometimes blocks, but the web page is usually accessible.
"""
result = {}
url = f"https://dexscreener.com/solana/{address}"
text = await fetch_page_text(url)
if text:
# Extract price from page
price_match = re.search(r"\$([\d.]+(?:e-?\d+)?)", text)
if price_match:
result["scraped_price"] = price_match.group()
# Extract volume
vol_match = re.search(r"24h\s*Volume.*?\$([\d,]+)", text, re.IGNORECASE)
if vol_match:
result["scraped_volume_24h"] = vol_match.group(1)
# Extract liquidity
liq_match = re.search(r"Liquidity.*?\$([\d,]+)", text, re.IGNORECASE)
if liq_match:
result["scraped_liquidity"] = liq_match.group(1)
if result:
result["source"] = "dexscreener_web"
return result
return {}
# ── Tertiary/Alternative APIs ─────────────────────────────────
async def fetch_geckoterminal(address: str, chain: str = "solana") -> dict:
"""
GeckoTerminal API - free, no key required.
Similar to DexScreener, good backup.
"""
chain_map = {
"solana": "solana",
"ethereum": "eth",
"base": "base",
"bsc": "bsc",
}
gecko_chain = chain_map.get(chain, chain)
try:
import aiohttp
url = f"https://api.geckoterminal.com/api/v2/networks/{gecko_chain}/tokens/{address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10), headers={"Accept": "application/json"}) as resp,
):
if resp.status == 200:
data = await resp.json()
token = data.get("data", {}).get("attributes", {})
return {
"geckoterminal": {
"price_usd": token.get("price_usd"),
"market_cap_usd": token.get("market_cap_usd"),
"volume_usd_h24": token.get("volume_usd_h24"),
"name": token.get("name"),
"symbol": token.get("symbol"),
},
"source": "geckoterminal",
}
except Exception as e:
logger.debug(f"GeckoTerminal failed: {e}")
return {}
async def fetch_dexlab(address: str) -> dict:
"""
DexLab - Solana-specific DEX data.
"""
try:
import aiohttp
url = f"https://api.dexlab.space/v1/token/{address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp,
):
if resp.status == 200:
data = await resp.json()
return {
"dexlab": {
"price": data.get("price"),
"market_cap": data.get("marketCap"),
"volume": data.get("volume24h"),
},
"source": "dexlab",
}
except Exception:
pass
return {}
async def fetch_step_finance(address: str) -> dict:
"""
Step Finance - Solana DeFi analytics.
"""
try:
import aiohttp
url = f"https://data.api.step.finance/api/v1/tokens/{address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp,
):
if resp.status == 200:
data = await resp.json()
return {"step_finance": data, "source": "step_finance"}
except Exception:
pass
return {}
async def fetch_moonrank(address: str) -> dict:
"""
MoonRank - Solana token analytics.
"""
try:
import aiohttp
url = f"https://api.moonrank.app/tokens/{address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp,
):
if resp.status == 200:
data = await resp.json()
return {"moonrank": data, "source": "moonrank"}
except Exception:
pass
return {}
async def fetch_unchained(address: str) -> dict:
"""
Unchained Capital - alternative on-chain data.
"""
try:
import aiohttp
url = f"https://api.unchained.com/v1/tokens/{address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp,
):
if resp.status == 200:
data = await resp.json()
return {"unchained": data, "source": "unchained"}
except Exception:
pass
return {}
# ── Community/Unofficial APIs ─────────────────────────────────
async def fetch_jito_search(address: str) -> dict:
"""
Jito bundle search - check if token is being bundled (MEV activity).
"""
try:
import aiohttp
url = f"https://searcher.jito.network/api/v1/bundles?token={address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp,
):
if resp.status == 200:
data = await resp.json()
return {"jito_bundles": data, "source": "jito"}
except Exception:
pass
return {}
async def fetch_orca_pools(address: str) -> dict:
"""
Orca pools - check liquidity on Orca DEX.
"""
try:
import aiohttp
url = f"https://api.orca.so/v1/pools?token={address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp,
):
if resp.status == 200:
data = await resp.json()
return {"orca_pools": data, "source": "orca"}
except Exception:
pass
return {}
async def fetch_raydium_pools(address: str) -> dict:
"""
Raydium pools - check liquidity on Raydium DEX.
"""
try:
import aiohttp
url = f"https://api.raydium.io/v2/main/pairs?token={address}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp,
):
if resp.status == 200:
data = await resp.json()
return {"raydium_pools": data, "source": "raydium"}
except Exception:
pass
return {}
# ── Social Media / Sentiment Scraping ─────────────────────────
async def scrape_twitter_search(token: str) -> dict:
"""
Scrape public Twitter search for token mentions.
Uses Twitter's public search endpoint (no API key needed).
"""
result = {"mentions": [], "source": "twitter_search"}
try:
import aiohttp
url = f"https://api.allorigins.win/raw?url={__import__('urllib.parse').quote(f'https://twitter.com/search?q={token}&f=live')}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=15), headers={"User-Agent": "Mozilla/5.0"}) as resp,
):
if resp.status == 200:
text = await resp.text()
# Extract tweet text patterns
tweets = re.findall(r'"text":"([^"]*)"', text)
result["mentions"] = tweets[:10]
result["mention_count"] = len(tweets)
except Exception:
pass
return result
async def scrape_reddit_search(token: str) -> dict:
"""
Scrape Reddit for token discussions.
Uses Reddit's JSON API (public, no auth needed).
"""
result = {"posts": [], "source": "reddit"}
try:
import aiohttp
url = f"https://www.reddit.com/search.json?q={token}&sort=new&limit=10"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=15), headers={"User-Agent": "RMI-Bot/1.0"}) as resp,
):
if resp.status == 200:
data = await resp.json()
posts = data.get("data", {}).get("children", [])
result["posts"] = [
{"title": p["data"].get("title"), "score": p["data"].get("score")} for p in posts[:10]
]
result["post_count"] = len(result["posts"])
except Exception:
pass
return result
# ── RSS / News Feeds ─────────────────────────────────────────
async def fetch_crypto_news_feeds(token: str = "") -> dict:
"""
Fetch crypto news from public RSS feeds.
"""
feeds = [
"https://cryptopanic.com/api/free/posts/?filter=important",
"https://cointelegraph.com/rss",
"https://www.coindesk.com/arc/outboundfeeds/rss/",
]
result = {"articles": [], "source": "crypto_news"}
try:
import aiohttp
async with aiohttp.ClientSession() as session:
for feed_url in feeds[:1]: # Limit to first for speed
try:
async with session.get(
feed_url,
timeout=aiohttp.ClientTimeout(total=10),
headers={"User-Agent": "RMI-Bot/1.0"},
) as resp:
if resp.status == 200:
text = await resp.text()
# Extract titles
titles = re.findall(r"<title>(.*?)</title>", text)
result["articles"] = titles[1:11] # Skip feed title
except Exception:
continue
except Exception:
pass
return result
# ── AI Web Search (via Hermes Agent) ─────────────────────────
async def ai_web_search(query: str) -> dict:
"""
Use the Hermes agent's web search capability to find data.
This is a last resort but can find very specific info.
"""
result = {"results": [], "source": "ai_web_search"}
try:
# This would be called via delegate_task or similar
# For now, we'll use a simple Google search scrape
import aiohttp
url = f"https://html.duckduckgo.com/html/?q={__import__('urllib.parse').quote(query)}"
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=15), headers={"User-Agent": "Mozilla/5.0"}) as resp,
):
if resp.status == 200:
text = await resp.text()
# Extract result snippets
snippets = re.findall(r'<a class="result__url"[^>]*>(.*?)</a>', text)
result["results"] = snippets[:5]
except Exception as e:
logger.debug(f"AI web search failed: {e}")
return result
# ── Public Blockchain Data Archives ───────────────────────────
async def fetch_bigquery_public(address: str) -> dict:
"""
Check Google BigQuery public crypto datasets.
These are free public datasets with rich on-chain data.
"""
# Note: This requires BigQuery API access, which we may not have
# But we can check if there are any cached results
result = {"source": "bigquery_cache"}
try:
from main import get_redis
r = await get_redis()
cached = await r.get(f"x402:bigquery:{address[:10]}")
if cached:
result["cached_data"] = json.loads(cached)
except Exception:
pass
return result
# ── Master Fallback Orchestrator ──────────────────────────────
async def try_all_fallbacks(
tool: str,
address: str = "",
chain: str = "solana",
token: str = "",
url: str = "",
redis=None,
) -> dict:
"""
Master fallback orchestrator. Tries EVERY method before giving up.
Returns the best result found across all fallback layers.
"""
cache = FallbackCache(redis)
cache_key = cache.make_key(tool, address=address, chain=chain, token=token)
# 0. Check cache first
cached = await cache.get(cache_key)
if cached:
cached["fallback_layer"] = "cache"
cached["cache_age_hours"] = cached.get("cache_age_hours", 0)
return cached
results = {}
layers_tried = []
# 1. GeckoTerminal
if address:
geo = await fetch_geckoterminal(address, chain)
if geo:
results["geckoterminal"] = geo
layers_tried.append("geckoterminal")
# 2. DexLab (Solana)
if address and chain == "solana":
dex = await fetch_dexlab(address)
if dex:
results["dexlab"] = dex
layers_tried.append("dexlab")
# 3. Step Finance (Solana)
if address and chain == "solana":
step = await fetch_step_finance(address)
if step:
results["step_finance"] = step
layers_tried.append("step_finance")
# 4. MoonRank
if address:
mr = await fetch_moonrank(address)
if mr:
results["moonrank"] = mr
layers_tried.append("moonrank")
# 5. Solscan scraping
if address:
if chain == "solana":
scraped = await scrape_solscan(address, chain)
else:
scraped = await scrape_etherscan(address, chain)
if scraped:
results["web_scrape"] = scraped
layers_tried.append(f"web_scrape_{chain}")
# 6. DexScreener web
if address:
ds_web = await scrape_dexscreener_web(address)
if ds_web:
results["dexscreener_web"] = ds_web
layers_tried.append("dexscreener_web")
# 7. Raydium pools
if address and chain == "solana":
ray = await fetch_raydium_pools(address)
if ray:
results["raydium"] = ray
layers_tried.append("raydium")
# 8. Orca pools
if address and chain == "solana":
orc = await fetch_orca_pools(address)
if orc:
results["orca"] = orc
layers_tried.append("orca")
# 9. Jito bundles
if address and chain == "solana":
jito = await fetch_jito_search(address)
if jito:
results["jito"] = jito
layers_tried.append("jito")
# 10. Social sentiment
if token:
tw = await scrape_twitter_search(token)
if tw.get("mentions"):
results["twitter"] = tw
layers_tried.append("twitter")
rd = await scrape_reddit_search(token)
if rd.get("posts"):
results["reddit"] = rd
layers_tried.append("reddit")
# 11. News feeds
if token:
news = await fetch_crypto_news_feeds(token)
if news.get("articles"):
results["news"] = news
layers_tried.append("news")
# 12. AI web search (last resort)
if address or token:
search_query = f"{token or address} crypto token price analysis"
search = await ai_web_search(search_query)
if search.get("results"):
results["web_search"] = search
layers_tried.append("web_search")
# 13. BigQuery cache
if address:
bq = await fetch_bigquery_public(address)
if bq.get("cached_data"):
results["bigquery"] = bq
layers_tried.append("bigquery")
if not results:
return {
"fallback_layer": "none",
"error": "All fallback methods failed",
"layers_tried": layers_tried,
"recommendation": "refund",
}
# Merge all results into a single response
merged = {
"fallback_layer": "multi_source",
"sources_used": layers_tried,
"data_count": len(results),
"confidence": min(1.0, len(layers_tried) * 0.2), # More sources = more confidence
"best_price": None,
"best_liquidity": None,
}
# Extract best price from all sources
for source, data in results.items():
if source == "geckoterminal":
merged["best_price"] = data.get("geckoterminal", {}).get("price_usd", merged["best_price"])
merged["best_liquidity"] = data.get("geckoterminal", {}).get("volume_usd_h24")
elif source == "dexlab":
merged["best_price"] = data.get("dexlab", {}).get("price", merged["best_price"])
elif source == "web_scrape_solana":
merged["best_price"] = data.get("web_scrape", {}).get("scraped_price", merged["best_price"])
elif source == "dexscreener_web":
merged["best_price"] = data.get("dexscreener_web", {}).get("scraped_price", merged["best_price"])
# Store in cache
await cache.set(cache_key, merged, ttl=3600) # Cache for 1 hour
return merged
# ── Integration with existing tools ───────────────────────────
async def audit_with_fallback(address: str, chain: str, redis=None) -> dict:
"""Run audit with full fallback chain."""
from app.routers.x402_tools import _audit_evm, _audit_solana
result = {}
try:
if chain == "solana":
result = await _audit_solana(address)
else:
result = await _audit_evm(address, chain)
except Exception as e:
logger.warning(f"Primary audit failed: {e}")
# If primary failed or returned no data, use fallback
if not result or result.get("sources_used") == []:
fallback = await try_all_fallbacks("audit", address=address, chain=chain, redis=redis)
result.update(fallback)
result["was_primary_failed"] = True
else:
# Still try fallback to enrich data
fallback = await try_all_fallbacks("audit", address=address, chain=chain, redis=redis)
if fallback.get("fallback_layer") != "none":
result["enriched_by"] = fallback.get("sources_used", [])
result.update({k: v for k, v in fallback.items() if k not in result})
return result