RMI Platform commited on
Commit
ab145db
·
1 Parent(s): 9725810

feat: wire historical scrapers (Rekt DB, Chainabuse, TRM, SlowMist) into DataBus

Browse files

- Add historical_scraper_tool.py with DataBus chains for on-demand ingestion
- Register chains: historical_ingest, historical_sources, defi_hacks, scam_reports
- Add convenience endpoints: /historical/sources, /historical/ingest/{source_id}
- Fix test imports and add null checks
- Fill gap in RAG_MODERNIZATION.md: firehose sources now wired to DataBus

Sources available:
- rekt_db (3,000+ DeFi hacks since 2020)
- chainabuse (community scam reports with addresses)
- slowmist_hacked (detailed exploit analysis)
- rekt_news (in-depth post-mortems)
- immunefi (bug bounty reports)
- certik (audit findings)
- trm_crime_report (2026 crypto crime typologies)
- elliptic_scams (annual scam trends)

backend/app/databus/providers.py ADDED
The diff for this file is too large to render. See raw diff
 
backend/app/databus/router.py ADDED
@@ -0,0 +1,1207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DataBus Router — Unified API Endpoints
3
+ ========================================
4
+
5
+ Replaces the fractured caching_shield/router, cache_manager stats,
6
+ and all connector-specific endpoints with a single clean interface.
7
+
8
+ POST /api/v1/databus/fetch — Fetch data (any type)
9
+ GET /api/v1/databus/health — System health + cache stats
10
+ GET /api/v1/databus/capacity — Credit report + recommendations
11
+ GET /api/v1/databus/chains — List all provider chains
12
+ POST /api/v1/databus/invalidate — Clear cache (admin)
13
+ GET /api/v1/databus/vault/status — Key pool status (admin, no key values)
14
+ GET /api/v1/databus/vault/reload — Reload keys from vault (admin)
15
+ GET /api/v1/databus/fetch/{type} — GET convenience for simple queries
16
+ """
17
+
18
+ import logging
19
+ import os
20
+
21
+ from fastapi import APIRouter, HTTPException, Query, Request
22
+
23
+ from app.databus.core import databus
24
+
25
+
26
+ # Chains that legitimately require an address/token to return data.
27
+ # When called in list-mode (no address), return 200 with empty data (not 502)
28
+ # so the frontend can render an empty state instead of an error.
29
+ LIST_MODE_OK = {
30
+ "wallet_labels", "ohlcv", "scanner", "trades", "transfers", "holders",
31
+ "token_security", "cluster_map", "entity_intel", "wallet_forensics",
32
+ "rugcheck", "sophistication", "behavioral", "pnl", "portfolio",
33
+ "bubble_map", "early_buyers", "funding_trail", "counterparties",
34
+ "ct_rundown", "kol_track", "kol_profile", "whale_alerts", "insider_detect",
35
+ "mev_detect", "bundle_detect", "sniper_detect", "wash_trade_detect",
36
+ "copy_trade_detect", "bot_farm_detect", "fresh_wallet_analysis", "dev_finder",
37
+ # Newly added — all require address/token to return data
38
+ "dev_activity", "token_price", "token_security_check", "trader_profile",
39
+ "liquidity", "top_holders", "token_info", "market_data", "risk_score",
40
+ "audit", "compliance", "whale_transactions", "wallet_profile",
41
+ "token_transactions", "wallet_transactions", "address_labels", "entity_lookup",
42
+ "historical_ingest", "historical_sources", "defi_hacks", "scam_reports",
43
+ }
44
+
45
+ logger = logging.getLogger("databus.router")
46
+
47
+ router = APIRouter(prefix="/api/v1/databus", tags=["databus"])
48
+
49
+
50
+ def _verify_admin(request: Request) -> bool:
51
+ admin_key = os.getenv("ADMIN_API_KEY", "")
52
+ if not admin_key:
53
+ return True
54
+ provided = request.headers.get("X-Admin-Key", "")
55
+ return provided == admin_key
56
+
57
+
58
+ @router.post("/fetch")
59
+ async def databus_fetch(request: Request):
60
+ """
61
+ Universal data fetch endpoint.
62
+ Every response is packaged based on who's asking.
63
+ No raw data leaks — consumers only get what they're authorized for.
64
+
65
+ Body: {
66
+ "data_type": "token_price", # required
67
+ "admin_key": "", # for admin data
68
+ "force_fresh": false, # skip cache
69
+ "rag_index": false, # index in RAG
70
+ "consumer_type": "", # public_web/authenticated/premium/admin/mcp_tool/x402_paid
71
+ "tool_id": "", # MCP tool ID for scoping
72
+ "x402_tier": "", # x402 pricing tier: free/basic/premium/enterprise
73
+ "mint": "So111...", # type-specific params
74
+ "address": "0x...", # type-specific params
75
+ ...
76
+ }
77
+ """
78
+ body = await request.json()
79
+ data_type = body.pop("data_type", None)
80
+ if not data_type:
81
+ raise HTTPException(400, "data_type is required")
82
+
83
+ admin_key = body.pop("admin_key", "")
84
+ force_fresh = body.pop("force_fresh", False)
85
+ rag_index = body.pop("rag_index", False)
86
+ consumer_type = body.pop("consumer_type", "")
87
+ tool_id = body.pop("tool_id", "")
88
+ x402_tier = body.pop("x402_tier", "")
89
+
90
+ result = await databus.fetch(
91
+ data_type=data_type,
92
+ admin_key=admin_key,
93
+ force_fresh=force_fresh,
94
+ rag_index=rag_index,
95
+ consumer_type=consumer_type,
96
+ tool_id=tool_id,
97
+ x402_tier=x402_tier,
98
+ request=request,
99
+ **body,
100
+ )
101
+ if result is None:
102
+ if data_type in LIST_MODE_OK:
103
+ return {"data": [], "results": [], "empty": True, "reason": "address_required", "data_type": data_type}
104
+ raise HTTPException(502, f"No data available for {data_type}")
105
+ return result
106
+
107
+
108
+ @router.get("/fetch/{data_type}")
109
+ async def databus_fetch_get(
110
+ data_type: str,
111
+ request: Request,
112
+ admin_key: str = Query(default=""),
113
+ force_fresh: bool = Query(default=False),
114
+ ):
115
+ """GET convenience for simple queries. Params passed as query args."""
116
+ # Collect all query params except the known ones
117
+ kwargs = {}
118
+ for key, value in request.query_params.items():
119
+ if key not in ("admin_key", "force_fresh"):
120
+ kwargs[key] = value
121
+
122
+ result = await databus.fetch(data_type=data_type, admin_key=admin_key, force_fresh=force_fresh, **kwargs)
123
+ if result is None:
124
+ if data_type in LIST_MODE_OK:
125
+ return {"data": [], "results": [], "empty": True, "reason": "address_required", "data_type": data_type}
126
+ raise HTTPException(502, f"No data available for {data_type}")
127
+ return result
128
+
129
+
130
+ @router.get("/health")
131
+ async def databus_health():
132
+ """Full system health: cache, vault, chains, stats."""
133
+ return await databus.health()
134
+
135
+
136
+ @router.get("/capacity")
137
+ async def databus_capacity():
138
+ """Credit report and recommendations for free tier expansion."""
139
+ if not databus._initialized:
140
+ await databus.initialize()
141
+ return databus.capacity_report()
142
+
143
+
144
+ @router.get("/chains")
145
+ async def databus_chains():
146
+ """List all available data types and their provider fallback chains."""
147
+ if not databus._initialized:
148
+ await databus.initialize()
149
+ return databus.list_chains()
150
+
151
+
152
+ @router.get("/access-matrix")
153
+ async def databus_access_matrix(request: Request):
154
+ """View the full access control matrix. Admin key required."""
155
+ if not _verify_admin(request):
156
+ from app.databus.access_control import access_controller
157
+
158
+ # Non-admin sees a limited view — only their tier's access
159
+ return {"note": "Full matrix requires admin key. Your access depends on your consumer type."}
160
+ from app.databus.access_control import access_controller
161
+
162
+ return access_controller.list_access_matrix()
163
+
164
+
165
+ @router.get("/mcp-scope/{tool_id}")
166
+ async def databus_mcp_scope(tool_id: str):
167
+ """Get the data types an MCP tool is authorized to access."""
168
+ from app.databus.access_control import access_controller
169
+
170
+ allowed = access_controller.get_mcp_allowed_types(tool_id)
171
+ if not allowed:
172
+ return {"tool_id": tool_id, "allowed_types": [], "note": "Unknown tool or no scope defined"}
173
+ return {"tool_id": tool_id, "allowed_types": allowed}
174
+
175
+
176
+ @router.get("/x402-scope/{tier}")
177
+ async def databus_x402_scope(tier: str):
178
+ """Get the data types an x402 pricing tier can access."""
179
+ from app.databus.access_control import access_controller
180
+
181
+ allowed = access_controller.get_x402_allowed_types(tier)
182
+ return {"tier": tier, "allowed_types": allowed}
183
+
184
+
185
+ @router.post("/invalidate")
186
+ async def databus_invalidate(request: Request):
187
+ """Clear cache. Admin key required."""
188
+ if not _verify_admin(request):
189
+ raise HTTPException(401, "Admin key required")
190
+
191
+ body = await request.json() if request.headers.get("content-type") == "application/json" else {}
192
+ data_type = body.get("data_type")
193
+
194
+ if data_type:
195
+ await databus.invalidate(data_type, **{k: v for k, v in body.items() if k != "data_type"})
196
+ return {"status": "invalidated", "data_type": data_type}
197
+ else:
198
+ await databus.invalidate_all()
199
+ return {"status": "all_cache_cleared"}
200
+
201
+
202
+ @router.get("/vault/status")
203
+ async def databus_vault_status(request: Request):
204
+ """Key pool status. NEVER exposes key values. Admin key required."""
205
+ if not _verify_admin(request):
206
+ raise HTTPException(401, "Admin key required")
207
+
208
+ if not databus.vault:
209
+ await databus.initialize()
210
+ return databus.vault.status()
211
+
212
+
213
+ @router.get("/vault/capacity")
214
+ async def databus_vault_capacity(request: Request):
215
+ """Detailed capacity report with free tier recommendations. Admin key required."""
216
+ if not _verify_admin(request):
217
+ raise HTTPException(401, "Admin key required")
218
+
219
+ if not databus.vault:
220
+ await databus.initialize()
221
+ return databus.vault.capacity_report()
222
+
223
+
224
+ @router.post("/vault/reload")
225
+ async def databus_vault_reload(request: Request):
226
+ """Reload keys from vault without restart. Admin key required."""
227
+ if not _verify_admin(request):
228
+ raise HTTPException(401, "Admin key required")
229
+
230
+ await databus.vault.reload()
231
+ return {"status": "reloaded", "providers": len(databus.vault.pools)}
232
+
233
+
234
+ @router.post("/vault/add-key")
235
+ async def databus_vault_add_key(request: Request):
236
+ """Hot-add an API key to a provider pool. Admin key required."""
237
+ if not _verify_admin(request):
238
+ raise HTTPException(401, "Admin key required")
239
+
240
+ body = await request.json()
241
+ provider = body.get("provider")
242
+ key_name = body.get("key_name")
243
+ key_value = body.get("key_value")
244
+
245
+ if not all([provider, key_name, key_value]):
246
+ raise HTTPException(400, "provider, key_name, key_value required")
247
+
248
+ await databus.vault.add_key(provider, key_name, key_value)
249
+ return {"status": "added", "provider": provider, "key_name": key_name}
250
+
251
+
252
+ @router.post("/vault/reset-monthly")
253
+ async def databus_vault_reset_monthly(request: Request):
254
+ """Reset monthly call counters. Admin key required."""
255
+ if not _verify_admin(request):
256
+ raise HTTPException(401, "Admin key required")
257
+
258
+ databus.vault.reset_monthly_counters()
259
+ return {"status": "monthly_counters_reset"}
260
+
261
+
262
+ # ── Convenience endpoints for Arkham Intelligence ──
263
+
264
+
265
+ @router.get("/arkham/entity/{address}")
266
+ async def arkham_entity(address: str, request: Request):
267
+ """Resolve a wallet address to an Arkham entity. Admin key required."""
268
+ admin_key = request.headers.get("X-Admin-Key", "")
269
+ result = await databus.fetch("entity_intel", address=address, admin_key=admin_key)
270
+ if result is None:
271
+ raise HTTPException(502, f"Could not resolve entity for {address}")
272
+ return result
273
+
274
+
275
+ @router.get("/arkham/labels")
276
+ async def arkham_labels(request: Request):
277
+ """Look up labels for addresses. Admin key required for Arkham, public for local."""
278
+ admin_key = request.headers.get("X-Admin-Key", "")
279
+ address = request.query_params.get("address", "")
280
+ result = await databus.fetch("wallet_labels", address=address, admin_key=admin_key)
281
+ if result is None:
282
+ raise HTTPException(502, "No labels found")
283
+ return result
284
+
285
+
286
+ @router.get("/arkham/portfolio/{address}")
287
+ async def arkham_portfolio(address: str, request: Request):
288
+ """Get portfolio for an entity. Admin key required."""
289
+ admin_key = request.headers.get("X-Admin-Key", "")
290
+ result = await databus.fetch("arkham_portfolio", address=address, admin_key=admin_key)
291
+ if result is None:
292
+ raise HTTPException(502, f"Could not get portfolio for {address}")
293
+ return result
294
+
295
+
296
+ # ── New convenience endpoints for expanded data types ──
297
+
298
+
299
+ @router.get("/token/{mint}")
300
+ async def token_detail(mint: str, request: Request):
301
+ """Rich token detail (metadata + price + security)."""
302
+ result = await databus.fetch("token_detail", mint=mint)
303
+ if result is None:
304
+ raise HTTPException(502, f"No data for token {mint}")
305
+ return result
306
+
307
+
308
+ @router.get("/wallet/{address}/tokens")
309
+ async def wallet_tokens(address: str, request: Request):
310
+ """Wallet token holdings."""
311
+ result = await databus.fetch("wallet_tokens", address=address)
312
+ if result is None:
313
+ raise HTTPException(502, f"No token data for wallet {address}")
314
+ return result
315
+
316
+
317
+ @router.get("/wallet/{address}/pnl")
318
+ async def wallet_pnl(address: str, request: Request):
319
+ """Wallet profit/loss."""
320
+ result = await databus.fetch("wallet_pnl", address=address)
321
+ if result is None:
322
+ raise HTTPException(502, f"No PnL data for wallet {address}")
323
+ return result
324
+
325
+
326
+ @router.get("/bubble-map/{address}")
327
+ async def bubble_map(address: str, request: Request, chain: str = "solana", depth: int = 2):
328
+ """Interactive holder bubble map."""
329
+ result = await databus.fetch("bubble_map", address=address, chain=chain, depth=depth)
330
+ if result is None:
331
+ raise HTTPException(502, f"No bubble map data for {address}")
332
+ return result
333
+
334
+
335
+ @router.get("/rugmaps/{address}")
336
+ async def rugmaps_analysis(address: str, request: Request, chain: str = "solana"):
337
+ """RugMaps AI analysis + similar scam search."""
338
+ result = await databus.fetch("rugmaps_analysis", address=address, chain=chain)
339
+ if result is None:
340
+ raise HTTPException(502, f"No RugMaps analysis for {address}")
341
+ return result
342
+
343
+
344
+ @router.get("/threat/{address}")
345
+ async def threat_check(address: str, request: Request, chain_id: str = "1"):
346
+ """Threat intelligence check (CryptoScamDB + GoPlus + Januus)."""
347
+ result = await databus.fetch("threat_check", address=address, chain_id=chain_id)
348
+ if result is None:
349
+ raise HTTPException(502, f"No threat data for {address}")
350
+ return result
351
+
352
+
353
+ @router.get("/contract-scan/{address}")
354
+ async def contract_scan(address: str, request: Request, chain: str = "base"):
355
+ """Smart contract deep scan (Slither + Mythril)."""
356
+ result = await databus.fetch("contract_scan", address=address, chain=chain)
357
+ if result is None:
358
+ raise HTTPException(502, f"No contract scan data for {address}")
359
+ return result
360
+
361
+
362
+ @router.get("/sentinel-deep/{address}")
363
+ async def sentinel_deep(address: str, request: Request, chain: str = "solana"):
364
+ """Deep SENTINEL scan + threat feeds + contract analysis."""
365
+ admin_key = request.headers.get("X-Admin-Key", "")
366
+ result = await databus.fetch("sentinel_deep", address=address, chain=chain, admin_key=admin_key)
367
+ if result is None:
368
+ raise HTTPException(502, f"No deep scan data for {address}")
369
+ return result
370
+
371
+
372
+ @router.get("/prediction-markets")
373
+ async def prediction_markets(request: Request, query: str = "", category: str = ""):
374
+ """Prediction market intel (Polymarket + Kalshi)."""
375
+ result = await databus.fetch("prediction_markets", query=query, category=category)
376
+ if result is None:
377
+ raise HTTPException(502, "No prediction market data available")
378
+ return result
379
+
380
+
381
+ @router.get("/prediction-signals")
382
+ async def prediction_signals(request: Request):
383
+ """Auto-detected trading signals from prediction markets."""
384
+ result = await databus.fetch("prediction_signals")
385
+ if result is None:
386
+ raise HTTPException(502, "No prediction signals available")
387
+ return result
388
+
389
+
390
+ # ═══════════════════════════════════════════════════════════════════════════════
391
+ # NEW: Batch, Cache Control, Provider Health, Schema Validation (#7,#8,#9)
392
+ # ═══════════════════════════════════════════════════════════════════════════════
393
+
394
+
395
+ @router.post("/batch")
396
+ async def databus_batch(request: Request):
397
+ """#7 Batch DataBus — fetch multiple data types in one call.
398
+
399
+ Body: {
400
+ "requests": [
401
+ {"data_type": "token_price", "mint": "So111..."},
402
+ {"data_type": "wallet_labels", "address": "0x..."},
403
+ ...
404
+ ],
405
+ "admin_key": "", # optional, applies to all
406
+ "consumer_type": "", # optional, applies to all
407
+ }
408
+ Returns: array of results in same order as requests.
409
+ """
410
+ body = await request.json()
411
+ requests_list = body.get("requests", [])
412
+ if not requests_list:
413
+ raise HTTPException(400, "requests array is required")
414
+ if len(requests_list) > 20:
415
+ raise HTTPException(400, "Maximum 20 requests per batch")
416
+
417
+ admin_key = body.get("admin_key", "")
418
+ consumer_type = body.get("consumer_type", "")
419
+
420
+ # Inject shared params into each request
421
+ for req in requests_list:
422
+ if admin_key and "admin_key" not in req:
423
+ req["admin_key"] = admin_key
424
+ if consumer_type and "consumer_type" not in req:
425
+ req["consumer_type"] = consumer_type
426
+ req["request"] = request
427
+
428
+ results = await databus.fetch_batch(requests_list)
429
+ return {"results": results, "count": len(results)}
430
+
431
+
432
+ @router.post("/warm/start")
433
+ async def databus_warm_start(request: Request):
434
+ """Start cache warm background task. Admin key required."""
435
+ if not _verify_admin(request):
436
+ raise HTTPException(401, "Admin key required")
437
+ body = await request.json() if request.headers.get("content-type") == "application/json" else {}
438
+ interval = body.get("interval_seconds", 30)
439
+ await databus.start_cache_warm(interval_seconds=interval)
440
+ return {"status": "started", "interval_seconds": interval}
441
+
442
+
443
+ @router.post("/warm/stop")
444
+ async def databus_warm_stop(request: Request):
445
+ """Stop cache warm background task. Admin key required."""
446
+ if not _verify_admin(request):
447
+ raise HTTPException(401, "Admin key required")
448
+ await databus.stop_cache_warm()
449
+ return {"status": "stopped"}
450
+
451
+
452
+ @router.get("/providers/health")
453
+ async def databus_providers_health(request: Request):
454
+ """#5 Provider Health Dashboard — per-provider success/failure/latency/circuit status."""
455
+ if not _verify_admin(request):
456
+ raise HTTPException(401, "Admin key required")
457
+ if not databus._initialized:
458
+ await databus.initialize()
459
+ return {
460
+ "provider_health": databus._provider_health.all_health(),
461
+ "deduplication": databus._dedup.stats(),
462
+ }
463
+
464
+
465
+ @router.get("/cache/stats")
466
+ async def databus_cache_stats(request: Request):
467
+ """#10 Per-type cache stats with TTL tuning suggestions."""
468
+ if not _verify_admin(request):
469
+ raise HTTPException(401, "Admin key required")
470
+ return {
471
+ "per_type": databus.cache.type_stats(),
472
+ "tuning_alerts": [
473
+ {"data_type": dt, **stats} for dt, stats in databus.cache.type_stats().items() if stats.get("suggestion")
474
+ ],
475
+ }
476
+
477
+
478
+ @router.post("/schema/validate")
479
+ async def databus_schema_validate(request: Request):
480
+ """#15 Validate a DataBus response against expected schema.
481
+
482
+ Body: {"data_type": "token_price", "data": {"price_usd": 1.23, ...}}
483
+ Returns: {valid: bool, missing_fields: [...]}
484
+ """
485
+ body = await request.json()
486
+ data_type = body.get("data_type", "")
487
+ data = body.get("data", {})
488
+ from app.databus.response_schema import schema_validator
489
+
490
+ is_valid, missing = schema_validator.validate(data_type, data)
491
+ return {"data_type": data_type, "valid": is_valid, "missing_fields": missing}
492
+
493
+
494
+ # ── Social Data Endpoints ─────────────────────────────────────────
495
+
496
+
497
+ @router.get("/social/x/profile/{username}")
498
+ async def social_x_profile(username: str):
499
+ """Get X/Twitter user profile — cached 24h."""
500
+ from app.databus.social import SocialDataAggregator
501
+
502
+ agg = SocialDataAggregator(databus.cache)
503
+ result = await agg.x.get_user(username)
504
+ if not result:
505
+ raise HTTPException(404, f"User @{username} not found or API unavailable")
506
+ return {"source": "x_twitter", "username": username, "data": result}
507
+
508
+
509
+ @router.get("/social/x/tweets/{username}")
510
+ async def social_x_tweets(username: str, count: int = Query(20, ge=1, le=100)):
511
+ """Get recent tweets from user — cached 15min."""
512
+ from app.databus.social import SocialDataAggregator
513
+
514
+ agg = SocialDataAggregator(databus.cache)
515
+ result = await agg.get_our_tweets(count=count) if username.lower() == "cryptorugmunch" else None
516
+ if result is None:
517
+ # Generic user lookup
518
+ profile = await agg.x.get_user(username)
519
+ if profile:
520
+ result = await agg.x.get_user_tweets(profile["id"], max_results=count)
521
+ if not result:
522
+ raise HTTPException(404, f"Tweets for @{username} not available")
523
+ return {"source": "x_twitter", "username": username, "count": len(result), "data": result}
524
+
525
+
526
+ @router.get("/social/x/mentions")
527
+ async def social_x_mentions(count: int = Query(20, ge=1, le=100)):
528
+ """Get mentions of @CryptoRugMunch — cached 15min."""
529
+ from app.databus.social import SocialDataAggregator
530
+
531
+ agg = SocialDataAggregator(databus.cache)
532
+ result = await agg.get_our_mentions(count=count)
533
+ if not result:
534
+ raise HTTPException(404, "Mentions not available (API budget exhausted)")
535
+ return {"source": "x_twitter", "count": len(result), "data": result}
536
+
537
+
538
+ @router.get("/social/x/engagement")
539
+ async def social_x_engagement(tweet_ids: str = Query(..., description="Comma-separated tweet IDs")):
540
+ """Get engagement metrics for tweets — cached 1h."""
541
+ from app.databus.social import SocialDataAggregator
542
+
543
+ agg = SocialDataAggregator(databus.cache)
544
+ ids = [tid.strip() for tid in tweet_ids.split(",")[:100]]
545
+ result = await agg.x.get_engagement_metrics(ids)
546
+ return {"source": "x_twitter", "count": len(result), "data": result}
547
+
548
+
549
+ @router.get("/social/x/search")
550
+ async def social_x_search(q: str = Query(..., description="Search query"), count: int = Query(10, ge=1, le=100)):
551
+ """Search X/Twitter — VERY expensive, cached 24h. Use sparingly."""
552
+ from app.databus.social import SocialDataAggregator
553
+
554
+ agg = SocialDataAggregator(databus.cache)
555
+ result = await agg.search_mentions(q, count=count)
556
+ if not result:
557
+ raise HTTPException(404, "Search results not available (API budget exhausted or no results)")
558
+ return {"source": "x_twitter", "query": q, "count": len(result), "data": result}
559
+
560
+
561
+ @router.get("/social/kol/{username}")
562
+ async def social_kol_reputation(username: str):
563
+ """KOL reputation score — cached 24h."""
564
+ from app.databus.social import SocialDataAggregator
565
+
566
+ agg = SocialDataAggregator(databus.cache)
567
+ return await agg.get_kol_reputation(username)
568
+
569
+
570
+ @router.get("/social/sentiment")
571
+ async def social_sentiment(username: str = Query("CryptoRugMunch")):
572
+ """Brand sentiment analysis — cached 1h."""
573
+ from app.databus.social import SocialDataAggregator
574
+
575
+ agg = SocialDataAggregator(databus.cache)
576
+ return await agg.get_sentiment(username)
577
+
578
+
579
+ @router.get("/social/x/budget")
580
+ async def social_x_budget():
581
+ """Current X API read budget status."""
582
+ from app.databus.social import XTwitterProvider
583
+
584
+ provider = XTwitterProvider(databus.cache)
585
+ return {
586
+ "daily_reads_used": provider._daily_reads,
587
+ "daily_reads_remaining": X_DAILY_READ_BUDGET - provider._daily_reads,
588
+ "monthly_free_limit": X_FREE_MONTHLY_READ_LIMIT,
589
+ "daily_budget": X_DAILY_READ_BUDGET,
590
+ "note": "X Free tier: 10k reads/month. Cache aggressively.",
591
+ }
592
+
593
+
594
+ X_HANDLE = "CryptoRugMunch"
595
+
596
+
597
+ @router.get("/social/x/discover")
598
+ async def social_x_discover(handle: str = Query(X_HANDLE), limit: int = Query(50, ge=1, le=100)):
599
+ """Discover tweets via web search — no API needed, works for free."""
600
+ from app.databus.social_scraper import XWebScraper
601
+
602
+ scraper = XWebScraper(databus.cache)
603
+ tweets = await scraper.discover_tweets(handle=handle, limit=limit)
604
+ return {"source": "web_search", "handle": handle, "count": len(tweets), "data": tweets}
605
+
606
+
607
+ @router.get("/social/x/engagement-report")
608
+ async def social_x_engagement_report(handle: str = Query(X_HANDLE)):
609
+ """Get engagement report for a handle — avg likes, best tweets, posting frequency."""
610
+ from app.databus.social_scraper import XWebScraper
611
+
612
+ scraper = XWebScraper(databus.cache)
613
+ report = await scraper.get_engagement_report(handle=handle)
614
+ return report
615
+
616
+
617
+ @router.get("/social/x/mentions-discover")
618
+ async def social_x_mentions_discover(handle: str = Query(X_HANDLE), limit: int = Query(20, ge=1, le=50)):
619
+ """Find tweets mentioning a handle — via web search."""
620
+ from app.databus.social_scraper import XWebScraper
621
+
622
+ scraper = XWebScraper(databus.cache)
623
+ mentions = await scraper.get_mentions(handle=handle, limit=limit)
624
+ return {"source": "web_search", "handle": handle, "count": len(mentions), "data": mentions}
625
+
626
+
627
+ @router.get("/social/x/trending")
628
+ async def social_x_trending():
629
+ """Get current crypto trending topics from web search."""
630
+ from app.databus.social_scraper import XWebScraper
631
+
632
+ scraper = XWebScraper(databus.cache)
633
+ topics = await scraper.get_trending_topics()
634
+ return {"source": "web_search", "count": len(topics), "data": topics}
635
+
636
+
637
+ # ── Bitquery Blockchain Data Endpoints ───────────────────────────
638
+
639
+
640
+ @router.get("/bitquery/health")
641
+ async def bitquery_health():
642
+ """Check Bitquery provider health and billing status."""
643
+ from app.databus.bitquery_provider import BitqueryProvider
644
+
645
+ provider = BitqueryProvider(databus.cache)
646
+ return await provider.health()
647
+
648
+
649
+ @router.get("/bitquery/token-price/{network}/{token_address}")
650
+ async def bitquery_token_price(network: str, token_address: str):
651
+ """Get DEX token price from Bitquery."""
652
+ from app.databus.bitquery_provider import BitqueryProvider
653
+
654
+ provider = BitqueryProvider(databus.cache)
655
+ result = await provider.get_token_price(network, token_address)
656
+ if result and "error" in result:
657
+ raise HTTPException(status_code=503, detail=result)
658
+ return {"source": "bitquery", "network": network, "token": token_address, "data": result}
659
+
660
+
661
+ @router.get("/bitquery/holders/{network}/{token_address}")
662
+ async def bitquery_holders(network: str, token_address: str, limit: int = Query(100, ge=1, le=500)):
663
+ """Get token holder distribution from Bitquery."""
664
+ from app.databus.bitquery_provider import BitqueryProvider
665
+
666
+ provider = BitqueryProvider(databus.cache)
667
+ result = await provider.get_holder_distribution(network, token_address, limit)
668
+ if result and "error" in result:
669
+ raise HTTPException(status_code=503, detail=result)
670
+ return {"source": "bitquery", "network": network, "token": token_address, "data": result}
671
+
672
+
673
+ @router.get("/bitquery/tx-trace/{network}/{tx_hash}")
674
+ async def bitquery_tx_trace(network: str, tx_hash: str):
675
+ """Get full transaction trace from Bitquery."""
676
+ from app.databus.bitquery_provider import BitqueryProvider
677
+
678
+ provider = BitqueryProvider(databus.cache)
679
+ result = await provider.get_transaction_trace(network, tx_hash)
680
+ if result and "error" in result:
681
+ raise HTTPException(status_code=503, detail=result)
682
+ return {"source": "bitquery", "network": network, "tx": tx_hash, "data": result}
683
+
684
+
685
+ @router.get("/bitquery/dex-volume/{network}")
686
+ async def bitquery_dex_volume(network: str, pool: str = Query(None), timeframe: str = Query("24h")):
687
+ """Get DEX trading volume from Bitquery."""
688
+ from app.databus.bitquery_provider import BitqueryProvider
689
+
690
+ provider = BitqueryProvider(databus.cache)
691
+ result = await provider.get_dex_volume(network, pool, timeframe)
692
+ if result and "error" in result:
693
+ raise HTTPException(status_code=503, detail=result)
694
+ return {
695
+ "source": "bitquery",
696
+ "network": network,
697
+ "pool": pool,
698
+ "timeframe": timeframe,
699
+ "data": result,
700
+ }
701
+
702
+
703
+ @router.get("/bitquery/balance/{network}/{address}")
704
+ async def bitquery_balance(network: str, address: str):
705
+ """Get address token balances from Bitquery."""
706
+ from app.databus.bitquery_provider import BitqueryProvider
707
+
708
+ provider = BitqueryProvider(databus.cache)
709
+ result = await provider.get_address_balance(network, address)
710
+ if result and "error" in result:
711
+ raise HTTPException(status_code=503, detail=result)
712
+ return {"source": "bitquery", "network": network, "address": address, "data": result}
713
+
714
+
715
+ @router.get("/bitquery/cross-chain/{address}")
716
+ async def bitquery_cross_chain(address: str, networks: str = Query("ethereum,bsc,solana")):
717
+ """Track token transfers across chains from Bitquery."""
718
+ from app.databus.bitquery_provider import BitqueryProvider
719
+
720
+ provider = BitqueryProvider(databus.cache)
721
+ net_list = [n.strip() for n in networks.split(",")]
722
+ result = await provider.get_cross_chain_transfers(address, net_list)
723
+ if result and "error" in result:
724
+ raise HTTPException(status_code=503, detail=result)
725
+ return {"source": "bitquery", "address": address, "networks": net_list, "data": result}
726
+
727
+
728
+ @router.get("/bitquery/contract-events/{network}/{contract}")
729
+ async def bitquery_contract_events(
730
+ network: str, contract: str, event: str = Query(None), limit: int = Query(50, ge=1, le=200)
731
+ ):
732
+ """Get smart contract events from Bitquery."""
733
+ from app.databus.bitquery_provider import BitqueryProvider
734
+
735
+ provider = BitqueryProvider(databus.cache)
736
+ result = await provider.get_smart_contract_events(network, contract, event, limit)
737
+ if result and "error" in result:
738
+ raise HTTPException(status_code=503, detail=result)
739
+ return {"source": "bitquery", "network": network, "contract": contract, "data": result}
740
+
741
+
742
+ # ═══════════════════════════════════════════════════════════════════════════════
743
+ # INTELLIGENT WEBHOOK SYSTEM — Arkham, Helius, Moralis, Alchemy + custom
744
+ # ═══════════════════════════════════════════════════════════════════════════════
745
+
746
+
747
+ @router.post("/webhooks/{service}")
748
+ async def receive_webhook(service: str, request: Request):
749
+ """Universal webhook receiver. Routes to correct handler based on service.
750
+
751
+ Supported: arkham, helius, moralis, alchemy, custom
752
+
753
+ Auto-validates signatures, deduplicates, caches in DataBus,
754
+ indexes in RAG, triggers premium scanner, pushes alerts.
755
+ """
756
+ try:
757
+ from app.databus.webhooks import handle_webhook
758
+ except ImportError:
759
+ raise HTTPException(501, "Webhook system not available")
760
+
761
+ raw_body = await request.body()
762
+ headers = dict(request.headers)
763
+
764
+ try:
765
+ payload = await request.json()
766
+ except:
767
+ payload = {}
768
+
769
+ result = await handle_webhook(service, payload, headers, raw_body)
770
+
771
+ if "error" in result and result.get("status") != "duplicate":
772
+ raise HTTPException(400, result["error"])
773
+
774
+ return result
775
+
776
+
777
+ @router.get("/webhooks")
778
+ async def list_webhooks_endpoint():
779
+ """List all recent webhook events."""
780
+ try:
781
+ from app.databus.webhooks import list_webhooks
782
+
783
+ return await list_webhooks()
784
+ except ImportError:
785
+ raise HTTPException(501, "Webhook system not available")
786
+
787
+
788
+ @router.post("/webhooks/setup/{service}")
789
+ async def setup_webhook_endpoint(service: str, request: Request):
790
+ """Programmatically register a webhook with a service.
791
+
792
+ Body: {
793
+ "webhook_url": "https://rugmunch.io/api/v1/databus/webhooks/helius",
794
+ "events": ["transaction", "token_transfer"],
795
+ "addresses": ["0x..."],
796
+ "api_key": "optional-override"
797
+ }
798
+ """
799
+ try:
800
+ from app.databus.webhooks import setup_webhook
801
+ except ImportError:
802
+ raise HTTPException(501, "Webhook system not available")
803
+
804
+ body = await request.json()
805
+ result = await setup_webhook(
806
+ service=service,
807
+ webhook_url=body.get("webhook_url", ""),
808
+ events=body.get("events"),
809
+ api_key=body.get("api_key", ""),
810
+ addresses=body.get("addresses", []),
811
+ chains=body.get("chains", []),
812
+ )
813
+ return result
814
+
815
+
816
+ # ═══════════════════════════════════════════════════════════════════════════════
817
+ # PREMIUM SCANNER ENDPOINTS
818
+ # ═══════════════════════════════════════════════════════════════════════════════
819
+
820
+
821
+ @router.get("/premium/bundles/{address}")
822
+ async def premium_bundle_detect(address: str, chain: str = "solana", request: Request = None):
823
+ """Bubblemaps-style bundle detection. Premium tier."""
824
+ return await databus.fetch("bundle_detect", address=address, chain=chain)
825
+
826
+
827
+ @router.get("/premium/clusters/{address}")
828
+ async def premium_cluster_map(address: str, chain: str = "solana", depth: int = 3, request: Request = None):
829
+ """Full wallet cluster mapping with graph-ready nodes/edges. Premium tier."""
830
+ return await databus.fetch("cluster_map", address=address, chain=chain, depth=depth)
831
+
832
+
833
+ @router.get("/premium/dev-finder/{token}")
834
+ async def premium_dev_finder(token: str, chain: str = "solana", request: Request = None):
835
+ """Find developer/creator wallets behind a token. Premium tier."""
836
+ return await databus.fetch("dev_finder", token_address=token, chain=chain)
837
+
838
+
839
+ @router.get("/premium/snipers/{address}")
840
+ async def premium_sniper_detect(address: str, chain: str = "solana", request: Request = None):
841
+ """Detect snipers — first-block buyers with fast dumps. Premium tier."""
842
+ return await databus.fetch("sniper_detect", address=address, chain=chain)
843
+
844
+
845
+ @router.get("/premium/bot-farms/{address}")
846
+ async def premium_bot_farms(address: str, chain: str = "solana", request: Request = None):
847
+ """Detect bot farms — identical behavior patterns. Premium tier."""
848
+ return await databus.fetch("bot_farm_detect", address=address, chain=chain)
849
+
850
+
851
+ @router.get("/premium/copy-trading/{address}")
852
+ async def premium_copy_trading(address: str, chain: str = "solana", request: Request = None):
853
+ """Detect copy trading patterns. Premium tier."""
854
+ return await databus.fetch("copy_trade_detect", address=address, chain=chain)
855
+
856
+
857
+ @router.get("/premium/insider-signals/{address}")
858
+ async def premium_insider(address: str, chain: str = "solana", request: Request = None):
859
+ """Detect insider trading signals. Premium tier."""
860
+ return await databus.fetch("insider_detect", address=address, chain=chain)
861
+
862
+
863
+ @router.get("/premium/wash-trading/{address}")
864
+ async def premium_wash_trading(address: str, chain: str = "solana", request: Request = None):
865
+ """Detect wash trading patterns. Premium tier."""
866
+ return await databus.fetch("wash_trade_detect", address=address, chain=chain)
867
+
868
+
869
+ @router.get("/premium/mev/{address}")
870
+ async def premium_mev(address: str, chain: str = "solana", request: Request = None):
871
+ """Detect MEV sandwich attacks. Premium tier."""
872
+ return await databus.fetch("mev_detect", address=address, chain=chain)
873
+
874
+
875
+ @router.get("/premium/fresh-wallets/{address}")
876
+ async def premium_fresh_wallets(address: str, chain: str = "solana", request: Request = None):
877
+ """Analyze fresh wallet concentration — high new-wallet % = rug risk. Premium tier."""
878
+ return await databus.fetch("fresh_wallet_analysis", address=address, chain=chain)
879
+
880
+
881
+ # ═══════════════════════════════════════════════════════════════════════════════
882
+ # RUGCHARTS — Volume Authenticity, OHLCV, Token Security
883
+ # ═══════════════════════════════════════════════════════════════════════════════
884
+
885
+
886
+ @router.get("/premium/volume-authenticity/{address}")
887
+ async def premium_volume_auth(address: str, chain: str = "ethereum", request: Request = None):
888
+ """Fake volume % with bootstrap CI. The RugCharts moat — no one else does this."""
889
+ return await databus.fetch(
890
+ "volume_authenticity",
891
+ address=address,
892
+ chain=chain,
893
+ volume_24h=request.query_params.get("volume_24h", "0"),
894
+ liquidity_usd=request.query_params.get("liquidity_usd", "0"),
895
+ unique_wallets=request.query_params.get("unique_wallets", "0"),
896
+ buy_count=request.query_params.get("buy_count", "0"),
897
+ sell_count=request.query_params.get("sell_count", "0"),
898
+ tx_count=request.query_params.get("tx_count", "0"),
899
+ )
900
+
901
+
902
+ @router.get("/ohlcv/{address}")
903
+ async def ohlcv_candles(
904
+ address: str,
905
+ chain: str = "ethereum",
906
+ timeframe: str = "1h",
907
+ limit: int = 100,
908
+ request: Request = None,
909
+ ):
910
+ """OHLCV candlestick data with authenticity scoring baked in."""
911
+ return await databus.fetch("ohlcv", token=address, chain=chain, timeframe=timeframe, limit=limit)
912
+
913
+
914
+ @router.get("/premium/security-scan/{address}")
915
+ async def premium_security_scan(address: str, chain: str = "ethereum", request: Request = None):
916
+ """37+ security checks — GoPlus, honeypot, contract, liquidity, holders, rug pull indicators."""
917
+ return await databus.fetch("token_security", address=address, chain=chain)
918
+
919
+
920
+ @router.get("/security-checks")
921
+ async def security_check_matrix():
922
+ """Full list of all 37+ security checks with weights and descriptions."""
923
+ return await databus.fetch("token_security", action="matrix")
924
+
925
+
926
+ # ═══════════════════════════════════════════════════════════════════════════════
927
+ # RUGCHARTS INTELLIGENCE — 10 Premium Endpoints
928
+ # ═══════════════════════════════════════════════════════════════════════════════
929
+
930
+
931
+ @router.get("/premium/smart-money")
932
+ async def smart_money_endpoint(chain: str = "solana", limit: int = 20):
933
+ """What profitable wallets are buying right now — with entity labels."""
934
+ return await databus.fetch("smart_money", chain=chain, limit=limit)
935
+
936
+
937
+ @router.get("/premium/whale-alerts/{address}")
938
+ async def whale_alerts_endpoint(address: str, chain: str = "solana", min_value: float = 100000):
939
+ """Real-time large transaction detection for a token or wallet."""
940
+ return await databus.fetch("whale_alerts", address=address, chain=chain, min_value_usd=min_value)
941
+
942
+
943
+ @router.get("/premium/token-launches")
944
+ async def token_launches_endpoint(chain: str = "solana", limit: int = 50):
945
+ """Newly launched tokens with instant risk scoring by age."""
946
+ return await databus.fetch("token_launches", chain=chain, limit=limit)
947
+
948
+
949
+ @router.get("/premium/insider-detection/{address}")
950
+ async def insider_detection_endpoint(address: str, chain: str = "solana"):
951
+ """Detect pre-pump accumulation — volume spikes before major price moves."""
952
+ return await databus.fetch("insider_detection", address=address, chain=chain)
953
+
954
+
955
+ @router.get("/premium/liquidity-risk/{address}")
956
+ async def liquidity_risk_endpoint(address: str, chain: str = "solana"):
957
+ """LP health: concentration risk, lock status, holder entities via Arkham."""
958
+ return await databus.fetch("liquidity_risk", address=address, chain=chain)
959
+
960
+
961
+ @router.get("/premium/holder-health/{address}")
962
+ async def holder_health_endpoint(address: str, chain: str = "solana"):
963
+ """Holder distribution: Gini coefficient, top concentration, decentralization score."""
964
+ return await databus.fetch("holder_health", address=address, chain=chain)
965
+
966
+
967
+ @router.get("/premium/cross-chain/{address}")
968
+ async def cross_chain_endpoint(address: str):
969
+ """Trace entity across all chains via Arkham. Discover all related addresses."""
970
+ return await databus.fetch("cross_chain_entity", address=address)
971
+
972
+
973
+ @router.get("/premium/rug-patterns/{address}")
974
+ async def rug_patterns_endpoint(address: str, chain: str = "solana"):
975
+ """Match token against 10 known rug pull patterns. Similarity scoring."""
976
+ return await databus.fetch("rug_patterns", address=address, chain=chain)
977
+
978
+
979
+ @router.get("/premium/dev-reputation/{address}")
980
+ async def dev_reputation_endpoint(address: str, chain: str = "solana"):
981
+ """Deployer wallet history: token count, lifespan, entity resolution."""
982
+ return await databus.fetch("dev_reputation", address=address, chain=chain)
983
+
984
+
985
+ @router.get("/premium/token-report/{address}")
986
+ async def token_report_endpoint(address: str, chain: str = "solana"):
987
+ """ONE-CALL enhanced token report. Smart verdicts, entity enrichment,
988
+ trust adjustments, tier-aware. THE endpoint powering RugCharts."""
989
+ return await databus.fetch("token_report", address=address, chain=chain)
990
+
991
+
992
+ @router.get("/tiers")
993
+ async def tier_comparison_endpoint():
994
+ """Competitive tier comparison: RugCharts vs DexScreener vs Nansen vs GMGN."""
995
+ return await databus.fetch("tier_comparison")
996
+
997
+
998
+ # ═══════════════════════════════════════════════════════════════════════════════
999
+ # MARKET DATA — Free APIs: CoinGecko, Fear & Greed, Polymarket
1000
+ # ═══════════════════════════════════════════════════════════════════════════════
1001
+
1002
+
1003
+ @router.get("/market/prices")
1004
+ async def market_prices(coins: str = "bitcoin,ethereum,solana"):
1005
+ """Live prices from CoinGecko. Free, no key needed."""
1006
+ return await databus.fetch("live_prices", coins=coins)
1007
+
1008
+
1009
+ @router.get("/market/fear-greed")
1010
+ async def fear_greed():
1011
+ """Crypto Fear & Greed Index. Free, no key needed."""
1012
+ return await databus.fetch("fear_greed")
1013
+
1014
+
1015
+ @router.get("/market/trending")
1016
+ async def market_trending():
1017
+ """Trending coins from CoinGecko."""
1018
+ return await databus.fetch("trending_coins")
1019
+
1020
+
1021
+ @router.get("/market/prediction-markets")
1022
+ async def prediction_markets():
1023
+ """Prediction markets from Polymarket. Free, no key needed."""
1024
+ return await databus.fetch("prediction_markets")
1025
+
1026
+
1027
+ @router.get("/market/brief")
1028
+ async def market_brief():
1029
+ """One-call market overview: prices + fear/greed + trending + predictions."""
1030
+ return await databus.fetch("market_brief")
1031
+
1032
+
1033
+ @router.get("/news/full")
1034
+ async def full_news(limit: int = 15):
1035
+ """Complete news feed: headlines + market data + fear/greed + polymarket."""
1036
+ return await databus.fetch("full_news", limit=limit)
1037
+
1038
+
1039
+ # ═══════════════════════════════════════════════════════════════════════════════
1040
+ # NEWS INTELLIGENCE — Multi-source, quality-scored, social-enabled
1041
+ # ═══════════════════════════════════════════════════════════════════════════════
1042
+
1043
+
1044
+ @router.get("/news/intel")
1045
+ async def news_intel(limit: int = 30):
1046
+ """Complete news intelligence — 10+ sources, quality-scored, deduped, sentiment-tagged."""
1047
+ return await databus.fetch("news_intel", limit=limit)
1048
+
1049
+
1050
+ @router.get("/news/weekly-best")
1051
+ async def weekly_best(limit: int = 20):
1052
+ """Curated weekly best — highest quality crypto journalism."""
1053
+ return await databus.fetch("weekly_best", limit=limit)
1054
+
1055
+
1056
+ @router.get("/news/academic")
1057
+ async def academic_papers(limit: int = 10):
1058
+ """Academic crypto/blockchain research papers from arXiv."""
1059
+ return await databus.fetch("academic_papers", limit=limit)
1060
+
1061
+
1062
+ @router.get("/news/social")
1063
+ async def social_feed(limit: int = 30):
1064
+ """Crypto social feed — X/Twitter + CryptoPanic sentiment."""
1065
+ return await databus.fetch("social_feed", limit=limit)
1066
+
1067
+
1068
+ @router.post("/news/react/{content_hash}")
1069
+ async def react_article(content_hash: str, request: Request):
1070
+ """React to article: 🔥🐂🐻💎🧠🤡🚀💀"""
1071
+ body = await request.json()
1072
+ return await databus.fetch(
1073
+ "article_reactions",
1074
+ content_hash=content_hash,
1075
+ reaction=body.get("reaction", "🔥"),
1076
+ user=body.get("user", "anon"),
1077
+ )
1078
+
1079
+
1080
+ @router.get("/news/reactions/{content_hash}")
1081
+ async def get_article_reactions(content_hash: str):
1082
+ """Get reactions and comments for an article."""
1083
+ return await databus.fetch("article_reactions", content_hash=content_hash)
1084
+
1085
+
1086
+ @router.post("/news/comment/{content_hash}")
1087
+ async def comment_article(content_hash: str, request: Request):
1088
+ """Comment on an article."""
1089
+ body = await request.json()
1090
+ return await databus.fetch(
1091
+ "article_comments",
1092
+ content_hash=content_hash,
1093
+ user=body.get("user", "anon"),
1094
+ text=body.get("text", ""),
1095
+ )
1096
+
1097
+
1098
+ @router.post("/news/bb-post/{content_hash}")
1099
+ async def create_bb_from_article(content_hash: str, request: Request):
1100
+ """Turn article into Bulletin Board post."""
1101
+ body = await request.json() if request.headers.get("content-type") == "application/json" else {}
1102
+ return await databus.fetch("bb_post", content_hash=content_hash, user=body.get("user", "system"))
1103
+
1104
+
1105
+ # ═══════════════════════════════════════════════════════════════════════════════
1106
+ # CT RUNDOWN — Crypto Twitter Intelligence
1107
+ # ═══════════════════════════════════════════════════════════════════════════════
1108
+
1109
+
1110
+ @router.get("/news/ct-rundown")
1111
+ async def ct_rundown(limit: int = 20):
1112
+ """CT Rundown — top 20 Crypto Twitter stories, AI-summarized, category-diverse."""
1113
+ return await databus.fetch("ct_rundown", limit=limit)
1114
+
1115
+
1116
+ @router.get("/news/ct-accounts")
1117
+ async def ct_accounts():
1118
+ """Curated CT account list — 150+ top accounts across 6 tiers."""
1119
+ return await databus.fetch("ct_accounts")
1120
+
1121
+
1122
+ # ═══════════════════════════════════════════════════════════════════════════════
1123
+ # SOCIAL INTELLIGENCE — KOL tracking, shill detection, Daily Intel
1124
+ # ═══════════════════════════════════════════════════════════════════════════════
1125
+
1126
+
1127
+ @router.get("/social/kol/{handle}")
1128
+ async def kol_profile(handle: str):
1129
+ """KOL performance profile — trust score, call history, win rate."""
1130
+ return await databus.fetch("kol_profile", handle=handle)
1131
+
1132
+
1133
+ @router.get("/social/kol-leaderboard")
1134
+ async def kol_leaderboard(limit: int = 20):
1135
+ """KOL leaderboard — ranked by trust score and accuracy."""
1136
+ return await databus.fetch("kol_leaderboard", limit=limit)
1137
+
1138
+
1139
+ @router.get("/social/shill-alerts")
1140
+ async def shill_alerts():
1141
+ """Active shill campaigns — coordinated promotion, pump-and-dump patterns."""
1142
+ return await databus.fetch("shill_detector")
1143
+
1144
+
1145
+ @router.get("/social/scam-monitor")
1146
+ async def scam_monitor():
1147
+ """Scam channel monitoring — Telegram/Discord scam pattern detection."""
1148
+ return await databus.fetch("scam_monitor")
1149
+
1150
+
1151
+ @router.get("/social/metrics")
1152
+ async def social_metrics():
1153
+ """Social metrics — trending topics, sentiment, KOL activity."""
1154
+ return await databus.fetch("social_metrics")
1155
+
1156
+
1157
+ @router.get("/intel/daily")
1158
+ async def daily_intel():
1159
+ """Daily Intelligence Report — Groq AI-powered market briefing with all data sources."""
1160
+ return await databus.fetch("daily_intel")
1161
+
1162
+
1163
+ # ═══════════════════════════════════════════════════════════════════════════════
1164
+ # HISTORICAL SCAM INTELLIGENCE — Rekt DB, Chainabuse, TRM, SlowMist, Immunefi
1165
+ # ═══════════════════════════════════════════════════════════════════════════════
1166
+
1167
+
1168
+ @router.get("/historical/sources")
1169
+ async def historical_sources_endpoint():
1170
+ """List all available historical scam/hack data sources."""
1171
+ return await databus.fetch("historical_sources")
1172
+
1173
+
1174
+ @router.post("/historical/ingest/{source_id}")
1175
+ async def historical_ingest_endpoint(source_id: str, request: Request):
1176
+ """Ingest historical data from a specific source into RAG.
1177
+
1178
+ Admin key required. Sources: rekt_db, chainabuse, slowmist_hacked, trm_crime_report
1179
+ """
1180
+ if not _verify_admin(request):
1181
+ raise HTTPException(401, "Admin key required for historical ingestion")
1182
+
1183
+ return await databus.fetch("historical_ingest", source_id=source_id, admin_key="verified")
1184
+
1185
+
1186
+ @router.post("/historical/ingest-all")
1187
+ async def historical_ingest_all(request: Request):
1188
+ """Run full historical ingestion across all sources.
1189
+
1190
+ Admin key required. Ingests: Rekt DB, Chainabuse, SlowMist, TRM Labs reports.
1191
+ """
1192
+ if not _verify_admin(request):
1193
+ raise HTTPException(401, "Admin key required for historical ingestion")
1194
+
1195
+ return await databus.fetch("historical_ingest", source_id="all", admin_key="verified")
1196
+
1197
+
1198
+ @router.get("/historical/defi-hacks")
1199
+ async def defi_hacks_endpoint(limit: int = 50):
1200
+ """Fetch recent DeFi hacks from Rekt DB. Cached 24h."""
1201
+ return await databus.fetch("defi_hacks", limit=limit)
1202
+
1203
+
1204
+ @router.get("/historical/scam-reports")
1205
+ async def scam_reports_endpoint(limit: int = 50):
1206
+ """Fetch scam reports from Chainabuse. Cached 24h."""
1207
+ return await databus.fetch("scam_reports", limit=limit)
backend/app/historical_scraper_tool.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG Historical Scraper Tool — Wire historical sources into DataBus
3
+ ================================================================
4
+
5
+ Registers Rekt DB, Chainabuse, SlowMist, Immunefi scrapers as DataBus chains
6
+ for on-demand and scheduled ingestion into RAG collections.
7
+
8
+ This fills the major gap identified in RAG_MODERNIZATION.md:
9
+ "Firehose sources not wired (cadences defined, fetchers missing)"
10
+
11
+ DataBus endpoints added:
12
+ - POST /api/v1/databus/fetch/historical_ingest
13
+ - POST /api/v1/databus/fetch/historical_ingest/{source_id}
14
+ - GET /api/v1/databus/fetch/historical_sources
15
+
16
+ Sources: rekt_db (DeFi hacks), chainabuse (scam reports), slowmist, immunefi, trm_crime_report
17
+ """
18
+
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ from datetime import UTC, datetime
23
+ from typing import Any
24
+
25
+ logger = logging.getLogger("historical_scraper_tool")
26
+
27
+ # Import the scrapers from rag_historical
28
+ try:
29
+ from app.rag_historical import ingest_historical_source, ingest_all_historical, SOURCES
30
+ except ImportError:
31
+ # Will be available when run as module
32
+ ingest_historical_source = None
33
+ ingest_all_historical = None
34
+ SOURCES = {}
35
+
36
+
37
+ # ── Provider Functions ───────────────────────────────────────────────
38
+
39
+
40
+ async def _fetch_historical_ingest(source_id: str = "all", **kwargs) -> dict | None:
41
+ """Run historical ingestion for a specific source or all sources.
42
+
43
+ source_id: "rekt_db", "chainabuse", "slowmist_hacked", "trm_crime_report", or "all"
44
+ """
45
+ # Import here to allow circular imports
46
+ from app.rag_historical import ingest_historical_source, ingest_all_historical, SOURCES
47
+
48
+ force = kwargs.get("force", False)
49
+
50
+ if source_id == "all":
51
+ result = await ingest_all_historical()
52
+ elif source_id in SOURCES:
53
+ result = await ingest_historical_source(source_id)
54
+ else:
55
+ return {"error": f"Unknown source: {source_id}", "valid_sources": list(SOURCES.keys())}
56
+
57
+ return result
58
+
59
+
60
+ async def _fetch_historical_sources(**kwargs) -> dict | None:
61
+ """List all available historical sources and their configuration."""
62
+ from app.rag_historical import SOURCES
63
+
64
+ sources_info = []
65
+ for source_id, info in SOURCES.items():
66
+ sources_info.append({
67
+ "id": source_id,
68
+ "name": info.get("name", source_id),
69
+ "url": info.get("url", ""),
70
+ "collection": info.get("collection", "unknown"),
71
+ "cadence": info.get("cadence", "unknown"),
72
+ "content_type": info.get("content_type", "unknown"),
73
+ "description": info.get("description", ""),
74
+ })
75
+
76
+ return {
77
+ "sources": sources_info,
78
+ "total_sources": len(sources_info),
79
+ "timestamp": datetime.now(UTC).isoformat(),
80
+ }
81
+
82
+
83
+ async def _fetch_defi_hacks(limit: int = 50, **kwargs) -> dict | None:
84
+ """Fetch recent DeFi hacks from Rekt DB (cached 24h)."""
85
+ from app.rag_historical import scrape_rekt_db
86
+
87
+ try:
88
+ docs = await scrape_rekt_db()
89
+ hacks = docs[:limit] if docs else []
90
+
91
+ return {
92
+ "hacks": hacks,
93
+ "count": len(hacks),
94
+ "source": "rekt_db",
95
+ "cached": True, # Will be cached by DataBus
96
+ }
97
+ except Exception as e:
98
+ logger.warning(f"DeFi hacks fetch failed: {e}")
99
+ return None
100
+
101
+
102
+ async def _fetch_scam_reports(limit: int = 50, **kwargs) -> dict | None:
103
+ """Fetch recent scam reports from Chainabuse (cached 24h)."""
104
+ from app.rag_historical import scrape_chainabuse
105
+
106
+ try:
107
+ docs = await scrape_chainabuse()
108
+ reports = docs[:limit] if docs else []
109
+
110
+ return {
111
+ "reports": reports,
112
+ "count": len(reports),
113
+ "source": "chainabuse",
114
+ }
115
+ except Exception as e:
116
+ logger.warning(f"Scam reports fetch failed: {e}")
117
+ return None
118
+
119
+
120
+ # ── Chain Registration ───────────────────────────────────────────────
121
+
122
+
123
+ def register_historical_chains() -> dict[str, Any]:
124
+ """Register historical scraper chains with DataBus.
125
+
126
+ Must be called during app initialization.
127
+ Returns dict of chain_name -> ProviderChain.
128
+ """
129
+ from app.databus.providers import Provider, ProviderChain, ProviderTier
130
+
131
+ chains = {}
132
+
133
+ # Historical ingestion (admin only, background task)
134
+ chains["historical_ingest"] = ProviderChain(
135
+ data_type="historical_ingest",
136
+ description="Ingest historical scam/hack data into RAG (Rekt DB, Chainabuse, etc.)",
137
+ providers=[
138
+ Provider(
139
+ "historical_scraper",
140
+ ProviderTier.LOCAL,
141
+ _fetch_historical_ingest,
142
+ weight=10.0,
143
+ rate_limit_rps=0.1, # Very slow - don't spam API
144
+ is_local=True,
145
+ ),
146
+ ],
147
+ )
148
+
149
+ # List available sources
150
+ chains["historical_sources"] = ProviderChain(
151
+ data_type="historical_sources",
152
+ description="List all historical data sources",
153
+ providers=[
154
+ Provider(
155
+ "sources_lister",
156
+ ProviderTier.LOCAL,
157
+ _fetch_historical_sources,
158
+ weight=10.0,
159
+ rate_limit_rps=5.0,
160
+ is_local=True,
161
+ ),
162
+ ],
163
+ )
164
+
165
+ # DeFi hacks endpoint (cached)
166
+ chains["defi_hacks"] = ProviderChain(
167
+ data_type="defi_hacks",
168
+ description="Recent DeFi hacks from Rekt DB",
169
+ providers=[
170
+ Provider(
171
+ "rekt_db_scraper",
172
+ ProviderTier.LOCAL,
173
+ _fetch_defi_hacks,
174
+ weight=10.0,
175
+ rate_limit_rps=0.1,
176
+ is_local=True,
177
+ ),
178
+ ],
179
+ )
180
+
181
+ # Scam reports endpoint (cached)
182
+ chains["scam_reports"] = ProviderChain(
183
+ data_type="scam_reports",
184
+ description="Scam reports from Chainabuse",
185
+ providers=[
186
+ Provider(
187
+ "chainabuse_scraper",
188
+ ProviderTier.LOCAL,
189
+ _fetch_scam_reports,
190
+ weight=10.0,
191
+ rate_limit_rps=0.1,
192
+ is_local=True,
193
+ ),
194
+ ],
195
+ )
196
+
197
+ logger.info(
198
+ f"Historical scraper chains registered: "
199
+ f"{list(chains.keys())}"
200
+ )
201
+
202
+ return chains
203
+
204
+
205
+ # ── Quick CLI Test ───────────────────────────────────────────────────
206
+
207
+
208
+ if __name__ == "__main__":
209
+ import sys
210
+
211
+ async def main():
212
+ if len(sys.argv) > 1:
213
+ source = sys.argv[1]
214
+ result = await _fetch_historical_ingest(source)
215
+ print(json.dumps(result, indent=2, default=str))
216
+ else:
217
+ sources = await _fetch_historical_sources()
218
+ print(json.dumps(sources, indent=2, default=str))
219
+
220
+ asyncio.run(main())
backend/app/test_historical_scraper.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Historical Scraper Tool — DataBus integration
3
+ =====================================================
4
+ """
5
+
6
+ import sys
7
+ sys.path.insert(0, '/root/backend')
8
+ sys.path.insert(0, '/root/backend/app')
9
+
10
+ import asyncio
11
+ import json
12
+ from datetime import UTC, datetime
13
+ from unittest.mock import AsyncMock, MagicMock, patch
14
+
15
+ import pytest
16
+
17
+
18
+ class TestHistoricalScraperTool:
19
+ """Test suite for historical_scraper_tool.py"""
20
+
21
+ def test_register_historical_chains_returns_dict(self):
22
+ """Test that register_historical_chains returns a dictionary of chains."""
23
+ from app.historical_scraper_tool import register_historical_chains
24
+
25
+ chains = register_historical_chains()
26
+
27
+ assert isinstance(chains, dict)
28
+ assert "historical_ingest" in chains
29
+ assert "historical_sources" in chains
30
+ assert "defi_hacks" in chains
31
+ assert "scam_reports" in chains
32
+
33
+ def test_register_historical_chains_provider_count(self):
34
+ """Test that each chain has at least one provider."""
35
+ from app.historical_scraper_tool import register_historical_chains
36
+
37
+ chains = register_historical_chains()
38
+
39
+ for name, chain in chains.items():
40
+ assert chain.data_type == name, f"Chain {name} has wrong data_type"
41
+ assert len(chain.providers) >= 1, f"Chain {name} has no providers"
42
+
43
+ def test_sources_dict_structure(self):
44
+ """Test that SOURCES dict in rag_historical has expected structure."""
45
+ from app.rag_historical import SOURCES
46
+
47
+ expected_sources = ["rekt_db", "chainabuse", "slowmist_hacked", "rekt_news", "trm_crime_report"]
48
+
49
+ for source in expected_sources:
50
+ assert source in SOURCES, f"Missing source: {source}"
51
+ assert "name" in SOURCES[source]
52
+ assert "collection" in SOURCES[source]
53
+ assert "url" in SOURCES[source]
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_fetch_historical_sources(self):
57
+ """Test fetching historical sources list."""
58
+ from app.historical_scraper_tool import _fetch_historical_sources
59
+
60
+ result = await _fetch_historical_sources()
61
+ assert result is not None
62
+ assert "sources" in result
63
+ assert "total_sources" in result
64
+ assert result["total_sources"] >= 5
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_fetch_historical_ingest_unknown_source(self):
68
+ """Test that unknown source returns error."""
69
+ from app.historical_scraper_tool import _fetch_historical_ingest
70
+
71
+ result = await _fetch_historical_ingest(source_id="unknown_source")
72
+
73
+ assert "error" in result
74
+
75
+
76
+ class TestRagHistoricalScrapers:
77
+ """Test suite for rag_historical.py scrapers"""
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_scrape_rekt_db_returns_list(self):
81
+ """Test that scrape_rekt_db returns a list of documents."""
82
+ from app.rag_historical import scrape_rekt_db
83
+
84
+ # This may fail if API is unreachable, but should return empty list not crash
85
+ result = await scrape_rekt_db()
86
+
87
+ assert isinstance(result, list)
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_scrape_chainabuse_returns_list(self):
91
+ """Test that scrape_chainabuse returns a list of documents."""
92
+ from app.rag_historical import scrape_chainabuse
93
+
94
+ result = await scrape_chainabuse()
95
+
96
+ assert isinstance(result, list)
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_ingest_historical_source_unknown(self):
100
+ """Test that unknown source returns error dict."""
101
+ from app.rag_historical import ingest_historical_source
102
+
103
+ result = await ingest_historical_source("nonexistent_source")
104
+
105
+ assert "error" in result
106
+ assert "Unknown source" in result["error"]
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_ingest_all_historical_structure(self):
110
+ """Test structure of ingest_all_historical result."""
111
+ from app.rag_historical import ingest_all_historical
112
+
113
+ result = await ingest_all_historical()
114
+
115
+ assert "status" in result
116
+ assert "sources" in result
117
+ assert "timestamp" in result
118
+
119
+
120
+ class TestIntegration:
121
+ """Integration tests for DataBus chains"""
122
+
123
+ def test_chains_registered_in_providers(self):
124
+ """Test that historical chains are registered in build_provider_chains."""
125
+ from app.databus.providers import build_provider_chains
126
+
127
+ chains = build_provider_chains()
128
+
129
+ # Check that our historical chains were added
130
+ assert "historical_ingest" in chains
131
+ assert "historical_sources" in chains
132
+ assert "defi_hacks" in chains
133
+ assert "scam_reports" in chains
134
+
135
+ def test_router_list_mode_ok_updated(self):
136
+ """Test that router LIST_MODE_OK includes historical chains."""
137
+ from databus.router import LIST_MODE_OK
138
+
139
+ assert "historical_ingest" in LIST_MODE_OK
140
+ assert "historical_sources" in LIST_MODE_OK
141
+ assert "defi_hacks" in LIST_MODE_OK
142
+ assert "scam_reports" in LIST_MODE_OK
143
+
144
+
145
+ if __name__ == "__main__":
146
+ pytest.main([__file__, "-v"])