jzou19950715 commited on
Commit
116fef8
Β·
verified Β·
1 Parent(s): aa4bdc8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +188 -238
app.py CHANGED
@@ -3,8 +3,8 @@ Blockchain Wallet Analyzer - A tool for analyzing Ethereum wallet contents and N
3
 
4
  This module provides a complete implementation of a blockchain wallet analysis tool
5
  with a Gradio web interface. It includes wallet analysis, NFT tracking,
6
- interactive chat capabilities via the OpenAI API,
7
- and now NFT image rendering from OpenSea (with user-provided API key).
8
 
9
  Author: Claude
10
  Date: January 2025
@@ -25,11 +25,11 @@ from decimal import Decimal
25
  from dataclasses import dataclass
26
  from pathlib import Path
27
  from io import BytesIO
28
- from PIL import Image
29
 
30
  import aiohttp
31
  import openai
32
  import gradio as gr
 
33
  from tenacity import retry, stop_after_attempt, wait_exponential
34
 
35
  # Type variables
@@ -69,6 +69,7 @@ class Config:
69
  Your personality:
70
  - Friendly and enthusiastic
71
  - Explain findings in fun, simple ways
 
72
 
73
  Instructions:
74
  - You have access to detailed wallet data in your context
@@ -78,34 +79,36 @@ 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/sec for free tier
82
  MAX_RETRIES: int = 3
83
- OPENAI_MODEL: str = "gpt-4o-mini" # GPT model name
84
  MAX_TOKENS: int = 4000
85
  TEMPERATURE: float = 0.7
86
  HISTORY_LIMIT: int = 5
 
 
87
  class WalletAnalyzer:
88
  """Analyzes Ethereum wallet contents using Etherscan API."""
89
 
90
  def __init__(self, api_key: str):
91
- """Initialize the analyzer with API key."""
92
  self.api_key = api_key
93
  self.base_url = Config.ETHERSCAN_BASE_URL
94
- self.last_request_time = 0
95
  self.session: Optional[aiohttp.ClientSession] = None
 
96
 
97
  async def __aenter__(self) -> WalletAnalyzer:
98
- """Create aiohttp session on context manager enter."""
99
  self.session = aiohttp.ClientSession()
100
  return self
101
 
102
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
103
- """Close aiohttp session on context manager exit."""
104
  if self.session:
105
  await self.session.close()
106
  self.session = None
107
 
108
- @retry(stop=stop_after_attempt(Config.MAX_RETRIES), wait=wait_exponential(multiplier=1, min=4, max=10))
 
 
 
109
  async def _fetch_data(self, params: Dict[str, str]) -> Dict[str, Any]:
110
  """Fetch data from Etherscan with retry logic."""
111
  if not self.session:
@@ -113,17 +116,16 @@ class WalletAnalyzer:
113
 
114
  await self._rate_limit()
115
  params["apikey"] = self.api_key
116
-
117
  try:
118
  async with self.session.get(self.base_url, params=params) as response:
119
  if response.status != 200:
120
- raise APIError(f"API request failed: {response.status}")
121
  data = await response.json()
122
  if data.get("status") == "0":
123
- err_msg = data.get("message", "Unknown Etherscan error")
124
- if "Max rate limit reached" in err_msg:
125
- raise APIError("Rate limit exceeded")
126
- raise APIError(f"API error: {err_msg}")
127
  return data
128
  except aiohttp.ClientError as e:
129
  raise APIError(f"Network error: {str(e)}")
@@ -131,7 +133,7 @@ class WalletAnalyzer:
131
  raise APIError(f"Unexpected error: {str(e)}")
132
 
133
  async def _rate_limit(self) -> None:
134
- """Simple rate limit for Etherscan free tier requests."""
135
  now = time.time()
136
  diff = now - self.last_request_time
137
  if diff < Config.RATE_LIMIT_DELAY:
@@ -145,30 +147,30 @@ class WalletAnalyzer:
145
 
146
  async def get_portfolio_data(self, address: str) -> WalletData:
147
  """
148
- Get entire portfolio data:
149
- - ETH balance
150
- - ERC-20 tokens
151
- - NFT collections (with contract + token_ids)
 
 
152
  """
153
  if not self._validate_address(address):
154
  raise ValidationError(f"Invalid Ethereum address: {address}")
155
 
156
  logger.info(f"Fetching portfolio data for {address}")
157
-
158
  eth_balance = await self._get_eth_balance(address)
159
- token_holdings = await self._get_token_holdings(address)
160
- nft_collections = await self._get_nft_holdings(address)
161
-
162
  return {
163
  "address": address,
164
  "last_updated": datetime.now().isoformat(),
165
  "eth_balance": float(eth_balance),
166
- "tokens": token_holdings,
167
- "nft_collections": nft_collections
168
  }
169
 
170
  async def _get_eth_balance(self, address: str) -> Decimal:
