jzou19950715 commited on
Commit
a35a442
·
verified ·
1 Parent(s): e7cc054

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +509 -371
app.py CHANGED
@@ -1,108 +1,210 @@
1
- ###############################################################################
2
- # IMPORTS & CONSTANTS #
3
- ###############################################################################
 
 
 
 
 
 
 
 
 
4
 
5
  import os
6
  import re
7
- import aiohttp
 
 
8
  import asyncio
9
- import openai
10
  from datetime import datetime
11
- from typing import List, Dict, Tuple, Any, Optional
12
  from decimal import Decimal
13
- import time
14
- import json
15
- import gradio as gr
16
-
17
- SYSTEM_PROMPT: str = """
18
- You are LOSS DOG 🐕 (Learning & Observing Smart Systems Digital Output Generator), an adorable blockchain-sniffing puppy!
19
- Your personality:
20
- - Friendly and enthusiastic
21
- - Explain findings in fun, simple ways
22
-
23
- Instructions:
24
- - You have access to detailed wallet data in your context
25
- - Use this data to provide specific answers about holdings
26
- - Reference exact numbers and collections when discussing NFTs
27
- - Compare wallets if multiple are available
28
- """
29
 
30
- ETHERSCAN_BASE_URL: str = "https://api.etherscan.io/api"
31
- ETHEREUM_ADDRESS_REGEX: str = r"0x[a-fA-F0-9]{40}"
32
-
33
- ###############################################################################
34
- # ETHERSCAN CLIENT CLASS #
35
- ###############################################################################
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- class EtherscanClient:
 
 
38
  def __init__(self, api_key: str):
 
39
  self.api_key = api_key
40
- self.base_url = ETHERSCAN_BASE_URL
41
  self.last_request_time = 0
42
- self.rate_limit_delay = 0.2 # 5 requests per second max for free tier
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- async def _rate_limit(self):
45
- """Implement rate limiting for Etherscan API"""
46
  current_time = time.time()
47
  time_passed = current_time - self.last_request_time
48
- if time_passed < self.rate_limit_delay:
49
- await asyncio.sleep(self.rate_limit_delay - time_passed)
50
  self.last_request_time = time.time()
51
 
52
- async def fetch_data(self, params: Dict[str, str]) -> Dict[str, Any]:
53
- """Generic method to fetch data from Etherscan API"""
54
- params["apikey"] = self.api_key
55
- await self._rate_limit()
56
-
57
- try:
58
- async with aiohttp.ClientSession() as session:
59
- async with session.get(self.base_url, params=params) as response:
60
- if response.status != 200:
61
- return {"error": f"API request failed: {response.status}"}
62
- data = await response.json()
63
- if data["status"] == "0":
64
- error_msg = data.get('message', 'Unknown error')
65
- if "Max rate limit reached" in error_msg:
66
- await asyncio.sleep(1)
67
- return await self.fetch_data(params)
68
- return {"error": f"API error: {error_msg}"}
69
- return data
70
- except Exception as e:
71
- return {"error": f"Error fetching data: {str(e)}"}
72
 
73
- async def get_portfolio_data(self, address: str) -> Dict[str, Any]:
74
- """Get complete portfolio including ETH, tokens, and NFTs"""
75
  # Get ETH balance
76
- eth_params = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  "module": "account",
78
  "action": "balance",
79
  "address": address,
80
  "tag": "latest"
81
  }
82
- eth_data = await self.fetch_data(eth_params)
83
- eth_balance = Decimal(eth_data.get("result", "0")) / Decimal("1000000000000000000")
84
 
85
- # Get token transfers
86
- token_params = {
 
87
  "module": "account",
88
  "action": "tokentx",
89
  "address": address,
90
  "sort": "desc"
91
  }
92
- token_data = await self.fetch_data(token_params)
93
-
94
- # Get NFTs
95
- nft_params = {
96
- "module": "account",
97
- "action": "tokennfttx",
98
- "address": address,
99
- "sort": "desc"
100
- }
101
- nft_data = await self.fetch_data(nft_params)
102
 
103
- # Process token holdings
104
- token_holdings = {}
105
- for tx in token_data.get("result", []):
106
  contract = tx["contractAddress"]
107
  if contract not in token_holdings:
108
  token_holdings[contract] = {
@@ -118,9 +220,30 @@ class EtherscanClient:
118
  elif tx["from"].lower() == address.lower():
119
  token_holdings[contract]["balance"] -= amount
120
 
121
- # Process NFTs
122
- nft_holdings = {}
123
- for tx in nft_data.get("result", []):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  collection_name = tx.get("tokenName", "Unknown Collection")
125
  token_id = tx["tokenID"]
126
  key = f"{tx['contractAddress']}_{token_id}"
@@ -132,335 +255,350 @@ class EtherscanClient:
132
  "contract": tx["contractAddress"],
133
  "acquired_time": tx["timeStamp"]
134
  }
 
 
 
 
 
