jzou19950715 commited on
Commit
aa4bdc8
Β·
verified Β·
1 Parent(s): 5d753f7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +244 -172
app.py CHANGED
@@ -2,11 +2,9 @@
2
  Blockchain Wallet Analyzer - A tool for analyzing Ethereum wallet contents and NFT holdings.
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, 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
@@ -20,12 +18,14 @@ import json
20
  import time
21
  import logging
22
  import asyncio
23
- from typing import List, Dict, Tuple, Any, Optional, TypeVar, cast
 
24
  from datetime import datetime
25
  from decimal import Decimal
26
  from dataclasses import dataclass
27
- from enum import Enum
28
  from pathlib import Path
 
 
29
 
30
  import aiohttp
31
  import openai
@@ -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/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
@@ -105,15 +105,9 @@ class WalletAnalyzer:
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
- """
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.")
119
 
@@ -137,7 +131,7 @@ class WalletAnalyzer:
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:
@@ -146,36 +140,31 @@ class WalletAnalyzer:
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:
@@ -191,8 +180,8 @@ class WalletAnalyzer:
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,21 +190,22 @@ class WalletAnalyzer:
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"]
207
- if contract not in token_map:
208
- token_map[contract] = {
209
  "name": tx["tokenName"],
210
  "symbol": tx["tokenSymbol"],
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():
218
- token_map[contract]["balance"] -= amount
219
 
220
  return [
221
  {
@@ -226,10 +216,25 @@ class WalletAnalyzer:
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",
@@ -239,64 +244,98 @@ class WalletAnalyzer:
239
  }
240
  data = await self._fetch_data(params)
241
 
242
- nft_holdings: Dict[str, Dict[str, Any]] = {}
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
@@ -307,7 +346,10 @@ class ChatInterface:
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)
@@ -316,7 +358,7 @@ class ChatInterface:
316
  messages=[{"role": "user", "content": "test"}],
317
  max_tokens=1
318
  )
319
-
320
  # Check Etherscan
321
  async def check_etherscan():
322
  async with WalletAnalyzer(etherscan_key) as analyzer:
@@ -324,29 +366,29 @@ class ChatInterface:
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(
@@ -356,72 +398,102 @@ class ChatInterface:
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)
@@ -429,29 +501,30 @@ class ChatInterface:
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,12 +533,12 @@ class GradioInterface:
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():
@@ -489,52 +562,51 @@ class GradioInterface:
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
  )
@@ -545,7 +617,7 @@ class GradioInterface:
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()
@@ -557,26 +629,24 @@ class GradioInterface:
557
  outputs=[wallet_context, chatbot]
558
  )
559
 
 
560
  async def handle_message(
561
  message: str,
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],
@@ -587,6 +657,7 @@ class GradioInterface:
587
  [msg_input]
588
  )
589
 
 
590
  send_btn.click(
591
  fn=handle_message,
592
  inputs=[msg_input, chatbot, wallet_context],
@@ -600,13 +671,14 @@ class GradioInterface:
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
 
 
2
  Blockchain Wallet Analyzer - A tool for analyzing Ethereum wallet contents and NFT holdings.
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
 
18
  import time
19
  import logging
20
  import asyncio
21
+ import base64
22
+ from typing import List, Dict, Tuple, Any, Optional, TypeVar
23
  from datetime import datetime
24
  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
 
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
 
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:
112
  raise APIError("No active session. Use context manager.")
113
 
 
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:
 
140
 
141
  @staticmethod
142
  def _validate_address(address: str) -> bool:
143
+ """Validate Ethereum address format."""
144
  return bool(re.match(Config.ETHEREUM_ADDRESS_REGEX, address))
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:
 
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",
 
190
  "sort": "desc"
191
  }
192
  data = await self._fetch_data(params)
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
  {
 
216
  for v in token_map.values() if v["balance"] > 0
217
  ]
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",
 
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
 
346
 
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
355
  client = openai.OpenAI(api_key=openai_key)
 
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
  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(
 
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)
 
501
  model=Config.OPENAI_MODEL,
502
  messages=[
503
  {"role": "system", "content": Config.SYSTEM_PROMPT},
504
+ {"role": "system", "content": context_str},
505
  *openai_msgs,
506
  {"role": "user", "content": message}
507
  ],
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
  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():
 
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
  )
 
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()
 
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
  [msg_input]
658
  )
659
 
660
+ # Send button callback
661
  send_btn.click(
662
  fn=handle_message,
663
  inputs=[msg_input, chatbot, wallet_context],
 
671
  return demo
672
 
673
  def launch(self):
 
674
  self.demo.queue()
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