171
- """Get ETH balance for address."""
172
  params = {
173
  "module": "account",
174
  "action": "balance",
@@ -179,10 +181,7 @@ class WalletAnalyzer:
179
  return Decimal(data["result"]) / Decimal("1e18")
180
 
181
  async def _get_token_holdings(self, address: str) -> List[Dict[str, Any]]:
182
- """
183
- Retrieve ERC-20 token balances for the address by
184
- summing inbound/outbound transactions.
185
- """
186
  params = {
187
  "module": "account",
188
  "action": "tokentx",
@@ -193,19 +192,19 @@ class WalletAnalyzer:
193
 
194
  token_map: Dict[str, Dict[str, Any]] = {}
195
  for tx in data.get("result", []):
196
- caddr = tx["contractAddress"]
197
- if caddr not in token_map:
198
- token_map[caddr] = {
199
  "name": tx["tokenName"],
200
  "symbol": tx["tokenSymbol"],
201
  "decimals": int(tx["tokenDecimal"]),
202
  "balance": Decimal(0)
203
  }
204
- amount = Decimal(tx["value"]) / Decimal(10 ** token_map[caddr]["decimals"])
205
  if tx["to"].lower() == address.lower():
206
- token_map[caddr]["balance"] += amount
207
  elif tx["from"].lower() == address.lower():
208
- token_map[caddr]["balance"] -= amount
209
 
210
  return [
211
  {
@@ -218,23 +217,8 @@ class WalletAnalyzer:
218
 
219
  async def _get_nft_holdings(self, address: str) -> Dict[str, Any]:
220
  """
221
- Retrieve NFT holdings for address.
222
- We store them in a structure that includes contract addresses + token_ids
223
- so we can fetch images from OpenSea easily.
224
-
225
- Example structure:
226
- {
227
- "collections": [
228
- {
229
- "collection_name": "Bored Ape Yacht Club",
230
- "items": [
231
- { "contract": "0xbc4ca0e...", "token_id": "1234" },
232
- ...
233
- ]
234
- },
235
- ...
236
- ]
237
- }
238
  """
239
  params = {
240
  "module": "account",
@@ -243,99 +227,92 @@ class WalletAnalyzer:
243
  "sort": "desc"
244
  }
245
  data = await self._fetch_data(params)
246
-
247
  if data.get("status") != "1" or "result" not in data:
248
  return {"collections": []}
249
 
250
- # We'll track them by (collection_name -> list of NFT {contract, token_id})
251
- coll_map: Dict[str, List[Dict[str, str]]] = {}
252
- # We'll also track what's currently owned
253
- ownership = {}
254
-
255
  for tx in data["result"]:
256
  contract = tx["contractAddress"]
257
  coll_name = tx.get("tokenName", "Unknown Collection")
258
  token_id = tx["tokenID"]
259
  key = f"{contract}_{token_id}"
260
 
261
- # If received
262
  if tx["to"].lower() == address.lower():
263
- ownership[key] = {
264
  "contract": contract,
265
- "collection_name": coll_name,
266
- "token_id": token_id
267
  }
268
- # If sent out
269
  elif tx["from"].lower() == address.lower():
270
- if key in ownership:
271
- ownership.pop(key, None)
272
-
273
- # Group by collection
274
- for entry in ownership.values():
275
- coll = entry["collection_name"]
276
- if coll not in coll_map:
277
- coll_map[coll] = []
278
- coll_map[coll].append({
279
- "contract": entry["contract"],
280
- "token_id": entry["token_id"]
 
281
  })
282
-
283
- # Convert to list form
284
  collections_out = []
285
- for c_name, items in coll_map.items():
286
  collections_out.append({
287
- "collection_name": c_name,
288
  "items": items
289
  })
290
 
291
- return {"collections": collections_out}
292
- #########################
293
- # OPENSEA IMAGE FETCHING
294
- #########################
295
-
296
- OPENSEA_API_BASE = "https://api.opensea.io/api/v2/chain/ethereum/contract"
297
 
298
  async def fetch_nft_metadata(opensea_key: str, contract: str, token_id: str) -> Dict[str, Any]:
299
  """
300
- Fetch NFT metadata (name, image_url) from OpenSea.
301
- Returns { "name": ..., "image_url": ... } or { "error": ... }
302
  """
303
- url = f"{OPENSEA_API_BASE}/{contract}/nfts/{token_id}"
304
  headers = {"X-API-KEY": opensea_key} if opensea_key else {}
305
-
306
  async with aiohttp.ClientSession() as session:
307
- async with session.get(url, headers=headers) as response:
308
- if response.status == 403:
309
- return {"error": "403 Forbidden: Invalid or restricted OpenSea API key."}
310
- if response.status == 404:
311
  return {"error": f"404 Not Found: {contract} #{token_id}"}
312
-
313
  try:
314
- data = await response.json()
315
  except Exception as e:
316
- return {"error": f"Invalid JSON from OpenSea: {str(e)}"}
317
-
318
- # parse data
319
  nft_obj = data.get("nft", {})
320
  name = nft_obj.get("name", f"NFT #{token_id}")
321
  image_url = nft_obj.get("image_url", "")
322
  return {"name": name, "image_url": image_url}
323
 
324
- def convert_image_to_base64(image_bytes: bytes) -> str:
325
  """
326
- Convert raw image bytes to a base64 data URI that Gradio can display.
327
  """
 
 
 
 
 
 
 
328
  try:
329
- img = Image.open(BytesIO(image_bytes))
330
- buffer = BytesIO()
331
- img.save(buffer, format="PNG")
332
- b64_str = base64.b64encode(buffer.getvalue()).decode()
333
- return f"data:image/png;base64,{b64_str}"
334
  except Exception as e:
335
- logger.error(f"Error converting image to base64: {e}")
336
- return ""
 
 
337
  class ChatInterface:
338
- """Handles the chat + wallet + NFT images integration."""
339
 
340
  def __init__(self, openai_key: str, etherscan_key: str, opensea_key: str):
341
  self.openai_key = openai_key
@@ -347,8 +324,7 @@ class ChatInterface:
347
  @staticmethod
348
  def _validate_api_keys(openai_key: str, etherscan_key: str, opensea_key: str) -> Tuple[bool, str]:
349
  """
350
- Validate OpenAI + Etherscan keys with sample calls,
351
- and check if OpenSea key is non-empty.
352
  """
353
  try:
354
  # Check OpenAI
@@ -358,7 +334,7 @@ class ChatInterface:
358
  messages=[{"role": "user", "content": "test"}],
359
  max_tokens=1
360
  )
361
-
362
  # Check Etherscan
363
  async def check_etherscan():
364
  async with WalletAnalyzer(etherscan_key) as analyzer:
@@ -366,138 +342,120 @@ class ChatInterface:
366
  await analyzer._fetch_data(params)
367
  asyncio.run(check_etherscan())
368
 
 
369
  if not opensea_key.strip():
370
- return False, "OpenSea API key cannot be empty."
371
 
372
- return True, "All API keys are valid!"
373
  except Exception as e:
374
  return False, f"API key validation failed: {str(e)}"
375
 
376
  def _format_context_message(self) -> str:
377
- """Format the wallet data in the system prompt for GPT context."""
378
  lines = []
379
  if not self.context:
380
  return ""
381
- lines.append("Current Wallet Data:\n")
382
- for addr, data in self.context.items():
383
- lines.append(f"Wallet {addr[:8]}...{addr[-6:]}")
384
- lines.append(f" ETH Balance: {data['eth_balance']:.4f}")
385
- lines.append(f" ERC-20 Tokens: {len(data['tokens'])}")
386
  # NFT aggregator
387
- coll_struct = data["nft_collections"]
388
- if "collections" in coll_struct:
389
- lines.append(" NFT Collections:")
390
- for col in coll_struct["collections"]:
391
- lines.append(f" * {col['collection_name']}: {len(col['items'])} NFT(s)")
392
  return "\n".join(lines)
393
 
394
  async def process_message(
395
  self,
396
  message: str,
397
  history: Optional[ChatHistory] = None
398
- ) -> Tuple[ChatHistory, Dict[str, Any], List[str]]:
399
  """
400
  1) Detect Ethereum address
401
- 2) Fetch wallet data from Etherscan
402
- 3) For each NFT, fetch from OpenSea + convert to base64
403
- 4) Return image data + chat response
404
  """
405
  if history is None:
406
  history = []
 
407
  if not message.strip():
408
  return history, self.context, []
409
 
410
- # Attempt address detection
411
  match = re.search(Config.ETHEREUM_ADDRESS_REGEX, message)
412
- nft_images_base64: List[str] = []
413
 
414
  if match:
415
  eth_address = match.group(0)
416
- partial_info = f"Analyzing {eth_address}..."
417
- history.append((message, partial_info))
 
418
 
419
  try:
420
- # Grab wallet data
421
  async with WalletAnalyzer(self.etherscan_key) as analyzer:
422
  wallet_data = await analyzer.get_portfolio_data(eth_address)
423
-
424
  self.context[eth_address] = wallet_data
425
 
426
  # Summaries
427
- eth_bal = wallet_data["eth_balance"]
428
- token_count = len(wallet_data["tokens"])
429
- nft_data = wallet_data["nft_collections"]
430
-
431
- # Summarize
432
  lines = [
433
  f"πŸ“Š Summary for {eth_address[:8]}...{eth_address[-6:]}",
434
- f"ETH: {eth_bal:.4f}",
435
- f"Tokens: {token_count}"
436
  ]
437
- total_nft_count = 0
438
- # If we have "collections"
439
- if "collections" in nft_data:
440
- for col in nft_data["collections"]:
441
- total_nft_count += len(col["items"])
442
- lines.append(f"NFTs: {total_nft_count}")
443
-
444
- # Append summary
445
  history.append((message, "\n".join(lines)))
446
 
447
- # Fetch NFT images (limit for demonstration)
448
- # For each collection, let's do up to 2 NFTs
449
- # We'll do 2 collections max as well, to avoid heavy rate usage
450
- if "collections" in nft_data:
451
- for col in nft_data["collections"][:2]:
452
- for nft_obj in col["items"][:2]:
453
- contract = nft_obj["contract"]
454
- token_id = nft_obj["token_id"]
455
-
456
- # 1) fetch metadata from OpenSea
457
- meta = await fetch_nft_metadata(self.opensea_key, contract, token_id)
458
  if "error" in meta:
459
- logger.warning(f"Failed to fetch NFT: {meta['error']}")
460
  continue
461
  image_url = meta["image_url"]
462
  if not image_url:
463
- logger.info(f"No image for NFT {contract}#{token_id}")
464
  continue
 
 
 
 
 
 
 
465
 
466
- # 2) Download image & convert to base64
467
- async with aiohttp.ClientSession() as session:
468
- try:
469
- async with session.get(image_url) as resp:
470
- if resp.status == 200:
471
- raw_img = await resp.read()
472
- img_b64 = convert_image_to_base64(raw_img)
473
- if img_b64:
474
- nft_images_base64.append(img_b64)
475
- # Also reflect in chat
476
- found_msg = f"Found NFT: {meta['name']} (Contract {contract[:8]}...{contract[-6:]}, ID {token_id})"
477
- history.append((message, found_msg))
478
- else:
479
- logger.warning(f"Image fetch failed: {resp.status}")
480
- except Exception as e:
481
- logger.error(f"Error fetching NFT image: {e}")
482
  except Exception as e:
483
- err_msg = f"Error analyzing wallet: {str(e)}"
484
- logger.error(err_msg)
485
- history.append((message, err_msg))
486
 
487
- # Generate response from GPT
488
  try:
489
  context_str = self._format_context_message()
490
- # Convert local chat history -> OpenAI format
491
  limit = Config.HISTORY_LIMIT
492
- truncated = history[-limit:]
493
  openai_msgs = []
494
- for umsg, amsg in truncated:
495
- openai_msgs.append({"role": "user", "content": umsg})
496
- openai_msgs.append({"role": "assistant", "content": amsg})
497
 
 
498
  openai.api_key = self.openai_key
499
  client = openai.OpenAI(api_key=self.openai_key)
500
- response = client.chat.completions.create(
501
  model=Config.OPENAI_MODEL,
502
  messages=[
503
  {"role": "system", "content": Config.SYSTEM_PROMPT},
@@ -508,23 +466,25 @@ class ChatInterface:
508
  temperature=Config.TEMPERATURE,
509
  max_tokens=Config.MAX_TOKENS
510
  )
511
- final_answer = response.choices[0].message.content
512
- history.append((message, final_answer))
 
 
513
 
514
- # Return new chat, new context, and the collected base64 images
515
- return history, self.context, nft_images_base64
516
  except Exception as e:
517
- logger.error(f"OpenAI Error: {e}")
518
- err_resp = f"OpenAI error: {str(e)}"
519
- history.append((message, err_resp))
520
  return history, self.context, []
521
 
522
  def clear_context(self) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
523
- """Clear wallet context + chat."""
524
  self.context = {}
525
  return {}, []
 
 
526
  class GradioInterface:
527
- """Manages the Gradio UI for Hugging Face Space, with NFT images top-right."""
528
 
529
  def __init__(self):
530
  self.chat_interface: Optional[ChatInterface] = None
@@ -533,12 +493,11 @@ class GradioInterface:
533
  def _create_interface(self) -> gr.Blocks:
534
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
535
  gr.Markdown("""
536
- # πŸ• LOSS DOG: Blockchain Wallet Analyzer (NFT Images at Top-Right)
537
-
538
- **Instructions**:
539
- - Enter your **OpenAI**, **Etherscan**, and **OpenSea** API keys below.
540
- - Validate them, then chat with your Ethereum wallet address.
541
- - NFT images (if any) will appear as **base64** images in the top-right gallery.
542
  """)
543
 
544
  with gr.Row():
@@ -558,55 +517,47 @@ class GradioInterface:
558
  placeholder="Enter your OpenSea API key..."
559
  )
560
 
561
- validate_status = gr.Textbox(label="Validation Status", interactive=False)
562
- validate_btn = gr.Button("Validate API Keys")
563
 
564
  with gr.Row():
565
  # Main Chat Column
566
  with gr.Column(scale=2):
567
- chatbot = gr.Chatbot(
568
- label="Chat History",
569
- height=420,
570
- value=[]
571
- )
572
  with gr.Row():
573
  msg_input = gr.Textbox(
574
  label="Message",
575
- placeholder="Enter an Ethereum address or question..."
576
  )
577
- send_btn = gr.Button("Send")
578
-
579
- # NFT Gallery on Top-Right
580
  with gr.Column(scale=1):
581
  nft_gallery = gr.Gallery(
582
  label="NFT Images (Top-Right)",
583
- columns=2,
584
- show_label=True
585
  )
586
- # Then wallet context below
587
  wallet_context = gr.JSON(
588
  label="Active Wallet Context",
589
  value={}
590
  )
591
  clear_btn = gr.Button("Clear Context")
592
 
593
- # Initially disable chat until keys are validated
594
  msg_input.interactive = False
595
  send_btn.interactive = False
596
 
597
- # Validation function
598
- def validate_keys(openai_k: str, etherscan_k: str, opensea_k: str):
599
- is_valid, message = ChatInterface._validate_api_keys(openai_k, etherscan_k, opensea_k)
600
  if is_valid:
601
  self.chat_interface = ChatInterface(openai_k, etherscan_k, opensea_k)
602
  return (
603
- f"βœ… {message}",
604
  gr.update(interactive=True),
605
  gr.update(interactive=True)
606
  )
607
  else:
608
  return (
609
- f"❌ {message}",
610
  gr.update(interactive=False),
611
  gr.update(interactive=False)
612
  )
@@ -614,11 +565,11 @@ class GradioInterface:
614
  validate_btn.click(
615
  fn=validate_keys,
616
  inputs=[openai_key, etherscan_key, opensea_key],
617
- outputs=[validate_status, msg_input, send_btn]
618
  )
619
 
620
- # Clear context
621
  def clear_all():
 
622
  if self.chat_interface:
623
  return self.chat_interface.clear_context()
624
  return {}, []
@@ -629,24 +580,26 @@ class GradioInterface:
629
  outputs=[wallet_context, chatbot]
630
  )
631
 
632
- # Async callback to handle chat
633
  async def handle_message(
634
  message: str,
635
  chat_hist: List[Tuple[str, str]],
636
  context: Dict[str, Any]
637
- ) -> Tuple[List[Tuple[str, str]], Dict[str, Any], List[str]]:
638
- """Process user message. Return updated chat, context, base64 images."""
639
  if not self.chat_interface:
640
  return [], {}, []
 
641
  try:
642
- new_hist, new_ctx, nft_imgs_b64 = await self.chat_interface.process_message(message, chat_hist)
643
- return new_hist, new_ctx, nft_imgs_b64
644
  except Exception as e:
645
  logger.error(f"Error in handle_message: {e}")
 
 
646
  chat_hist.append((message, f"Error: {str(e)}"))
647
  return chat_hist, context, []
648
 
649
- # Submit callback
650
  msg_input.submit(
651
  fn=handle_message,
652
  inputs=[msg_input, chatbot, wallet_context],
@@ -657,7 +610,6 @@ class GradioInterface:
657
  [msg_input]
658
  )
659
 
660
- # Send button callback
661
  send_btn.click(
662
  fn=handle_message,
663
  inputs=[msg_input, chatbot, wallet_context],
@@ -675,10 +627,8 @@ class GradioInterface:
675
  self.demo.launch()
676
 
677
  def main():
678
- """
679
- Main entry point for the Hugging Face Space.
680
- """
681
- logger.info("Launching LOSS DOG with NFT image display (top-right).")
682
  interface = GradioInterface()
683
  interface.launch()
684
 
 
3
 
4
  This module provides a complete implementation of a blockchain wallet analysis tool
5
  with a Gradio web interface. It includes wallet analysis, NFT tracking,
6
+ interactive chat capabilities using the OpenAI API,
7
+ and NFT image rendering from OpenSea via PIL objects.
8
 
9
  Author: Claude
10
  Date: January 2025
 
25
  from dataclasses import dataclass
26
  from pathlib import Path
27
  from io import BytesIO
 
28
 
29
  import aiohttp
30
  import openai
31
  import gradio as gr
32
+ from PIL import Image
33
  from tenacity import retry, stop_after_attempt, wait_exponential
34
 
35
  # Type variables
 
69
  Your personality:
70
  - Friendly and enthusiastic
71
  - Explain findings in fun, simple ways
72
+ - Provide NFT images from OpenSea when possible
73
 
74
  Instructions:
75
  - You have access to detailed wallet data in your context
 
79
  """
80
  ETHERSCAN_BASE_URL: str = "https://api.etherscan.io/api"
81
  ETHEREUM_ADDRESS_REGEX: str = r"0x[a-fA-F0-9]{40}"
82
+ RATE_LIMIT_DELAY: float = 0.2 # 5 requests per second
83
  MAX_RETRIES: int = 3
84
+ OPENAI_MODEL: str = "gpt-4o-mini" # Example updated model
85
  MAX_TOKENS: int = 4000
86
  TEMPERATURE: float = 0.7
87
  HISTORY_LIMIT: int = 5
88
+ #### Part 2: Wallet Analyzer (Etherscan)
89
+
90
  class WalletAnalyzer:
91
  """Analyzes Ethereum wallet contents using Etherscan API."""
92
 
93
  def __init__(self, api_key: str):
 
94
  self.api_key = api_key
95
  self.base_url = Config.ETHERSCAN_BASE_URL
 
96
  self.session: Optional[aiohttp.ClientSession] = None
97
+ self.last_request_time = 0
98
 
99
  async def __aenter__(self) -> WalletAnalyzer:
 
100
  self.session = aiohttp.ClientSession()
101
  return self
102
 
103
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
 
104
  if self.session:
105
  await self.session.close()
106
  self.session = None
107
 
108
+ @retry(
109
+ stop=stop_after_attempt(Config.MAX_RETRIES),
110
+ wait=wait_exponential(multiplier=1, min=4, max=10)
111
+ )
112
  async def _fetch_data(self, params: Dict[str, str]) -> Dict[str, Any]:
113
  """Fetch data from Etherscan with retry logic."""
114
  if not self.session:
 
116
 
117
  await self._rate_limit()
118
  params["apikey"] = self.api_key
 
119
  try:
120
  async with self.session.get(self.base_url, params=params) as response:
121
  if response.status != 200:
122
+ raise APIError(f"Etherscan request failed: {response.status}")
123
  data = await response.json()
124
  if data.get("status") == "0":
125
+ err = data.get("message", "Unknown Etherscan error")
126
+ if "Max rate limit reached" in err:
127
+ raise APIError("Etherscan rate limit exceeded")
128
+ raise APIError(f"Etherscan error: {err}")
129
  return data
130
  except aiohttp.ClientError as e:
131
  raise APIError(f"Network error: {str(e)}")
 
133
  raise APIError(f"Unexpected error: {str(e)}")
134
 
135
  async def _rate_limit(self) -> None:
136
+ """Simple rate limiting for Etherscan free tier."""
137
  now = time.time()
138
  diff = now - self.last_request_time
139
  if diff < Config.RATE_LIMIT_DELAY:
 
147
 
148
  async def get_portfolio_data(self, address: str) -> WalletData:
149
  """
150
+ Return a dictionary with:
151
+ - address
152
+ - last_updated
153
+ - eth_balance
154
+ - tokens (list of ERC-20)
155
+ - nft_collections (with contract & token_id)
156
  """
157
  if not self._validate_address(address):
158
  raise ValidationError(f"Invalid Ethereum address: {address}")
159
 
160
  logger.info(f"Fetching portfolio data for {address}")
 
161
  eth_balance = await self._get_eth_balance(address)
162
+ tokens = await self._get_token_holdings(address)
163
+ nft_colls = await self._get_nft_holdings(address)
164
+
165
  return {
166
  "address": address,
167
  "last_updated": datetime.now().isoformat(),
168
  "eth_balance": float(eth_balance),
169
+ "tokens": tokens,
170
+ "nft_collections": nft_colls
171
  }
172
 
173
  async def _get_eth_balance(self, address: str) -> Decimal:
 
174
  params = {
175
  "module": "account",
176
  "action": "balance",
 
181
  return Decimal(data["result"]) / Decimal("1e18")
182
 
183
  async def _get_token_holdings(self, address: str) -> List[Dict[str, Any]]:
184
+ """Fetch ERC-20 tokens for address."""
 
 
 
185
  params = {
186
  "module": "account",
187
  "action": "tokentx",
 
192
 
193
  token_map: Dict[str, Dict[str, Any]] = {}
194
  for tx in data.get("result", []):
195
+ contract = tx["contractAddress"]
196
+ if contract not in token_map:
197
+ token_map[contract] = {
198
  "name": tx["tokenName"],
199
  "symbol": tx["tokenSymbol"],
200
  "decimals": int(tx["tokenDecimal"]),
201
  "balance": Decimal(0)
202
  }
203
+ amount = Decimal(tx["value"]) / Decimal(10 ** token_map[contract]["decimals"])
204
  if tx["to"].lower() == address.lower():
205
+ token_map[contract]["balance"] += amount
206
  elif tx["from"].lower() == address.lower():
207
+ token_map[contract]["balance"] -= amount
208
 
209
  return [
210
  {
 
217
 
218
  async def _get_nft_holdings(self, address: str) -> Dict[str, Any]:
219
  """
220
+ Return { "collections": [ { "collection_name": str, "items": [{ contract, token_id }, ...] }, ... ] }
221
+ so we can fetch images from OpenSea by contract + token_id.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  """
223
  params = {
224
  "module": "account",
 
227
  "sort": "desc"
228
  }
229
  data = await self._fetch_data(params)
230
+
231
  if data.get("status") != "1" or "result" not in data:
232
  return {"collections": []}
233
 
234
+ # track final ownership
235
+ ownership_map = {} # key = (contract_token), value = {contract, token_id, coll_name}
 
 
 
236
  for tx in data["result"]:
237
  contract = tx["contractAddress"]
238
  coll_name = tx.get("tokenName", "Unknown Collection")
239
  token_id = tx["tokenID"]
240
  key = f"{contract}_{token_id}"
241
 
 
242
  if tx["to"].lower() == address.lower():
243
+ ownership_map[key] = {
244
  "contract": contract,
245
+ "token_id": token_id,
246
+ "collection_name": coll_name
247
  }
 
248
  elif tx["from"].lower() == address.lower():
249
+ if key in ownership_map:
250
+ ownership_map.pop(key, None)
251
+
252
+ # group by collection_name
253
+ coll_dict: Dict[str, List[Dict[str, str]]] = {}
254
+ for item in ownership_map.values():
255
+ c_name = item["collection_name"]
256
+ if c_name not in coll_dict:
257
+ coll_dict[c_name] = []
258
+ coll_dict[c_name].append({
259
+ "contract": item["contract"],
260
+ "token_id": item["token_id"]
261
  })
262
+
 
263
  collections_out = []
264
+ for cname, items in coll_dict.items():
265
  collections_out.append({
266
+ "collection_name": cname,
267
  "items": items
268
  })
269
 
270
+ return { "collections": collections_out }
271
+ #### Part 3: OpenSea & PIL Conversion
 
 
 
 
272
 
273
  async def fetch_nft_metadata(opensea_key: str, contract: str, token_id: str) -> Dict[str, Any]:
274
  """
275
+ Fetch NFT metadata (including image_url) from OpenSea v2.
276
+ Returns { "name": str, "image_url": str } or { "error": str }
277
  """
278
+ url = f"https://api.opensea.io/api/v2/chain/ethereum/contract/{contract}/nfts/{token_id}"
279
  headers = {"X-API-KEY": opensea_key} if opensea_key else {}
 
280
  async with aiohttp.ClientSession() as session:
281
+ async with session.get(url, headers=headers) as resp:
282
+ if resp.status == 403:
283
+ return {"error": "403 Forbidden: OpenSea API key issue"}
284
+ if resp.status == 404:
285
  return {"error": f"404 Not Found: {contract} #{token_id}"}
 
286
  try:
287
+ data = await resp.json()
288
  except Exception as e:
289
+ return {"error": f"OpenSea JSON parse error: {str(e)}"}
290
+
 
291
  nft_obj = data.get("nft", {})
292
  name = nft_obj.get("name", f"NFT #{token_id}")
293
  image_url = nft_obj.get("image_url", "")
294
  return {"name": name, "image_url": image_url}
295
 
296
+ async def fetch_image_as_pil(url: str) -> Optional[Image.Image]:
297
  """
298
+ Download an image from a URL and return as a PIL Image.
299
  """
300
+ if not url:
301
+ return None
302
+ async with aiohttp.ClientSession() as session:
303
+ async with session.get(url) as resp:
304
+ if resp.status != 200:
305
+ return None
306
+ raw_bytes = await resp.read()
307
  try:
308
+ return Image.open(BytesIO(raw_bytes))
 
 
 
 
309
  except Exception as e:
310
+ logger.error(f"Error converting to PIL: {e}")
311
+ return None
312
+ #### Part 4: ChatInterface
313
+
314
  class ChatInterface:
315
+ """Handles chat logic with Etherscan (wallet data), OpenSea (NFT images), and OpenAI (chat)."""
316
 
317
  def __init__(self, openai_key: str, etherscan_key: str, opensea_key: str):
318
  self.openai_key = openai_key
 
324
  @staticmethod
325
  def _validate_api_keys(openai_key: str, etherscan_key: str, opensea_key: str) -> Tuple[bool, str]:
326
  """
327
+ Validate all keys. We'll do a minimal check for OpenSea (non-empty).
 
328
  """
329
  try:
330
  # Check OpenAI
 
334
  messages=[{"role": "user", "content": "test"}],
335
  max_tokens=1
336
  )
337
+
338
  # Check Etherscan
339
  async def check_etherscan():
340
  async with WalletAnalyzer(etherscan_key) as analyzer:
 
342
  await analyzer._fetch_data(params)
343
  asyncio.run(check_etherscan())
344
 
345
+ # Check OpenSea
346
  if not opensea_key.strip():
347
+ return False, "OpenSea API key is empty!"
348
 
349
+ return True, "All API keys validated!"
350
  except Exception as e:
351
  return False, f"API key validation failed: {str(e)}"
352
 
353
  def _format_context_message(self) -> str:
354
+ """Format wallet data for GPT system prompt."""
355
  lines = []
356
  if not self.context:
357
  return ""
358
+ lines.append("Current Wallet Data:")
359
+ for addr, wdata in self.context.items():
360
+ lines.append(f"Wallet {addr[:8]}...{addr[-6:]}:")
361
+ lines.append(f" ETH Balance: {wdata['eth_balance']:.4f}")
362
+ lines.append(f" # of Tokens: {len(wdata['tokens'])}")
363
  # NFT aggregator
364
+ nft_data = wdata["nft_collections"]
365
+ if "collections" in nft_data:
366
+ lines.append(" NFT Collections:")
367
+ for c in nft_data["collections"]:
368
+ lines.append(f" - {c['collection_name']}: {len(c['items'])} NFT(s)")
369
  return "\n".join(lines)
370
 
371
  async def process_message(
372
  self,
373
  message: str,
374
  history: Optional[ChatHistory] = None
375
+ ) -> Tuple[ChatHistory, Dict[str, Any], List[Image.Image]]:
376
  """
377
  1) Detect Ethereum address
378
+ 2) Etherscan for wallet data
379
+ 3) For each NFT, fetch from OpenSea, convert to PIL
380
+ 4) Return images + chat response
381
  """
382
  if history is None:
383
  history = []
384
+
385
  if not message.strip():
386
  return history, self.context, []
387
 
 
388
  match = re.search(Config.ETHEREUM_ADDRESS_REGEX, message)
389
+ nft_images: List[Image.Image] = []
390
 
391
  if match:
392
  eth_address = match.group(0)
393
+ # partial
394
+ partial_text = f"Analyzing {eth_address}..."
395
+ history.append((message, partial_text))
396
 
397
  try:
398
+ # Etherscan
399
  async with WalletAnalyzer(self.etherscan_key) as analyzer:
400
  wallet_data = await analyzer.get_portfolio_data(eth_address)
 
401
  self.context[eth_address] = wallet_data
402
 
403
  # Summaries
 
 
 
 
 
404
  lines = [
405
  f"πŸ“Š Summary for {eth_address[:8]}...{eth_address[-6:]}",
406
+ f"ETH: {wallet_data['eth_balance']:.4f}",
407
+ f"Tokens: {len(wallet_data['tokens'])}"
408
  ]
409
+ nft_info = wallet_data["nft_collections"]
410
+ total_nfts = 0
411
+ if "collections" in nft_info:
412
+ for c in nft_info["collections"]:
413
+ total_nfts += len(c["items"])
414
+ lines.append(f"NFTs: {total_nfts}")
415
+ # Add to chat
 
416
  history.append((message, "\n".join(lines)))
417
 
418
+ # If we want to fetch images for, say, first 2 collections, 2 items each
419
+ if "collections" in nft_info:
420
+ for coll in nft_info["collections"][:2]:
421
+ for item in coll["items"][:2]:
422
+ # 1) fetch metadata
423
+ meta = await fetch_nft_metadata(self.opensea_key, item["contract"], item["token_id"])
 
 
 
 
 
424
  if "error" in meta:
425
+ logger.warning(f"OpenSea metadata error: {meta['error']}")
426
  continue
427
  image_url = meta["image_url"]
428
  if not image_url:
429
+ logger.info(f"No image for {meta['name']}")
430
  continue
431
+ # 2) Download + convert to PIL
432
+ pil_img = await fetch_image_as_pil(image_url)
433
+ if pil_img:
434
+ nft_images.append(pil_img)
435
+ # Also mention in chat
436
+ found_msg = f"Found NFT image: {meta['name']} (contract {item['contract'][:8]}...{item['contract'][-6:]}, id={item['token_id']})"
437
+ history.append((message, found_msg))
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  except Exception as e:
440
+ err = f"Error analyzing {eth_address}: {str(e)}"
441
+ logger.error(err)
442
+ history.append((message, err))
443
 
444
+ # Now do an OpenAI chat
445
  try:
446
  context_str = self._format_context_message()
447
+ # Convert chat to OpenAI format
448
  limit = Config.HISTORY_LIMIT
449
+ short_hist = history[-limit:]
450
  openai_msgs = []
451
+ for usr, ans in short_hist:
452
+ openai_msgs.append({"role": "user", "content": usr})
453
+ openai_msgs.append({"role": "assistant", "content": ans})
454
 
455
+ # openai
456
  openai.api_key = self.openai_key
457
  client = openai.OpenAI(api_key=self.openai_key)
458
+ resp = client.chat.completions.create(
459
  model=Config.OPENAI_MODEL,
460
  messages=[
461
  {"role": "system", "content": Config.SYSTEM_PROMPT},
 
466
  temperature=Config.TEMPERATURE,
467
  max_tokens=Config.MAX_TOKENS
468
  )
469
+ final_msg = resp.choices[0].message.content
470
+ history.append((message, final_msg))
471
+
472
+ return history, self.context, nft_images
473
 
 
 
474
  except Exception as e:
475
+ logger.error(f"OpenAI error: {e}")
476
+ err_chat = f"OpenAI error: {str(e)}"
477
+ history.append((message, err_chat))
478
  return history, self.context, []
479
 
480
  def clear_context(self) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
481
+ """Clear the wallet context and chat."""
482
  self.context = {}
483
  return {}, []
484
+ #### Part 5: Gradio UI & Launch
485
+
486
  class GradioInterface:
487
+ """Manages the Gradio UI for Hugging Face Space, with top-right NFT gallery as PIL images."""
488
 
489
  def __init__(self):
490
  self.chat_interface: Optional[ChatInterface] = None
 
493
  def _create_interface(self) -> gr.Blocks:
494
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
495
  gr.Markdown("""