135
  elif tx["from"].lower() == address.lower():
136
  nft_holdings.pop(key, None)
137
-
138
- # Organize NFTs by collection
139
- collections = {}
140
- for nft in nft_holdings.values():
141
- coll = nft["collection"]
142
- if coll not in collections:
143
- collections[coll] = []
144
- collections[coll].append(nft["token_id"])
145
 
146
  return {
147
- "address": address,
148
- "last_updated": datetime.now().isoformat(),
149
- "eth_balance": float(eth_balance),
150
- "tokens": [
151
- {
152
- "name": data["name"],
153
- "symbol": data["symbol"],
154
- "balance": float(data["balance"])
155
- }
156
- for data in token_holdings.values()
157
- if data["balance"] > 0
158
- ],
159
- "nft_collections": {
160
- name: {
161
- "count": len(tokens),
162
- "token_ids": tokens
163
- }
164
- for name, tokens in collections.items()
165
  }
 
 
166
  }
167
 
168
- ###############################################################################
169
- # HELPER FUNCTIONS #
170
- ###############################################################################
171
 
172
- def validate_openai_key(api_key: str) -> Tuple[bool, str]:
173
- """Validate OpenAI API key by making a minimal API request."""
174
- if not api_key:
175
- return False, "OpenAI API key is required!"
176
- try:
177
- client = openai.OpenAI(api_key=api_key)
178
- client.chat.completions.create(
179
- model="gpt-4o-mini", # Using the correct model name
180
- messages=[{"role": "user", "content": "test"}],
181
- max_tokens=4000
182
- )
183
- return True, "OpenAI API key is valid! 🎉"
184
- except Exception as e:
185
- return False, f"OpenAI API key validation failed: {str(e)}"
186
-
187
- def validate_etherscan_key(api_key: str) -> Tuple[bool, str]:
188
- """Validate Etherscan API key by checking the API."""
189
- if not api_key:
190
- return False, "Etherscan API key is required!"
191
-
192
- async def validate():
193
- client = EtherscanClient(api_key)
194
- params = {"module": "stats", "action": "ethsupply"}
195
- result = await client.fetch_data(params)
196
- return "error" not in result
197
 
198
- try:
199
- result = asyncio.run(validate())
200
- return (True, "Etherscan API key is valid! 🎉") if result else (False, "Invalid Etherscan API key")
201
- except Exception as e:
202
- return False, f"Etherscan API key validation failed: {str(e)}"
203
-
204
- ###############################################################################
205
- # MESSAGE PROCESSING #
206
- ###############################################################################
207
-
208
- async def process_message(
209
- message: str,
210
- openai_key: str,
211
- etherscan_key: str,
212
- history: List[Tuple[str, str]],
213
- context: Dict[str, Any]
214
- ) -> Tuple[List[Tuple[str, str]], Dict[str, Any], str]:
215
- """Process user input and generate responses using wallet context."""
216
- if not message.strip():
217
- return history, context, ""
218
-
219
- # Check for Ethereum address
220
- match = re.search(ETHEREUM_ADDRESS_REGEX, message)
221
- if match:
222
- address = match.group(0)
223
- client = EtherscanClient(etherscan_key)
224
-
225
  try:
226
- wallet_data = await client.get_portfolio_data(address)
227
- context[address] = wallet_data
 
 
 
 
 
228
 
229
- # Create summary
230
- summary = []
231
- summary.append(f"📊 Portfolio Summary for {address[:8]}...{address[-6:]}")
232
- summary.append(f"💎 ETH Balance: {wallet_data['eth_balance']:.4f} ETH")
233
- summary.append(f"🪙 Tokens: {len(wallet_data['tokens'])} different tokens")
234
- total_nfts = sum(coll['count'] for coll in wallet_data['nft_collections'].values())
235
- summary.append(f"🎨 NFTs: {total_nfts} NFTs in {len(wallet_data['nft_collections'])} collections")
236
 
237
- bot_message = "\n".join(summary)
238
- history.append((message, bot_message))
239
- return history, context, ""
240
 
241
  except Exception as e:
242
- error_message = f"Error analyzing wallet: {str(e)}"
243
- history.append((message, error_message))
244
- return history, context, ""
245
 
246
- # Generate response using OpenAI with context
247
- try:
248
- # Create context message from wallet data
249
- context_msg = "Current Wallet Data:\n\n" if context else ""
250
- for addr, data in context.items():
251
- context_msg += f"Wallet {addr[:8]}...{addr[-6:]}:\n"
252
- context_msg += f"- ETH Balance: {data['eth_balance']:.4f} ETH\n"
253
- context_msg += f"- Tokens: {len(data['tokens'])}\n"
 
 
 
 
254
 
255
  if data['tokens']:
256
- context_msg += " Token Holdings:\n"
257
  for token in data['tokens']:
