779 lines
25 KiB
Python
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
|