496
+ # πŸ• LOSS DOG: Blockchain Wallet Analyzer (NFT Images, top-right)
497
+
498
+ - Enter your **OpenAI**, **Etherscan**, **OpenSea** keys below
499
+ - Validate, then chat with your Ethereum address
500
+ - NFT images will appear as **PIL** objects in the top-right
 
501
  """)
502
 
503
  with gr.Row():
 
517
  placeholder="Enter your OpenSea API key..."
518
  )
519
 
520
+ validation_status = gr.Textbox(label="Validation Status", interactive=False)
521
+ validate_btn = gr.Button("Validate API Keys", variant="primary")
522
 
523
  with gr.Row():
524
  # Main Chat Column
525
  with gr.Column(scale=2):
526
+ chatbot = gr.Chatbot(label="Chat History", height=420, value=[])
 
 
 
 
527
  with gr.Row():
528
  msg_input = gr.Textbox(
529
  label="Message",
530
+ placeholder="Enter an ETH address or question..."
531
  )
532
+ send_btn = gr.Button("Send", variant="primary")
533
+ # Top-Right: NFT Gallery (PIL images)
 
534
  with gr.Column(scale=1):
535
  nft_gallery = gr.Gallery(
536
  label="NFT Images (Top-Right)",
537
+ columns=2
 
538
  )
 
539
  wallet_context = gr.JSON(
540
  label="Active Wallet Context",
541
  value={}
542
  )
543
  clear_btn = gr.Button("Clear Context")
544
 
 
545
  msg_input.interactive = False
546
  send_btn.interactive = False
547
 
548
+ def validate_keys(openai_k: str, etherscan_k: str, opensea_k: str) -> Tuple[str, gr.update, gr.update]:
549
+ """Validate user-provided keys; enable chat if all pass."""
550
+ is_valid, msg = ChatInterface._validate_api_keys(openai_k, etherscan_k, opensea_k)
551
  if is_valid:
552
  self.chat_interface = ChatInterface(openai_k, etherscan_k, opensea_k)
553
  return (
554
+ f"βœ… {msg}",
555
  gr.update(interactive=True),
556
  gr.update(interactive=True)
557
  )
558
  else:
559
  return (
560
+ f"❌ {msg}",
561
  gr.update(interactive=False),
562
  gr.update(interactive=False)
563
  )
 
565
  validate_btn.click(
566
  fn=validate_keys,
567
  inputs=[openai_key, etherscan_key, opensea_key],
568
+ outputs=[validation_status, msg_input, send_btn]
569
  )
570
 
 
571
  def clear_all():
572
+ """Clear wallet context and chat."""
573
  if self.chat_interface:
574
  return self.chat_interface.clear_context()
575
  return {}, []
 
580
  outputs=[wallet_context, chatbot]
581
  )
582
 
 
583
  async def handle_message(
584
  message: str,
585
  chat_hist: List[Tuple[str, str]],
586
  context: Dict[str, Any]
587
+ ) -> Tuple[List[Tuple[str, str]], Dict[str, Any], List[Image.Image]]:
588
+ """Process user input, return updated chat, context, list of PIL images."""
589
  if not self.chat_interface:
590
  return [], {}, []
591
+
592
  try:
593
+ new_history, new_context, images = await self.chat_interface.process_message(message, chat_hist)
594
+ return new_history, new_context, images
595
  except Exception as e:
596
  logger.error(f"Error in handle_message: {e}")
597
+ if chat_hist is None:
598
+ chat_hist = []
599
  chat_hist.append((message, f"Error: {str(e)}"))
600
  return chat_hist, context, []
601
 
602
+ # Chat flow
603
  msg_input.submit(
604
  fn=handle_message,
605
  inputs=[msg_input, chatbot, wallet_context],
 
610
  [msg_input]
611
  )
612
 
 
613
  send_btn.click(
614
  fn=handle_message,
615
  inputs=[msg_input, chatbot, wallet_context],
 
627
  self.demo.launch()
628
 
629
  def main():
630
+ """Main entry point for Hugging Face Space."""
631
+ logger.info("Launching LOSS DOG with PIL-based NFT images (top-right).")
 
 
632
  interface = GradioInterface()
633
  interface.launch()
634