258
- context_msg += f" * {token['name']} ({token['symbol']}): {token['balance']}\n"
 
 
259
 
260
  if data['nft_collections']:
261
- context_msg += " NFT Collections:\n"
262
  for name, info in data['nft_collections'].items():
263
- context_msg += f" * {name}: {info['count']} NFTs\n"
264
- if info['count'] <= 5: # Show token IDs for small collections
265
- context_msg += f" Token IDs: {', '.join(info['token_ids'])}\n"
266
- context_msg += "\n"
267
-
268
- # Convert history to OpenAI format
269
- chat_history = []
270
- for user_msg, assistant_msg in history[-5:]:
271
- chat_history.extend([
272
- {"role": "user", "content": user_msg},
273
- {"role": "assistant", "content": assistant_msg}
274
- ])
275
-
276
- client = openai.OpenAI(api_key=openai_key)
277
- response = client.chat.completions.create(
278
- model="gpt-4o-mini", # Using the correct model name
279
- messages=[
280
- {"role": "system", "content": SYSTEM_PROMPT},
281
- {"role": "system", "content": context_msg},
282
- *chat_history,
283
- {"role": "user", "content": message}
284
- ],
285
- temperature=0.7,
286
- max_tokens=4000
287
- )
288
 
289
- bot_message = response.choices[0].message.content
290
- history.append((message, bot_message))
291
- return history, context, ""
292
- except Exception as e:
293
- error_message = f"Error generating response: {str(e)}"
294
- history.append((message, error_message))
295
- return history, context, ""
296
-
297
- ###############################################################################
298
- # GRADIO INTERFACE #
299
- ###############################################################################
300
- def create_interface() -> gr.Blocks:
301
- """Create the Gradio interface."""
302
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
303
- gr.Markdown("""
304
- # 🐕 LOSS DOG: Blockchain Wallet Analyzer
305
-
306
- Welcome to LOSS DOG - Your friendly blockchain analysis companion!
307
- - Enter your API keys below to get started
308
- - Input an Ethereum wallet address to analyze
309
- - Chat about your wallet contents with context!
310
- """)
311
-
312
- # API Keys Section
313
- with gr.Row():
314
- with gr.Column():
315
- openai_key = gr.Textbox(
316
- label="OpenAI API Key",
317
- type="password",
318
- placeholder="Enter your OpenAI API key..."
319
- )
320
- with gr.Column():
321
- etherscan_key = gr.Textbox(
322
- label="Etherscan API Key",
323
- type="password",
324
- placeholder="Enter your Etherscan API key..."
325
- )
326
-
327
- validation_status = gr.Textbox(
328
- label="Validation Status",
329
- interactive=False
330
- )
331
- validate_btn = gr.Button("Validate API Keys", variant="primary")
332
-
333
- # Main Interface
334
- with gr.Row():
335
- # Chat Area (Left Side)
336
- with gr.Column(scale=2):
337
- chatbot = gr.Chatbot(
338
- label="Chat History",
339
- value=[],
340
- height=500,
341
- type="messages" # Explicitly set message type
342
- )
343
- with gr.Row():
344
- msg_input = gr.Textbox(
345
- label="Message",
346
- placeholder="Enter wallet address or ask about holdings...",
347
- show_label=True
348
  )
