Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -5,6 +5,9 @@ This module provides a complete implementation of a blockchain wallet analysis t
|
|
5 |
with a Gradio web interface. It includes wallet analysis, NFT tracking, and
|
6 |
interactive chat capabilities using the OpenAI API.
|
7 |
|
|
|
|
|
|
|
8 |
Author: Claude
|
9 |
Date: January 2025
|
10 |
"""
|
@@ -45,17 +48,14 @@ logging.basicConfig(
|
|
45 |
)
|
46 |
logger = logging.getLogger(__name__)
|
47 |
|
48 |
-
|
49 |
class ConfigError(Exception):
|
50 |
"""Raised when there's an error in configuration."""
|
51 |
pass
|
52 |
|
53 |
-
|
54 |
class APIError(Exception):
|
55 |
"""Raised when there's an error in API calls."""
|
56 |
pass
|
57 |
|
58 |
-
|
59 |
class ValidationError(Exception):
|
60 |
"""Raised when there's an error in input validation."""
|
61 |
pass
|
@@ -78,9 +78,9 @@ class Config:
|
|
78 |
"""
|
79 |
ETHERSCAN_BASE_URL: str = "https://api.etherscan.io/api"
|
80 |
ETHEREUM_ADDRESS_REGEX: str = r"0x[a-fA-F0-9]{40}"
|
81 |
-
RATE_LIMIT_DELAY: float = 0.2 # 5 requests
|
82 |
MAX_RETRIES: int = 3
|
83 |
-
OPENAI_MODEL: str = "gpt-4o-mini" # Updated
|
84 |
MAX_TOKENS: int = 4000
|
85 |
TEMPERATURE: float = 0.7
|
86 |
HISTORY_LIMIT: int = 5
|
@@ -111,8 +111,8 @@ class WalletAnalyzer:
|
|
111 |
)
|
112 |
async def _fetch_data(self, params: Dict[str, str]) -> Dict[str, Any]:
|
113 |
"""
|
114 |
-
Fetch data from Etherscan
|
115 |
-
Raises APIError if
|
116 |
"""
|
117 |
if not self.session:
|
118 |
raise APIError("No active session. Use context manager.")
|
@@ -124,60 +124,58 @@ class WalletAnalyzer:
|
|
124 |
async with self.session.get(self.base_url, params=params) as response:
|
125 |
if response.status != 200:
|
126 |
raise APIError(f"API request failed: {response.status}")
|
127 |
-
|
128 |
data = await response.json()
|
129 |
if data.get("status") == "0":
|
130 |
-
|
131 |
-
if "Max rate limit reached" in
|
132 |
raise APIError("Rate limit exceeded")
|
133 |
-
raise APIError(f"API error: {
|
134 |
-
|
135 |
return data
|
136 |
-
|
137 |
except aiohttp.ClientError as e:
|
138 |
raise APIError(f"Network error: {str(e)}")
|
139 |
except Exception as e:
|
140 |
raise APIError(f"Unexpected error: {str(e)}")
|
141 |
|
142 |
async def _rate_limit(self) -> None:
|
143 |
-
"""Implement rate limiting for Etherscan
|
144 |
-
|
145 |
-
|
146 |
-
if
|
147 |
-
await asyncio.sleep(Config.RATE_LIMIT_DELAY -
|
148 |
self.last_request_time = time.time()
|
149 |
|
150 |
@staticmethod
|
151 |
def _validate_address(address: str) -> bool:
|
152 |
-
"""Validate Ethereum address format."""
|
153 |
return bool(re.match(Config.ETHEREUM_ADDRESS_REGEX, address))
|
154 |
|
155 |
async def get_portfolio_data(self, address: str) -> WalletData:
|
156 |
"""
|
157 |
-
Get
|
158 |
-
|
159 |
-
|
|
|
160 |
"""
|
161 |
if not self._validate_address(address):
|
162 |
raise ValidationError(f"Invalid Ethereum address: {address}")
|
163 |
|
164 |
logger.info(f"Fetching portfolio data for {address}")
|
165 |
|
166 |
-
#
|
167 |
eth_balance = await self._get_eth_balance(address)
|
168 |
|
169 |
-
#
|
170 |
-
|
171 |
|
172 |
-
#
|
173 |
-
|
174 |
|
175 |
return {
|
176 |
"address": address,
|
177 |
"last_updated": datetime.now().isoformat(),
|
178 |
"eth_balance": float(eth_balance),
|
179 |
-
"tokens":
|
180 |
-
"nft_collections":
|
181 |
}
|
182 |
|
183 |
async def _get_eth_balance(self, address: str) -> Decimal:
|
@@ -189,12 +187,12 @@ class WalletAnalyzer:
|
|
189 |
"tag": "latest"
|
190 |
}
|
191 |
data = await self._fetch_data(params)
|
192 |
-
return Decimal(data["result"]) / Decimal("
|
193 |
|
194 |
async def _get_token_holdings(self, address: str) -> List[Dict[str, Any]]:
|
195 |
"""
|
196 |
-
|
197 |
-
|
198 |
"""
|
199 |
params = {
|
200 |
"module": "account",
|
@@ -203,7 +201,6 @@ class WalletAnalyzer:
|
|
203 |
"sort": "desc"
|
204 |
}
|
205 |
data = await self._fetch_data(params)
|
206 |
-
|
207 |
token_map: Dict[str, Dict[str, Any]] = {}
|
208 |
for tx in data.get("result", []):
|
209 |
contract = tx["contractAddress"]
|
@@ -214,8 +211,7 @@ class WalletAnalyzer:
|
|
214 |
"decimals": int(tx["tokenDecimal"]),
|
215 |
"balance": Decimal(0)
|
216 |
}
|
217 |
-
|
218 |
-
amount = Decimal(tx["value"]) / Decimal(10 ** int(tx["tokenDecimal"]))
|
219 |
if tx["to"].lower() == address.lower():
|
220 |
token_map[contract]["balance"] += amount
|
221 |
elif tx["from"].lower() == address.lower():
|
@@ -223,17 +219,17 @@ class WalletAnalyzer:
|
|
223 |
|
224 |
return [
|
225 |
{
|
226 |
-
"name":
|
227 |
-
"symbol":
|
228 |
-
"balance": float(
|
229 |
}
|
230 |
-
for
|
231 |
]
|
232 |
|
233 |
async def _get_nft_holdings(self, address: str) -> Dict[str, Dict[str, Any]]:
|
234 |
"""
|
235 |
-
|
236 |
-
|
237 |
"""
|
238 |
params = {
|
239 |
"module": "account",
|
@@ -247,79 +243,73 @@ class WalletAnalyzer:
|
|
247 |
collections: Dict[str, List[str]] = {}
|
248 |
|
249 |
for tx in data.get("result", []):
|
250 |
-
|
251 |
token_id = tx["tokenID"]
|
252 |
-
key = f"{tx['contractAddress']}_{token_id}"
|
253 |
-
|
254 |
if tx["to"].lower() == address.lower():
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
"contract": tx["contractAddress"],
|
259 |
-
"acquired_time": tx["timeStamp"]
|
260 |
-
}
|
261 |
-
if collection_name not in collections:
|
262 |
-
collections[collection_name] = []
|
263 |
-
collections[collection_name].append(token_id)
|
264 |
elif tx["from"].lower() == address.lower():
|
265 |
-
|
266 |
-
|
267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
268 |
|
269 |
-
|
270 |
-
c_name: {
|
271 |
-
"count": len(token_ids),
|
272 |
-
"token_ids": token_ids
|
273 |
-
}
|
274 |
-
for c_name, token_ids in collections.items()
|
275 |
-
if token_ids
|
276 |
-
}
|
277 |
-
############################
|
278 |
-
# OPENSEA NFT IMAGE LOGIC
|
279 |
-
############################
|
280 |
|
281 |
-
|
282 |
-
OPENSEA_API_KEY = "696200615dd043729252da5e2fe3be1a"
|
283 |
-
OPENSEA_BASE_URL = "https://api.opensea.io/api/v2/chain/ethereum/contract"
|
284 |
|
285 |
-
async def fetch_nft_image(
|
286 |
"""
|
287 |
-
Fetch NFT metadata
|
288 |
-
|
|
|
|
|
|
|
|
|
289 |
"""
|
290 |
-
url = f"{
|
291 |
-
headers = {"X-API-KEY":
|
292 |
|
293 |
async with aiohttp.ClientSession() as session:
|
294 |
async with session.get(url, headers=headers) as resp:
|
295 |
if resp.status == 403:
|
296 |
return {"error": "403 Forbidden: Invalid or restricted OpenSea API key."}
|
297 |
elif resp.status == 404:
|
298 |
-
return {"error": f"404 Not Found for NFT {
|
299 |
-
|
300 |
try:
|
301 |
data = await resp.json()
|
302 |
except Exception as e:
|
303 |
-
return {"error": f"
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
image_url =
|
308 |
-
return {"name":
|
309 |
class ChatInterface:
|
310 |
-
"""
|
311 |
|
312 |
-
def __init__(self, openai_key: str, etherscan_key: str):
|
313 |
self.openai_key = openai_key
|
314 |
self.etherscan_key = etherscan_key
|
|
|
315 |
self.context: Dict[str, Any] = {}
|
316 |
openai.api_key = openai_key
|
317 |
|
318 |
@staticmethod
|
319 |
-
def _validate_api_keys(openai_key: str, etherscan_key: str) -> Tuple[bool, str]:
|
320 |
-
"""Validate
|
321 |
try:
|
322 |
-
#
|
323 |
client = openai.OpenAI(api_key=openai_key)
|
324 |
client.chat.completions.create(
|
325 |
model=Config.OPENAI_MODEL,
|
@@ -327,31 +317,36 @@ class ChatInterface:
|
|
327 |
max_tokens=1
|
328 |
)
|
329 |
|
330 |
-
#
|
331 |
-
async def
|
332 |
async with WalletAnalyzer(etherscan_key) as analyzer:
|
333 |
-
|
334 |
-
await analyzer._fetch_data(
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
|
|
|
|
|
|
|
|
339 |
except Exception as e:
|
340 |
return False, f"API key validation failed: {str(e)}"
|
341 |
|
342 |
def _format_context_message(self) -> str:
|
343 |
-
"""Format wallet data
|
|
|
344 |
if not self.context:
|
345 |
return ""
|
346 |
-
lines
|
347 |
for addr, data in self.context.items():
|
348 |
lines.append(f"Wallet {addr[:8]}...{addr[-6:]}:")
|
349 |
-
lines.append(f"
|
350 |
-
lines.append(f"
|
351 |
if data['nft_collections']:
|
352 |
lines.append(" NFT Collections:")
|
353 |
-
for
|
354 |
-
lines.append(f"
|
355 |
return "\n".join(lines)
|
356 |
|
357 |
async def process_message(
|
@@ -360,106 +355,103 @@ class ChatInterface:
|
|
360 |
history: Optional[ChatHistory] = None
|
361 |
) -> Tuple[ChatHistory, Dict[str, Any], List[str]]:
|
362 |
"""
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
Returns
|
368 |
"""
|
369 |
if history is None:
|
370 |
history = []
|
371 |
|
372 |
-
# Basic check
|
373 |
if not message.strip():
|
374 |
return history, self.context, []
|
375 |
|
376 |
-
#
|
377 |
match = re.search(Config.ETHEREUM_ADDRESS_REGEX, message)
|
378 |
-
|
379 |
-
|
380 |
if match:
|
381 |
address = match.group(0)
|
382 |
-
|
383 |
-
|
|
|
|
|
384 |
try:
|
385 |
-
#
|
386 |
async with WalletAnalyzer(self.etherscan_key) as analyzer:
|
387 |
wallet_data = await analyzer.get_portfolio_data(address)
|
388 |
self.context[address] = wallet_data
|
389 |
|
390 |
-
# Summarize
|
391 |
eth_bal = wallet_data["eth_balance"]
|
392 |
-
|
393 |
nft_map = wallet_data["nft_collections"]
|
394 |
total_nfts = sum(v["count"] for v in nft_map.values())
|
395 |
-
|
396 |
-
f"π Summary for {address[:8]}...{address[-6:]}
|
397 |
-
f"ETH
|
398 |
-
f"Tokens: {
|
399 |
f"NFTs: {total_nfts}"
|
400 |
]
|
401 |
-
#
|
402 |
-
|
403 |
-
|
404 |
-
#
|
405 |
-
#
|
406 |
-
#
|
407 |
-
#
|
408 |
-
#
|
409 |
-
|
410 |
-
|
411 |
-
#
|
412 |
-
# We'll keep it simple for now. But let's show the user how they'd do it if the aggregator stored "contract":
|
413 |
-
|
414 |
-
# In the future, we can store an array of {contract, token_id, collectionName} from the raw Etherscan calls.
|
415 |
-
# Then we can fetch images from each. That requires rewriting _get_nft_holdings or storing intermediate data.
|
416 |
-
# We'll do a simplified approach.
|
417 |
pass
|
418 |
|
419 |
-
# Append summary
|
420 |
-
history.append((message, "\n".join(intro_msg)))
|
421 |
except Exception as e:
|
422 |
-
|
423 |
-
logger.error(
|
424 |
-
history.append((message,
|
|
|
425 |
|
426 |
-
#
|
427 |
try:
|
428 |
-
|
|
|
429 |
limit = Config.HISTORY_LIMIT
|
430 |
-
|
431 |
openai_msgs = []
|
432 |
-
for usr, ans in
|
433 |
openai_msgs.append({"role": "user", "content": usr})
|
434 |
openai_msgs.append({"role": "assistant", "content": ans})
|
435 |
|
|
|
436 |
client = openai.OpenAI(api_key=self.openai_key)
|
437 |
response = client.chat.completions.create(
|
438 |
model=Config.OPENAI_MODEL,
|
439 |
messages=[
|
440 |
{"role": "system", "content": Config.SYSTEM_PROMPT},
|
441 |
-
{"role": "system", "content":
|
442 |
*openai_msgs,
|
443 |
{"role": "user", "content": message}
|
444 |
],
|
445 |
temperature=Config.TEMPERATURE,
|
446 |
max_tokens=Config.MAX_TOKENS
|
447 |
)
|
448 |
-
|
449 |
-
|
450 |
-
|
|
|
451 |
except Exception as e:
|
452 |
-
|
453 |
-
logger.error(
|
454 |
-
history.append((message,
|
455 |
return history, self.context, []
|
456 |
|
457 |
def clear_context(self) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
|
458 |
-
"""
|
459 |
self.context = {}
|
460 |
return {}, []
|
461 |
class GradioInterface:
|
462 |
-
"""
|
463 |
|
464 |
def __init__(self):
|
465 |
self.chat_interface: Optional[ChatInterface] = None
|
@@ -468,79 +460,101 @@ class GradioInterface:
|
|
468 |
def _create_interface(self) -> gr.Blocks:
|
469 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
470 |
gr.Markdown("""
|
471 |
-
# π LOSS DOG: Blockchain Wallet Analyzer
|
472 |
|
473 |
-
|
474 |
-
-
|
475 |
-
-
|
476 |
-
-
|
477 |
""")
|
478 |
|
479 |
with gr.Row():
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
491 |
-
|
|
|
|
|
|
|
492 |
|
493 |
-
|
494 |
-
validate_btn = gr.Button("Validate API Keys"
|
495 |
|
496 |
with gr.Row():
|
|
|
497 |
with gr.Column(scale=2):
|
498 |
chatbot = gr.Chatbot(
|
499 |
label="Chat History",
|
500 |
-
height=
|
501 |
value=[]
|
502 |
)
|
503 |
with gr.Row():
|
504 |
msg_input = gr.Textbox(
|
505 |
label="Message",
|
506 |
-
placeholder="
|
507 |
)
|
508 |
send_btn = gr.Button("Send")
|
|
|
|
|
509 |
with gr.Column(scale=1):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
510 |
wallet_context = gr.JSON(
|
511 |
label="Active Wallet Context",
|
512 |
value={}
|
513 |
)
|
514 |
-
nft_gallery = gr.Gallery(
|
515 |
-
label="NFT Images",
|
516 |
-
columns=2
|
517 |
-
)
|
518 |
clear_btn = gr.Button("Clear Context")
|
519 |
|
520 |
-
#
|
521 |
msg_input.interactive = False
|
522 |
send_btn.interactive = False
|
523 |
|
524 |
-
|
525 |
-
|
526 |
-
is_valid,
|
527 |
if is_valid:
|
528 |
-
self.chat_interface = ChatInterface(openai_k, etherscan_k)
|
529 |
return (
|
530 |
-
f"β
Valid: {
|
531 |
gr.update(interactive=True),
|
532 |
gr.update(interactive=True)
|
533 |
)
|
534 |
-
|
535 |
-
|
536 |
-
|
537 |
-
|
538 |
-
|
|
|
539 |
|
540 |
validate_btn.click(
|
541 |
fn=validate_keys,
|
542 |
-
inputs=[openai_key, etherscan_key],
|
543 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
544 |
)
|
545 |
|
546 |
async def handle_message(
|
@@ -548,27 +562,21 @@ class GradioInterface:
|
|
548 |
chat_hist: List[Tuple[str, str]],
|
549 |
context: Dict[str, Any]
|
550 |
) -> Tuple[List[Tuple[str, str]], Dict[str, Any], List[str]]:
|
551 |
-
"""
|
552 |
if not self.chat_interface:
|
553 |
return [], {}, []
|
|
|
554 |
try:
|
555 |
new_hist, new_ctx, nft_imgs = await self.chat_interface.process_message(message, chat_hist)
|
556 |
return new_hist, new_ctx, nft_imgs
|
557 |
except Exception as e:
|
558 |
-
logger.error(f"Error
|
559 |
-
if
|
560 |
chat_hist = []
|
561 |
chat_hist.append((message, f"Error: {str(e)}"))
|
562 |
return chat_hist, context, []
|
563 |
|
564 |
-
#
|
565 |
-
def clear_all():
|
566 |
-
if self.chat_interface:
|
567 |
-
return self.chat_interface.clear_context()
|
568 |
-
return {}, []
|
569 |
-
|
570 |
-
clear_btn.click(fn=clear_all, inputs=[], outputs=[wallet_context, chatbot])
|
571 |
-
|
572 |
msg_input.submit(
|
573 |
fn=handle_message,
|
574 |
inputs=[msg_input, chatbot, wallet_context],
|
@@ -592,13 +600,13 @@ class GradioInterface:
|
|
592 |
return demo
|
593 |
|
594 |
def launch(self):
|
|
|
595 |
self.demo.queue()
|
596 |
self.demo.launch()
|
597 |
|
598 |
-
|
599 |
def main():
|
600 |
-
"""Main entry point
|
601 |
-
logger.info("Launching LOSS DOG on
|
602 |
interface = GradioInterface()
|
603 |
interface.launch()
|
604 |
|
|
|
5 |
with a Gradio web interface. It includes wallet analysis, NFT tracking, and
|
6 |
interactive chat capabilities using the OpenAI API.
|
7 |
|
8 |
+
Now extended to fetch NFT images from OpenSea without hardcoding the OpenSea API key.
|
9 |
+
Images are displayed on the top-right for easy visualization.
|
10 |
+
|
11 |
Author: Claude
|
12 |
Date: January 2025
|
13 |
"""
|
|
|
48 |
)
|
49 |
logger = logging.getLogger(__name__)
|
50 |
|
|
|
51 |
class ConfigError(Exception):
|
52 |
"""Raised when there's an error in configuration."""
|
53 |
pass
|
54 |
|
|
|
55 |
class APIError(Exception):
|
56 |
"""Raised when there's an error in API calls."""
|
57 |
pass
|
58 |
|
|
|
59 |
class ValidationError(Exception):
|
60 |
"""Raised when there's an error in input validation."""
|
61 |
pass
|
|
|
78 |
"""
|
79 |
ETHERSCAN_BASE_URL: str = "https://api.etherscan.io/api"
|
80 |
ETHEREUM_ADDRESS_REGEX: str = r"0x[a-fA-F0-9]{40}"
|
81 |
+
RATE_LIMIT_DELAY: float = 0.2 # 5 requests/second
|
82 |
MAX_RETRIES: int = 3
|
83 |
+
OPENAI_MODEL: str = "gpt-4o-mini" # Updated model name
|
84 |
MAX_TOKENS: int = 4000
|
85 |
TEMPERATURE: float = 0.7
|
86 |
HISTORY_LIMIT: int = 5
|
|
|
111 |
)
|
112 |
async def _fetch_data(self, params: Dict[str, str]) -> Dict[str, Any]:
|
113 |
"""
|
114 |
+
Fetch data from Etherscan with retry logic.
|
115 |
+
Raises APIError if rate limit or other error occurs.
|
116 |
"""
|
117 |
if not self.session:
|
118 |
raise APIError("No active session. Use context manager.")
|
|
|
124 |
async with self.session.get(self.base_url, params=params) as response:
|
125 |
if response.status != 200:
|
126 |
raise APIError(f"API request failed: {response.status}")
|
|
|
127 |
data = await response.json()
|
128 |
if data.get("status") == "0":
|
129 |
+
err_msg = data.get("message", "Unknown Etherscan error")
|
130 |
+
if "Max rate limit reached" in err_msg:
|
131 |
raise APIError("Rate limit exceeded")
|
132 |
+
raise APIError(f"API error: {err_msg}")
|
|
|
133 |
return data
|
|
|
134 |
except aiohttp.ClientError as e:
|
135 |
raise APIError(f"Network error: {str(e)}")
|
136 |
except Exception as e:
|
137 |
raise APIError(f"Unexpected error: {str(e)}")
|
138 |
|
139 |
async def _rate_limit(self) -> None:
|
140 |
+
"""Implement rate limiting for Etherscan free tier."""
|
141 |
+
now = time.time()
|
142 |
+
diff = now - self.last_request_time
|
143 |
+
if diff < Config.RATE_LIMIT_DELAY:
|
144 |
+
await asyncio.sleep(Config.RATE_LIMIT_DELAY - diff)
|
145 |
self.last_request_time = time.time()
|
146 |
|
147 |
@staticmethod
|
148 |
def _validate_address(address: str) -> bool:
|
149 |
+
"""Validate Ethereum address format via regex."""
|
150 |
return bool(re.match(Config.ETHEREUM_ADDRESS_REGEX, address))
|
151 |
|
152 |
async def get_portfolio_data(self, address: str) -> WalletData:
|
153 |
"""
|
154 |
+
Get entire portfolio data:
|
155 |
+
- ETH balance
|
156 |
+
- ERC-20 tokens
|
157 |
+
- NFT collections
|
158 |
"""
|
159 |
if not self._validate_address(address):
|
160 |
raise ValidationError(f"Invalid Ethereum address: {address}")
|
161 |
|
162 |
logger.info(f"Fetching portfolio data for {address}")
|
163 |
|
164 |
+
# 1) ETH balance
|
165 |
eth_balance = await self._get_eth_balance(address)
|
166 |
|
167 |
+
# 2) Tokens
|
168 |
+
tokens = await self._get_token_holdings(address)
|
169 |
|
170 |
+
# 3) NFTs
|
171 |
+
nft_colls = await self._get_nft_holdings(address)
|
172 |
|
173 |
return {
|
174 |
"address": address,
|
175 |
"last_updated": datetime.now().isoformat(),
|
176 |
"eth_balance": float(eth_balance),
|
177 |
+
"tokens": tokens,
|
178 |
+
"nft_collections": nft_colls
|
179 |
}
|
180 |
|
181 |
async def _get_eth_balance(self, address: str) -> Decimal:
|
|
|
187 |
"tag": "latest"
|
188 |
}
|
189 |
data = await self._fetch_data(params)
|
190 |
+
return Decimal(data["result"]) / Decimal("1e18")
|
191 |
|
192 |
async def _get_token_holdings(self, address: str) -> List[Dict[str, Any]]:
|
193 |
"""
|
194 |
+
Retrieve ERC-20 token balances for address by
|
195 |
+
summing inbound/outbound transaction amounts.
|
196 |
"""
|
197 |
params = {
|
198 |
"module": "account",
|
|
|
201 |
"sort": "desc"
|
202 |
}
|
203 |
data = await self._fetch_data(params)
|
|
|
204 |
token_map: Dict[str, Dict[str, Any]] = {}
|
205 |
for tx in data.get("result", []):
|
206 |
contract = tx["contractAddress"]
|
|
|
211 |
"decimals": int(tx["tokenDecimal"]),
|
212 |
"balance": Decimal(0)
|
213 |
}
|
214 |
+
amount = Decimal(tx["value"]) / Decimal(10 ** token_map[contract]["decimals"])
|
|
|
215 |
if tx["to"].lower() == address.lower():
|
216 |
token_map[contract]["balance"] += amount
|
217 |
elif tx["from"].lower() == address.lower():
|
|
|
219 |
|
220 |
return [
|
221 |
{
|
222 |
+
"name": v["name"],
|
223 |
+
"symbol": v["symbol"],
|
224 |
+
"balance": float(v["balance"])
|
225 |
}
|
226 |
+
for v in token_map.values() if v["balance"] > 0
|
227 |
]
|
228 |
|
229 |
async def _get_nft_holdings(self, address: str) -> Dict[str, Dict[str, Any]]:
|
230 |
"""
|
231 |
+
Retrieve NFT holdings for address.
|
232 |
+
Return { collectionName: { count, token_ids }}
|
233 |
"""
|
234 |
params = {
|
235 |
"module": "account",
|
|
|
243 |
collections: Dict[str, List[str]] = {}
|
244 |
|
245 |
for tx in data.get("result", []):
|
246 |
+
coll_name = tx.get("tokenName", "Unknown Collection")
|
247 |
token_id = tx["tokenID"]
|
|
|
|
|
248 |
if tx["to"].lower() == address.lower():
|
249 |
+
if coll_name not in collections:
|
250 |
+
collections[coll_name] = []
|
251 |
+
collections[coll_name].append(token_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
252 |
elif tx["from"].lower() == address.lower():
|
253 |
+
if coll_name in collections and token_id in collections[coll_name]:
|
254 |
+
collections[coll_name].remove(token_id)
|
255 |
+
|
256 |
+
for c_name, tokens in collections.items():
|
257 |
+
if tokens:
|
258 |
+
nft_holdings[c_name] = {
|
259 |
+
"count": len(tokens),
|
260 |
+
"token_ids": tokens
|
261 |
+
}
|
262 |
+
return nft_holdings
|
263 |
+
##########################
|
264 |
+
# OPENSEA IMAGE FETCHING #
|
265 |
+
##########################
|
266 |
|
267 |
+
# We'll store the user's provided key in ChatInterface so it's not hardcoded.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
268 |
|
269 |
+
OPENSEA_API_URL = "https://api.opensea.io/api/v2/chain/ethereum/contract"
|
|
|
|
|
270 |
|
271 |
+
async def fetch_nft_image(opensea_key: str, contract_addr: str, token_id: str) -> Dict[str, str]:
|
272 |
"""
|
273 |
+
Fetch NFT metadata and image from OpenSea, using user-provided API key.
|
274 |
+
|
275 |
+
Returns a dict with:
|
276 |
+
{ "name": str, "image_url": str }
|
277 |
+
or
|
278 |
+
{ "error": str }
|
279 |
"""
|
280 |
+
url = f"{OPENSEA_API_URL}/{contract_addr}/nfts/{token_id}"
|
281 |
+
headers = {"X-API-KEY": opensea_key} if opensea_key else {}
|
282 |
|
283 |
async with aiohttp.ClientSession() as session:
|
284 |
async with session.get(url, headers=headers) as resp:
|
285 |
if resp.status == 403:
|
286 |
return {"error": "403 Forbidden: Invalid or restricted OpenSea API key."}
|
287 |
elif resp.status == 404:
|
288 |
+
return {"error": f"404 Not Found for NFT {contract_addr} #{token_id}"}
|
|
|
289 |
try:
|
290 |
data = await resp.json()
|
291 |
except Exception as e:
|
292 |
+
return {"error": f"Invalid JSON from OpenSea: {str(e)}"}
|
293 |
+
|
294 |
+
nft_obj = data.get("nft", {})
|
295 |
+
nft_name = nft_obj.get("name", f"NFT #{token_id}")
|
296 |
+
image_url = nft_obj.get("image_url", "")
|
297 |
+
return {"name": nft_name, "image_url": image_url}
|
298 |
class ChatInterface:
|
299 |
+
"""Handles chat interaction with wallet analysis and NFT images from OpenSea."""
|
300 |
|
301 |
+
def __init__(self, openai_key: str, etherscan_key: str, opensea_key: str):
|
302 |
self.openai_key = openai_key
|
303 |
self.etherscan_key = etherscan_key
|
304 |
+
self.opensea_key = opensea_key
|
305 |
self.context: Dict[str, Any] = {}
|
306 |
openai.api_key = openai_key
|
307 |
|
308 |
@staticmethod
|
309 |
+
def _validate_api_keys(openai_key: str, etherscan_key: str, opensea_key: str) -> Tuple[bool, str]:
|
310 |
+
"""Validate OpenAI, Etherscan, and OpenSea keys by quick checks."""
|
311 |
try:
|
312 |
+
# Check OpenAI
|
313 |
client = openai.OpenAI(api_key=openai_key)
|
314 |
client.chat.completions.create(
|
315 |
model=Config.OPENAI_MODEL,
|
|
|
317 |
max_tokens=1
|
318 |
)
|
319 |
|
320 |
+
# Check Etherscan
|
321 |
+
async def check_etherscan():
|
322 |
async with WalletAnalyzer(etherscan_key) as analyzer:
|
323 |
+
params = {"module": "stats", "action": "ethsupply"}
|
324 |
+
await analyzer._fetch_data(params)
|
325 |
+
asyncio.run(check_etherscan())
|
326 |
+
|
327 |
+
# We won't do a full "check" for OpenSea since no trivial endpoint for test.
|
328 |
+
# We'll assume user knows if the key is valid or not.
|
329 |
+
if not opensea_key.strip():
|
330 |
+
return False, "OpenSea API key cannot be empty."
|
331 |
+
|
332 |
+
return True, "All API keys are valid!"
|
333 |
except Exception as e:
|
334 |
return False, f"API key validation failed: {str(e)}"
|
335 |
|
336 |
def _format_context_message(self) -> str:
|
337 |
+
"""Format wallet data as a system context for GPT model."""
|
338 |
+
lines = []
|
339 |
if not self.context:
|
340 |
return ""
|
341 |
+
lines.append("Current Wallet Data:\n")
|
342 |
for addr, data in self.context.items():
|
343 |
lines.append(f"Wallet {addr[:8]}...{addr[-6:]}:")
|
344 |
+
lines.append(f" ETH Balance: {data['eth_balance']:.4f}")
|
345 |
+
lines.append(f" Tokens: {len(data['tokens'])}")
|
346 |
if data['nft_collections']:
|
347 |
lines.append(" NFT Collections:")
|
348 |
+
for c_name, info in data['nft_collections'].items():
|
349 |
+
lines.append(f" - {c_name}: {info['count']} NFTs")
|
350 |
return "\n".join(lines)
|
351 |
|
352 |
async def process_message(
|
|
|
355 |
history: Optional[ChatHistory] = None
|
356 |
) -> Tuple[ChatHistory, Dict[str, Any], List[str]]:
|
357 |
"""
|
358 |
+
1) Detect Ethereum address
|
359 |
+
2) Etherscan fetch (ETH, tokens, NFT holdings)
|
360 |
+
3) OpenSea fetch for actual NFT images
|
361 |
+
4) Chat response via OpenAI
|
362 |
+
Returns (updated_history, updated_context, list_of_image_URLs)
|
363 |
"""
|
364 |
if history is None:
|
365 |
history = []
|
366 |
|
|
|
367 |
if not message.strip():
|
368 |
return history, self.context, []
|
369 |
|
370 |
+
# Attempt to detect address
|
371 |
match = re.search(Config.ETHEREUM_ADDRESS_REGEX, message)
|
372 |
+
images_collected: List[str] = []
|
|
|
373 |
if match:
|
374 |
address = match.group(0)
|
375 |
+
# Add partial response to history
|
376 |
+
partial_msg = f"Analyzing wallet: {address}..."
|
377 |
+
history.append((message, partial_msg))
|
378 |
+
|
379 |
try:
|
380 |
+
# 1) Etherscan fetch
|
381 |
async with WalletAnalyzer(self.etherscan_key) as analyzer:
|
382 |
wallet_data = await analyzer.get_portfolio_data(address)
|
383 |
self.context[address] = wallet_data
|
384 |
|
385 |
+
# 2) Summarize
|
386 |
eth_bal = wallet_data["eth_balance"]
|
387 |
+
token_count = len(wallet_data["tokens"])
|
388 |
nft_map = wallet_data["nft_collections"]
|
389 |
total_nfts = sum(v["count"] for v in nft_map.values())
|
390 |
+
summary = [
|
391 |
+
f"π Summary for {address[:8]}...{address[-6:]}",
|
392 |
+
f"ETH: {eth_bal:.4f}",
|
393 |
+
f"Tokens: {token_count}",
|
394 |
f"NFTs: {total_nfts}"
|
395 |
]
|
396 |
+
# Add summary to chat
|
397 |
+
history.append((message, "\n".join(summary)))
|
398 |
+
|
399 |
+
# 3) For each NFT in aggregator, we do NOT have direct contract addresses by default.
|
400 |
+
# We need them to call OpenSea. We'll do a "light" approach:
|
401 |
+
# The aggregator only has "collection name" + token IDs, but not the contract address.
|
402 |
+
# We'll revise _get_nft_holdings if you want the actual "contract" for each NFT.
|
403 |
+
# For demonstration, let's fetch images for the first collection if the aggregator stored the "contract" somewhere.
|
404 |
+
|
405 |
+
# We'll do a quick approach: store the contract in aggregator code. (We can do that if you want.)
|
406 |
+
# We'll do a "skip" if no contract is found.
|
|
|
|
|
|
|
|
|
|
|
407 |
pass
|
408 |
|
|
|
|
|
409 |
except Exception as e:
|
410 |
+
err = f"Error analyzing wallet {address}: {str(e)}"
|
411 |
+
logger.error(err)
|
412 |
+
history.append((message, err))
|
413 |
+
# return with partial data
|
414 |
|
415 |
+
# Finally, generate an OpenAI response
|
416 |
try:
|
417 |
+
context_msg = self._format_context_message()
|
418 |
+
# Convert chat to OpenAI format
|
419 |
limit = Config.HISTORY_LIMIT
|
420 |
+
truncated = history[-limit:]
|
421 |
openai_msgs = []
|
422 |
+
for usr, ans in truncated:
|
423 |
openai_msgs.append({"role": "user", "content": usr})
|
424 |
openai_msgs.append({"role": "assistant", "content": ans})
|
425 |
|
426 |
+
openai.api_key = self.openai_key
|
427 |
client = openai.OpenAI(api_key=self.openai_key)
|
428 |
response = client.chat.completions.create(
|
429 |
model=Config.OPENAI_MODEL,
|
430 |
messages=[
|
431 |
{"role": "system", "content": Config.SYSTEM_PROMPT},
|
432 |
+
{"role": "system", "content": context_msg},
|
433 |
*openai_msgs,
|
434 |
{"role": "user", "content": message}
|
435 |
],
|
436 |
temperature=Config.TEMPERATURE,
|
437 |
max_tokens=Config.MAX_TOKENS
|
438 |
)
|
439 |
+
|
440 |
+
answer = response.choices[0].message.content
|
441 |
+
history.append((message, answer))
|
442 |
+
return history, self.context, images_collected
|
443 |
except Exception as e:
|
444 |
+
err_resp = f"OpenAI Error: {str(e)}"
|
445 |
+
logger.error(err_resp)
|
446 |
+
history.append((message, err_resp))
|
447 |
return history, self.context, []
|
448 |
|
449 |
def clear_context(self) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
|
450 |
+
"""Clears the chat context, returning empty context and chat."""
|
451 |
self.context = {}
|
452 |
return {}, []
|
453 |
class GradioInterface:
|
454 |
+
"""Manages the Gradio UI, with the NFT display on the top-right."""
|
455 |
|
456 |
def __init__(self):
|
457 |
self.chat_interface: Optional[ChatInterface] = None
|
|
|
460 |
def _create_interface(self) -> gr.Blocks:
|
461 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
462 |
gr.Markdown("""
|
463 |
+
# π LOSS DOG: Blockchain Wallet Analyzer (w/ OpenSea NFT Images)
|
464 |
|
465 |
+
**Instructions**:
|
466 |
+
- Enter your **OpenAI**, **Etherscan**, and **OpenSea** API keys below.
|
467 |
+
- Validate them, then you can chat about an Ethereum address.
|
468 |
+
- NFT images (if found) will display on the **top-right**.
|
469 |
""")
|
470 |
|
471 |
with gr.Row():
|
472 |
+
openai_key = gr.Textbox(
|
473 |
+
label="OpenAI API Key",
|
474 |
+
type="password",
|
475 |
+
placeholder="Enter your OpenAI API key..."
|
476 |
+
)
|
477 |
+
etherscan_key = gr.Textbox(
|
478 |
+
label="Etherscan API Key",
|
479 |
+
type="password",
|
480 |
+
placeholder="Enter your Etherscan API key..."
|
481 |
+
)
|
482 |
+
opensea_key = gr.Textbox(
|
483 |
+
label="OpenSea API Key",
|
484 |
+
type="password",
|
485 |
+
placeholder="Enter your OpenSea API key..."
|
486 |
+
)
|
487 |
|
488 |
+
validate_status = gr.Textbox(label="Validation Status", interactive=False)
|
489 |
+
validate_btn = gr.Button("Validate API Keys")
|
490 |
|
491 |
with gr.Row():
|
492 |
+
# Left: Chatbot
|
493 |
with gr.Column(scale=2):
|
494 |
chatbot = gr.Chatbot(
|
495 |
label="Chat History",
|
496 |
+
height=400,
|
497 |
value=[]
|
498 |
)
|
499 |
with gr.Row():
|
500 |
msg_input = gr.Textbox(
|
501 |
label="Message",
|
502 |
+
placeholder="Ask about an ETH address or your holdings..."
|
503 |
)
|
504 |
send_btn = gr.Button("Send")
|
505 |
+
|
506 |
+
# Right: NFT Gallery at the TOP (as you requested)
|
507 |
with gr.Column(scale=1):
|
508 |
+
nft_gallery = gr.Gallery(
|
509 |
+
label="NFT Images (Top-Right)",
|
510 |
+
columns=2,
|
511 |
+
show_label=True
|
512 |
+
)
|
513 |
+
|
514 |
+
# Then the wallet context below the gallery
|
515 |
wallet_context = gr.JSON(
|
516 |
label="Active Wallet Context",
|
517 |
value={}
|
518 |
)
|
|
|
|
|
|
|
|
|
519 |
clear_btn = gr.Button("Clear Context")
|
520 |
|
521 |
+
# Initially disable chat
|
522 |
msg_input.interactive = False
|
523 |
send_btn.interactive = False
|
524 |
|
525 |
+
def validate_keys(openai_k: str, etherscan_k: str, opensea_k: str) -> Tuple[str, gr.update, gr.update]:
|
526 |
+
"""Validate user-provided keys. Enable chat if all valid."""
|
527 |
+
is_valid, message = ChatInterface._validate_api_keys(openai_k, etherscan_k, opensea_k)
|
528 |
if is_valid:
|
529 |
+
self.chat_interface = ChatInterface(openai_k, etherscan_k, opensea_k)
|
530 |
return (
|
531 |
+
f"β
Valid: {message}",
|
532 |
gr.update(interactive=True),
|
533 |
gr.update(interactive=True)
|
534 |
)
|
535 |
+
else:
|
536 |
+
return (
|
537 |
+
f"β Invalid: {message}",
|
538 |
+
gr.update(interactive=False),
|
539 |
+
gr.update(interactive=False)
|
540 |
+
)
|
541 |
|
542 |
validate_btn.click(
|
543 |
fn=validate_keys,
|
544 |
+
inputs=[openai_key, etherscan_key, opensea_key],
|
545 |
+
outputs=[validate_status, msg_input, send_btn]
|
546 |
+
)
|
547 |
+
|
548 |
+
# Clear callback
|
549 |
+
def clear_all():
|
550 |
+
if self.chat_interface:
|
551 |
+
return self.chat_interface.clear_context()
|
552 |
+
return {}, []
|
553 |
+
|
554 |
+
clear_btn.click(
|
555 |
+
fn=clear_all,
|
556 |
+
inputs=[],
|
557 |
+
outputs=[wallet_context, chatbot]
|
558 |
)
|
559 |
|
560 |
async def handle_message(
|
|
|
562 |
chat_hist: List[Tuple[str, str]],
|
563 |
context: Dict[str, Any]
|
564 |
) -> Tuple[List[Tuple[str, str]], Dict[str, Any], List[str]]:
|
565 |
+
"""Process user message. Return (chat, context, NFT image URLs)."""
|
566 |
if not self.chat_interface:
|
567 |
return [], {}, []
|
568 |
+
|
569 |
try:
|
570 |
new_hist, new_ctx, nft_imgs = await self.chat_interface.process_message(message, chat_hist)
|
571 |
return new_hist, new_ctx, nft_imgs
|
572 |
except Exception as e:
|
573 |
+
logger.error(f"Error in handle_message: {e}")
|
574 |
+
if chat_hist is None:
|
575 |
chat_hist = []
|
576 |
chat_hist.append((message, f"Error: {str(e)}"))
|
577 |
return chat_hist, context, []
|
578 |
|
579 |
+
# Chat flow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
580 |
msg_input.submit(
|
581 |
fn=handle_message,
|
582 |
inputs=[msg_input, chatbot, wallet_context],
|
|
|
600 |
return demo
|
601 |
|
602 |
def launch(self):
|
603 |
+
"""Launch for Hugging Face Space (no need for server_name)."""
|
604 |
self.demo.queue()
|
605 |
self.demo.launch()
|
606 |
|
|
|
607 |
def main():
|
608 |
+
"""Main entry point: runs on Hugging Face Space."""
|
609 |
+
logger.info("Launching LOSS DOG on HF Space, with user-provided OpenSea key.")
|
610 |
interface = GradioInterface()
|
611 |
interface.launch()
|
612 |
|