349
- send_btn = gr.Button("Send", variant="primary")
350
-
351
- # Context Sidebar (Right Side)
352
- with gr.Column(scale=1):
353
- wallet_context = gr.JSON(
354
- label="Active Wallet Context",
355
- show_label=True,
356
- value={}
357
- )
358
- clear_btn = gr.Button("Clear Context", variant="secondary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
 
360
- # Initial States
361
- msg_input.interactive = False
362
- send_btn.interactive = False
363
 
364
- def validate_keys(openai_k: str, etherscan_k: str) -> Tuple[str, gr.update, gr.update]:
365
- """Validate API keys."""
366
- openai_valid, openai_msg = validate_openai_key(openai_k)
367
- etherscan_valid, etherscan_msg = validate_etherscan_key(etherscan_k)
 
 
 
 
 
 
 
 
 
368
 
369
- if openai_valid and etherscan_valid:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  return (
371
- " Both API keys are valid! You can start chatting now.",
372
- gr.update(interactive=True),
373
- gr.update(interactive=True)
374
  )
375
- return (
376
- f"❌ Validation failed:\n{openai_msg}\n{etherscan_msg}",
377
- gr.update(interactive=False),
378
- gr.update(interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  )
380
 
381
- def clear_context() -> Tuple[Dict, List[Dict[str, str]]]:
382
- """Clear the wallet context and chat history."""
383
- empty_context = {}
384
- empty_history = [] # Empty list for chat history
385
- return empty_context, empty_history
386
-
387
- async def handle_message(message: str, openai_k: str, etherscan_k: str,
388
- chat_history: List[Dict[str, str]], context: Dict[str, Any]):
389
- """Async wrapper for process_message."""
390
- if message.strip() == "":
391
- return chat_history, context
392
-
393
- try:
394
- # Initialize empty history if None
395
- if chat_history is None:
396
- chat_history = []
397
-
398
- # Initialize empty context if None
399
- if context is None:
400
- context = {}
401
-
402
- # Process message
403
- history, new_context, _ = await process_message(
404
- message, openai_k, etherscan_k, chat_history, context
405
  )
406
-
407
- return history, new_context
408
- except Exception as e:
409
- # Handle errors by adding them to chat history
410
- if chat_history is None:
411
- chat_history = []
412
- chat_history.append({"role": "user", "content": message})
413
- chat_history.append({"role": "assistant", "content": f"Error: {str(e)}"})
414
- return chat_history, context
415
-
416
- # Connect Event Handlers
417
- validate_btn.click(
418
- fn=validate_keys,
419
- inputs=[openai_key, etherscan_key],
420
- outputs=[validation_status, msg_input, send_btn]
421
- )
422
 
423
- clear_btn.click(
424
- fn=clear_context,
425
- inputs=[],
426
- outputs=[wallet_context, chatbot]
427
- )
 
 
 
 
 
428
 
429
- # Message Handling
430
- submit_event = msg_input.submit(
431
- fn=handle_message,
432
- inputs=[msg_input, openai_key, etherscan_key, chatbot, wallet_context],
433
- outputs=[chatbot, wallet_context],
434
- show_progress=True
435
- ).then(
436
- lambda: gr.update(value=""),
437
- None,
438
- [msg_input]
439
- )
440
 
441
- send_event = send_btn.click(
442
- fn=handle_message,
443
- inputs=[msg_input, openai_key, etherscan_key, chatbot, wallet_context],
444
- outputs=[chatbot, wallet_context],
445
- show_progress=True
446
- ).then(
447
- lambda: gr.update(value=""),
448
- None,
449
- [msg_input]
450
- )
451
 
452
- return demo
 
 
 
453
 
454
 
455
- ###############################################################################
456
- # MAIN EXECUTION #
457
- ###############################################################################
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
  if __name__ == "__main__":
460
- demo = create_interface()
461
- demo.queue() # Enable queuing for async operations
462
- demo.launch(
463
- server_name="0.0.0.0",
464
- server_port=7860,
465
- share=True
466
- )
 
1
+ """
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
+ Author: Claude
9
+ Date: January 2025
10
+ """
11
+
12
+ from __future__ import annotations
13
 
14
  import os
15
  import re
16
+ import json
17
+ import time
18
+ import logging
19
  import asyncio
20
+ from typing import List, Dict, Tuple, Any, Optional, TypeVar, cast
21
  from datetime import datetime
 
22
  from decimal import Decimal
23
+ from dataclasses import dataclass
24
+ from enum import Enum
25
+ from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ import aiohttp
28
+ import openai
29
+ import gradio as gr
30
+ from tenacity import retry, stop_after_attempt, wait_exponential
31
+
32
+ # Type variables
33
+ T = TypeVar('T')
34
+ WalletData = Dict[str, Any]
35
+ ChatHistory = List[Tuple[str, str]]
36
+
37
+ # Configure logging
38
+ logging.basicConfig(
39
+ level=logging.INFO,
40
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
41
+ handlers=[
42
+ logging.FileHandler('blockchain_analyzer.log'),
43
+ logging.StreamHandler()
44
+ ]
45
+ )
46
+ logger = logging.getLogger(__name__)
47
+
48
+ class ConfigError(Exception):
49
+ """Raised when there's an error in configuration."""
50
+ pass
51
+
52
+ class APIError(Exception):
53
+ """Raised when there's an error in API calls."""
54
+ pass
55
+
56
+ class ValidationError(Exception):
57
+ """Raised when there's an error in input validation."""
58
+ pass
59
+
60
+ @dataclass
61
+ class Config:
62
+ """Application configuration settings."""
63
+ SYSTEM_PROMPT: str = """
64
+ You are LOSS DOG 🐕 (Learning & Observing Smart Systems Digital Output Generator),
65
+ an adorable blockchain-sniffing puppy!
66
+ Your personality:
67
+ - Friendly and enthusiastic
68
+ - Explain findings in fun, simple ways
69
+
70
+ Instructions:
71
+ - You have access to detailed wallet data in your context
72
+ - Use this data to provide specific answers about holdings
73
+ - Reference exact numbers and collections when discussing NFTs
74
+ - Compare wallets if multiple are available
75
+ """
76
+ ETHERSCAN_BASE_URL: str = "https://api.etherscan.io/api"
77
+ ETHEREUM_ADDRESS_REGEX: str = r"0x[a-fA-F0-9]{40}"
78
+ RATE_LIMIT_DELAY: float = 0.2 # 5 requests per second max for free tier
79
+ MAX_RETRIES: int = 3
80
+ OPENAI_MODEL: str = "gpt-4o-mini" # Updated to correct model name
81
+ MAX_TOKENS: int = 4000
82
+ TEMPERATURE: float = 0.7
83
+ HISTORY_LIMIT: int = 5
84
+
85
+ @classmethod
86
+ def load(cls, config_path: str | Path) -> Config:
87
+ """Load configuration from a JSON file."""
88
+ try:
89
+ with open(config_path) as f:
90
+ config_data = json.load(f)
91
+ return cls(**config_data)
92
+ except Exception as e:
93
+ logger.error(f"Error loading config: {e}")
94
+ return cls()
95
 
96
+ class WalletAnalyzer:
97
+ """Analyzes Ethereum wallet contents using Etherscan API."""
98
+
99
  def __init__(self, api_key: str):
100
+ """Initialize the analyzer with API key."""
101
  self.api_key = api_key
102
+ self.base_url = Config.ETHERSCAN_BASE_URL
103
  self.last_request_time = 0
104
+ self.session: Optional[aiohttp.ClientSession] = None
105
+
106
+ async def __aenter__(self) -> WalletAnalyzer:
107
+ """Create aiohttp session on context manager enter."""
108
+ self.session = aiohttp.ClientSession()
109
+ return self
110
+
111
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
112
+ """Close aiohttp session on context manager exit."""
113
+ if self.session:
114
+ await self.session.close()
115
+ self.session = None
116
+
117
+ @retry(
118
+ stop=stop_after_attempt(Config.MAX_RETRIES),
119
+ wait=wait_exponential(multiplier=1, min=4, max=10)
120
+ )
121
+ async def _fetch_data(self, params: Dict[str, str]) -> Dict[str, Any]:
122
+ """Fetch data from Etherscan API with retry logic."""
123
+ if not self.session:
124
+ raise APIError("No active session. Use context manager.")
125
+
126
+ await self._rate_limit()
127
+ params["apikey"] = self.api_key
128
+
129
+ try:
130
+ async with self.session.get(self.base_url, params=params) as response:
131
+ if response.status != 200:
132
+ raise APIError(f"API request failed: {response.status}")
133
+
134
+ data = await response.json()
135
+ if data["status"] == "0":
136
+ error_msg = data.get('message', 'Unknown error')
137
+ if "Max rate limit reached" in error_msg:
138
+ raise APIError("Rate limit exceeded")
139
+ raise APIError(f"API error: {error_msg}")
140
+
141
+ return data
142
+
143
+ except aiohttp.ClientError as e:
144
+ raise APIError(f"Network error: {str(e)}")
145
+ except Exception as e:
146
+ raise APIError(f"Unexpected error: {str(e)}")
147
 
148
+ async def _rate_limit(self) -> None:
149
+ """Implement rate limiting for Etherscan API."""
150
  current_time = time.time()
151
  time_passed = current_time - self.last_request_time
152
+ if time_passed < Config.RATE_LIMIT_DELAY:
153
+ await asyncio.sleep(Config.RATE_LIMIT_DELAY - time_passed)
154
  self.last_request_time = time.time()
155
 
156
+ @staticmethod
157
+ def _validate_address(address: str) -> bool:
158
+ """Validate Ethereum address format."""
159
+ return bool(re.match(Config.ETHEREUM_ADDRESS_REGEX, address))
160
+
161
+ async def get_portfolio_data(self, address: str) -> WalletData:
162
+ """Get complete portfolio including ETH, tokens, and NFTs."""
163
+ if not self._validate_address(address):
164
+ raise ValidationError(f"Invalid Ethereum address: {address}")
 
 
 
 
 
 
 
 
 
 
 
165
 
166
+ logger.info(f"Fetching portfolio data for {address}")
167
+
168
  # Get ETH balance
169
+ eth_balance = await self._get_eth_balance(address)
170
+
171
+ # Get token data
172
+ token_holdings = await self._get_token_holdings(address)
173
+
174
+ # Get NFT data
175
+ nft_collections = await self._get_nft_holdings(address)
176
+
177
+ return {
178
+ "address": address,
179
+ "last_updated": datetime.now().isoformat(),
180
+ "eth_balance": float(eth_balance),
181
+ "tokens": token_holdings,
182
+ "nft_collections": nft_collections
183
+ }
184
+
185
+ async def _get_eth_balance(self, address: str) -> Decimal:
186
+ """Get ETH balance for address."""
187
+ params = {
188
  "module": "account",
189
  "action": "balance",
190
  "address": address,
191
  "tag": "latest"
192
  }
193
+ data = await self._fetch_data(params)
194
+ return Decimal(data["result"]) / Decimal("1000000000000000000")
195
 
196
+ async def _get_token_holdings(self, address: str) -> List[Dict[str, Any]]:
197
+ """Get token holdings for address."""
198
+ params = {
199
  "module": "account",
200
  "action": "tokentx",
201
  "address": address,
202
  "sort": "desc"
203
  }
204
+ data = await self._fetch_data(params)
 
 
 
 
 
 
 
 
 
205
 
206
+ token_holdings: Dict[str, Dict[str, Any]] = {}
207
+ for tx in data.get("result", []):
 
208
  contract = tx["contractAddress"]
209
  if contract not in token_holdings:
210
  token_holdings[contract] = {
 
220
  elif tx["from"].lower() == address.lower():
221
  token_holdings[contract]["balance"] -= amount
222
 
223
+ return [
224
+ {
225
+ "name": data["name"],
226
+ "symbol": data["symbol"],
227
+ "balance": float(data["balance"])
228
+ }
229
+ for data in token_holdings.values()
230
+ if data["balance"] > 0
231
+ ]
232
+
233
+ async def _get_nft_holdings(self, address: str) -> Dict[str, Dict[str, Any]]:
234
+ """Get NFT holdings for address."""
235
+ params = {
236
+ "module": "account",
237
+ "action": "tokennfttx",
238
+ "address": address,
239
+ "sort": "desc"
240
+ }
241
+ data = await self._fetch_data(params)
242
+
243
+ nft_holdings: Dict[str, Dict[str, Any]] = {}
244
+ collections: Dict[str, List[str]] = {}
245
+
246
+ for tx in data.get("result", []):
247
  collection_name = tx.get("tokenName", "Unknown Collection")
248
  token_id = tx["tokenID"]
249
  key = f"{tx['contractAddress']}_{token_id}"
 
255
  "contract": tx["contractAddress"],
256
  "acquired_time": tx["timeStamp"]
257
  }
258
+
259
+ if collection_name not in collections:
260
+ collections[collection_name] = []
261
+ collections[collection_name].append(token_id)
262
+
263
  elif tx["from"].lower() == address.lower():
264
  nft_holdings.pop(key, None)
265
+ if collection_name in collections and token_id in collections[collection_name]:
266
+ collections[collection_name].remove(token_id)
 
 
 
 
 
 
267
 
268
  return {
269
+ name: {
270
+ "count": len(tokens),
271
+ "token_ids": tokens
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  }
273
+ for name, tokens in collections.items()
274
+ if tokens # Only include collections with tokens
275
  }
276
 
 
 
 
277
 
278
+ class ChatInterface:
279
+ """Handles chat interaction using OpenAI API."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
+ def __init__(self, openai_key: str, etherscan_key: str):
282
+ """Initialize chat interface with API keys."""
283
+ self.openai_key = openai_key
284
+ self.etherscan_key = etherscan_key
285
+ self.context: Dict[str, Any] = {}
286
+ openai.api_key = openai_key
287
+
288
+ @staticmethod
289
+ def _validate_api_keys(openai_key: str, etherscan_key: str) -> Tuple[bool, str]:
290
+ """Validate both API keys."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  try:
292
+ # Validate OpenAI key
293
+ client = openai.OpenAI(api_key=openai_key)
294
+ client.chat.completions.create(
295
+ model=Config.OPENAI_MODEL,
296
+ messages=[{"role": "user", "content": "test"}],
297
+ max_tokens=1
298
+ )
299
 
300
+ # Validate Etherscan key
301
+ async def validate_etherscan():
302
+ async with WalletAnalyzer(etherscan_key) as analyzer:
303
+ params = {"module": "stats", "action": "ethsupply"}
304
+ await analyzer._fetch_data(params)
 
 
305
 
306
+ asyncio.run(validate_etherscan())
307
+ return True, "Both API keys are valid! 🎉"
 
308
 
309
  except Exception as e:
310
+ return False, f"API key validation failed: {str(e)}"
 
 
311
 
312
+ def _format_context_message(self) -> str:
313
+ """Format wallet data as context message."""
314
+ if not self.context:
315
+ return ""
316
+
317
+ context_msg = ["Current Wallet Data:\n"]
318
+ for addr, data in self.context.items():
319
+ context_msg.extend([
320
+ f"Wallet {addr[:8]}...{addr[-6:]}:",
321
+ f"- ETH Balance: {data['eth_balance']:.4f} ETH",
322
+ f"- Tokens: {len(data['tokens'])}"
323
+ ])
324
 
325
  if data['tokens']:
326
+ context_msg.append(" Token Holdings:")
327
  for token in data['tokens']:
328
+ context_msg.append(
329
+ f" * {token['name']} ({token['symbol']}): {token['balance']}"
330
+ )
331
 
332
  if data['nft_collections']:
333
+ context_msg.append(" NFT Collections:")
334
  for name, info in data['nft_collections'].items():
335
+ context_msg.append(f" * {name}: {info['count']} NFTs")
336
+ if info['count'] <= 5:
337
+ context_msg.append(
338
+ f" Token IDs: {', '.join(map(str, info['token_ids']))}"
339
+ )
340
+
341
+ return "\n".join(context_msg)
342
+
343
+ async def process_message(
344
+ self,
345
+ message: str,
346
+ history: Optional[ChatHistory] = None
347
+ ) -> Tuple[ChatHistory, Dict[str, Any], str]:
348
+ """Process user message and generate response."""
349
+ if not message.strip():
350
+ return history or [], self.context, ""
351
+
352
+ history = history or []
 
 
 
 
 
 
 
353
 
354
+ # Check for Ethereum address
355
+ match = re.search(Config.ETHEREUM_ADDRESS_REGEX, message)
356
+ if match:
357
+ try:
358
+ address = match.group(0)
359
+ async with WalletAnalyzer(self.etherscan_key) as analyzer:
360
+ wallet_data = await analyzer.get_portfolio_data(address)
361
+ self.context[address] = wallet_data
362
+
363
+ summary = [
364
+ f"📊 Portfolio Summary for {address[:8]}...{address[-6:]}",
365
+ f"💎 ETH Balance: {wallet_data['eth_balance']:.4f} ETH",
366
+ f"🪙 Tokens: {len(wallet_data['tokens'])} different tokens"
367
+ ]
368
+
369
+ total_nfts = sum(
370
+ coll['count'] for coll in wallet_data['nft_collections'].values())
371
+
372
+ summary.append(
373
+ f"🎨 NFTs: {total_nfts} NFTs in {len(wallet_data['nft_collections'])} collections"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  )
375
+
376
+ bot_message = "\n".join(summary)
377
+ history.append((message, bot_message))
378
+ return history, self.context, ""
379
+
380
+ except Exception as e:
381
+ logger.error(f"Error analyzing wallet: {e}")
382
+ error_message = f"Error analyzing wallet: {str(e)}"
383
+ history.append((message, error_message))
384
+ return history, self.context, ""
385
+
386
+ # Generate response using OpenAI
387
+ try:
388
+ # Format context message
389
+ context_msg = self._format_context_message()
390
+
391
+ # Convert history to OpenAI format
392
+ chat_history = []
393
+ for user_msg, assistant_msg in history[-Config.HISTORY_LIMIT:]:
394
+ chat_history.extend([
395
+ {"role": "user", "content": user_msg},
396
+ {"role": "assistant", "content": assistant_msg}
397
+ ])
398
+
399
+ # Create OpenAI client and generate response
400
+ client = openai.OpenAI(api_key=self.openai_key)
401
+ response = client.chat.completions.create(
402
+ model=Config.OPENAI_MODEL,
403
+ messages=[
404
+ {"role": "system", "content": Config.SYSTEM_PROMPT},
405
+ {"role": "system", "content": context_msg},
406
+ *chat_history,
407
+ {"role": "user", "content": message}
408
+ ],
409
+ temperature=Config.TEMPERATURE,
410
+ max_tokens=Config.MAX_TOKENS
411
+ )
412
+
413
+ bot_message = response.choices[0].message.content
414
+ history.append((message, bot_message))
415
+ return history, self.context, ""
416
+
417
+ except Exception as e:
418
+ logger.error(f"Error generating response: {e}")
419
+ error_message = f"Error generating response: {str(e)}"
420
+ history.append((message, error_message))
421
+ return history, self.context, ""
422
+
423
+ def clear_context(self) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
424
+ """Clear the wallet context and chat history."""
425
+ self.context = {}
426
+ return {}, []
427
 
 
 
 
428
 
429
+ class GradioInterface:
430
+ """Handles Gradio web interface setup and interactions."""
431
+
432
+ def __init__(self):
433
+ """Initialize Gradio interface."""
434
+ self.chat_interface: Optional[ChatInterface] = None
435
+ self.demo = self._create_interface()
436
+
437
+ def _create_interface(self) -> gr.Blocks:
438
+ """Create and configure Gradio interface."""
439
+ with gr.Blocks(theme=gr.themes.Soft()) as demo:
440
+ gr.Markdown("""
441
+ # 🐕 LOSS DOG: Blockchain Wallet Analyzer
442
 
443
+ Welcome to LOSS DOG - Your friendly blockchain analysis companion!
444
+ - Enter your API keys below to get started
445
+ - Input an Ethereum wallet address to analyze
446
+ - Chat about your wallet contents with context!
447
+ """)
448
+
449
+ # API Keys Section
450
+ with gr.Row():
451
+ with gr.Column():
452
+ openai_key = gr.Textbox(
453
+ label="OpenAI API Key",
454
+ type="password",
455
+ placeholder="Enter your OpenAI API key..."
456
+ )
457
+ with gr.Column():
458
+ etherscan_key = gr.Textbox(
459
+ label="Etherscan API Key",
460
+ type="password",
461
+ placeholder="Enter your Etherscan API key..."
462
+ )
463
+
464
+ validation_status = gr.Textbox(
465
+ label="Validation Status",
466
+ interactive=False
467
+ )
468
+ validate_btn = gr.Button("Validate API Keys", variant="primary")
469
+
470
+ # Main Interface
471
+ with gr.Row():
472
+ # Chat Area (Left Side)
473
+ with gr.Column(scale=2):
474
+ chatbot = gr.Chatbot(
475
+ label="Chat History",
476
+ height=500,
477
+ value=[]
478
+ )
479
+ with gr.Row():
480
+ msg_input = gr.Textbox(
481
+ label="Message",
482
+ placeholder="Enter wallet address or ask about holdings...",
483
+ show_label=True
484
+ )
485
+ send_btn = gr.Button("Send", variant="primary")
486
+
487
+ # Context Sidebar (Right Side)
488
+ with gr.Column(scale=1):
489
+ wallet_context = gr.JSON(
490
+ label="Active Wallet Context",
491
+ show_label=True,
492
+ value={}
493
+ )
494
+ clear_btn = gr.Button("Clear Context", variant="secondary")
495
+
496
+ # Initial States
497
+ msg_input.interactive = False
498
+ send_btn.interactive = False
499
+
500
+ # Event Handlers
501
+ def validate_keys(openai_k: str, etherscan_k: str) -> Tuple[str, gr.update, gr.update]:
502
+ """Validate API keys and initialize chat interface."""
503
+ is_valid, message = ChatInterface._validate_api_keys(openai_k, etherscan_k)
504
+
505
+ if is_valid:
506
+ self.chat_interface = ChatInterface(openai_k, etherscan_k)
507
+ return (
508
+ "✅ API keys validated! You can start chatting now.",
509
+ gr.update(interactive=True),
510
+ gr.update(interactive=True)
511
+ )
512
  return (
513
+ f" Validation failed: {message}",
514
+ gr.update(interactive=False),
515
+ gr.update(interactive=False)
516
  )
517
+
518
+ async def handle_message(
519
+ message: str,
520
+ chat_history: List[Tuple[str, str]],
521
+ context: Dict[str, Any]
522
+ ) -> Tuple[List[Tuple[str, str]], Dict[str, Any]]:
523
+ """Handle incoming messages."""
524
+ if not self.chat_interface:
525
+ return [], {}
526
+
527
+ try:
528
+ history, new_context, _ = await self.chat_interface.process_message(
529
+ message,
530
+ chat_history
531
+ )
532
+ return history, new_context
533
+
534
+ except Exception as e:
535
+ logger.error(f"Error handling message: {e}")
536
+ if chat_history is None:
537
+ chat_history = []
538
+ chat_history.append((message, f"Error: {str(e)}"))
539
+ return chat_history, context
540
+
541
+ # Connect Event Handlers
542
+ validate_btn.click(
543
+ fn=validate_keys,
544
+ inputs=[openai_key, etherscan_key],
545
+ outputs=[validation_status, msg_input, send_btn]
546
  )
547
 
548
+ if self.chat_interface:
549
+ clear_btn.click(
550
+ fn=self.chat_interface.clear_context,
551
+ inputs=[],
552
+ outputs=[wallet_context, chatbot]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
 
555
+ # Message Handling
556
+ msg_input.submit(
557
+ fn=handle_message,
558
+ inputs=[msg_input, chatbot, wallet_context],
559
+ outputs=[chatbot, wallet_context]
560
+ ).then(
561
+ lambda: gr.update(value=""),
562
+ None,
563
+ [msg_input]
564
+ )
565
 
566
+ send_btn.click(
567
+ fn=handle_message,
568
+ inputs=[msg_input, chatbot, wallet_context],
569
+ outputs=[chatbot, wallet_context]
570
+ ).then(
571
+ lambda: gr.update(value=""),
572
+ None,
573
+ [msg_input]
574
+ )
 
 
575
 
576
+ return demo
 
 
 
 
 
 
 
 
 
577
 
578
+ def launch(self, **kwargs):
579
+ """Launch the Gradio interface."""
580
+ self.demo.queue()
581
+ self.demo.launch(**kwargs)
582
 
583
 
584
+ def main():
585
+ """Main entry point for the application."""
586
+ try:
587
+ # Load configuration
588
+ config = Config.load("config.json")
589
+
590
+ # Initialize and launch interface
591
+ interface = GradioInterface()
592
+ interface.launch(
593
+ server_name="0.0.0.0",
594
+ server_port=7860,
595
+ share=True
596
+ )
597
+
598
+ except Exception as e:
599
+ logger.error(f"Application startup failed: {e}")
600
+ raise
601
+
602
 
603
  if __name__ == "__main__":
604
+ main()