iwa 0.0.10__py3-none-any.whl → 0.0.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -365,15 +365,29 @@ class ChainInterface:
365
365
  except Exception:
366
366
  return address[:6] + "..." + address[-4:]
367
367
 
368
- def get_token_decimals(self, address: EthereumAddress) -> int:
369
- """Get token decimals for an address."""
370
- try:
371
- from iwa.core.contracts.erc20 import ERC20Contract
368
+ def get_token_decimals(self, address: EthereumAddress, fallback_to_18: bool = True) -> Optional[int]:
369
+ """Get token decimals for an address.
372
370
 
373
- erc20 = ERC20Contract(address, self.chain.name.lower())
374
- return erc20.decimals if erc20.decimals is not None else 18
371
+ Args:
372
+ address: Token contract address.
373
+ fallback_to_18: If True, return 18 on error (default).
374
+ If False, return None on error (useful for detecting NFTs).
375
+
376
+ Returns:
377
+ Decimals as int, or None if error and fallback_to_18 is False.
378
+
379
+ """
380
+ try:
381
+ # Call decimals() directly without with_retry to avoid error logging
382
+ contract = self.web3.eth.contract(
383
+ address=self.web3.to_checksum_address(address),
384
+ abi=[{"constant": True, "inputs": [], "name": "decimals", "outputs": [{"type": "uint8"}], "type": "function"}]
385
+ )
386
+ return contract.functions.decimals().call()
375
387
  except Exception:
376
- return 18
388
+ if fallback_to_18:
389
+ return 18
390
+ return None
377
391
 
378
392
  def get_native_balance_wei(self, address: EthereumAddress):
379
393
  """Get the native balance in wei"""
iwa/core/cli.py CHANGED
@@ -14,6 +14,14 @@ from iwa.core.wallet import Wallet
14
14
  from iwa.tui.app import IwaApp
15
15
 
16
16
  iwa_cli = typer.Typer(help="iwa command line interface")
17
+
18
+ @iwa_cli.callback()
19
+ def main_callback(ctx: typer.Context):
20
+ """Initialize IWA CLI."""
21
+ # Print banner on startup
22
+ from iwa.core.utils import get_version, print_banner
23
+ print_banner("iwa", get_version("iwa"))
24
+
17
25
  wallet_cli = typer.Typer(help="Manage wallet")
18
26
 
19
27
  iwa_cli.add_typer(wallet_cli, name="wallet")
@@ -1,9 +1,10 @@
1
1
  """Transaction service module."""
2
2
 
3
3
  import time
4
- from typing import Dict, List, Optional, Tuple
4
+ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
5
5
 
6
6
  from loguru import logger
7
+ from web3 import Web3
7
8
  from web3 import exceptions as web3_exceptions
8
9
 
9
10
  from iwa.core.chain import ChainInterfaces
@@ -11,6 +12,191 @@ from iwa.core.db import log_transaction
11
12
  from iwa.core.keys import KeyStorage
12
13
  from iwa.core.services.account import AccountService
13
14
 
15
+ if TYPE_CHECKING:
16
+ from iwa.core.chain import ChainInterface
17
+
18
+ # ERC20 Transfer event signature: Transfer(address indexed from, address indexed to, uint256 value)
19
+ TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
20
+
21
+
22
+ class TransferLogger:
23
+ """Parse and log transfer events from transaction receipts."""
24
+
25
+ def __init__(
26
+ self,
27
+ account_service: AccountService,
28
+ chain_interface: "ChainInterface",
29
+ ):
30
+ """Initialize TransferLogger."""
31
+ self.account_service = account_service
32
+ self.chain_interface = chain_interface
33
+
34
+ def log_transfers(self, receipt: Dict, tx: Dict) -> None:
35
+ """Log all transfers (ERC20 and native) from a transaction receipt.
36
+
37
+ Args:
38
+ receipt: Transaction receipt containing logs.
39
+ tx: Original transaction dict.
40
+
41
+ """
42
+ # Log native value transfer if present
43
+ native_value = tx.get("value", 0)
44
+ if native_value and int(native_value) > 0:
45
+ self._log_native_transfer(tx, native_value)
46
+
47
+ # Log ERC20 transfers from event logs
48
+ logs = receipt.get("logs", [])
49
+ if hasattr(receipt, "logs"):
50
+ logs = receipt.logs
51
+
52
+ for log in logs:
53
+ self._process_log(log)
54
+
55
+ def _log_native_transfer(self, tx: Dict, value_wei: int) -> None:
56
+ """Log a native currency transfer."""
57
+ from_addr = tx.get("from", "")
58
+ to_addr = tx.get("to", "")
59
+
60
+ from_label = self._resolve_address_label(from_addr)
61
+ to_label = self._resolve_address_label(to_addr)
62
+
63
+ native_symbol = self.chain_interface.chain.native_currency
64
+ amount_eth = Web3.from_wei(value_wei, "ether")
65
+
66
+ logger.info(f"[TRANSFER] {amount_eth:.6g} {native_symbol}: {from_label} → {to_label}")
67
+
68
+ def _process_log(self, log) -> None:
69
+ """Process a single log entry for Transfer events."""
70
+ # Get topics - handle both dict and AttributeDict
71
+ topics = log.get("topics", []) if isinstance(log, dict) else getattr(log, "topics", [])
72
+
73
+ if not topics:
74
+ return
75
+
76
+ # Check if this is a Transfer event
77
+ first_topic = topics[0]
78
+ if isinstance(first_topic, bytes):
79
+ first_topic = "0x" + first_topic.hex()
80
+ elif hasattr(first_topic, "hex"):
81
+ first_topic = first_topic.hex()
82
+ if not first_topic.startswith("0x"):
83
+ first_topic = "0x" + first_topic
84
+
85
+ if first_topic.lower() != TRANSFER_EVENT_TOPIC.lower():
86
+ return
87
+
88
+ # Need at least 3 topics for indexed from/to
89
+ if len(topics) < 3:
90
+ return
91
+
92
+ try:
93
+ # Extract from/to from indexed topics (last 20 bytes of 32-byte topic)
94
+ from_topic = topics[1]
95
+ to_topic = topics[2]
96
+
97
+ from_addr = self._topic_to_address(from_topic)
98
+ to_addr = self._topic_to_address(to_topic)
99
+
100
+ # Extract amount from data
101
+ data = log.get("data", b"") if isinstance(log, dict) else getattr(log, "data", b"")
102
+ if isinstance(data, str):
103
+ data = bytes.fromhex(data.replace("0x", ""))
104
+
105
+ amount = int.from_bytes(data, "big") if data else 0
106
+
107
+ # Get token address
108
+ token_addr = (
109
+ log.get("address", "") if isinstance(log, dict) else getattr(log, "address", "")
110
+ )
111
+
112
+ self._log_erc20_transfer(token_addr, from_addr, to_addr, amount)
113
+
114
+ except Exception as e:
115
+ logger.debug(f"Failed to parse Transfer event: {e}")
116
+
117
+ def _topic_to_address(self, topic) -> str:
118
+ """Convert a 32-byte topic to a 20-byte address."""
119
+ if isinstance(topic, bytes):
120
+ # Last 20 bytes
121
+ addr_bytes = topic[-20:]
122
+ return Web3.to_checksum_address("0x" + addr_bytes.hex())
123
+ elif hasattr(topic, "hex"):
124
+ hex_str = topic.hex()
125
+ if not hex_str.startswith("0x"):
126
+ hex_str = "0x" + hex_str
127
+ # Last 40 chars (20 bytes)
128
+ return Web3.to_checksum_address("0x" + hex_str[-40:])
129
+ elif isinstance(topic, str):
130
+ if topic.startswith("0x"):
131
+ topic = topic[2:]
132
+ return Web3.to_checksum_address("0x" + topic[-40:])
133
+ return ""
134
+
135
+ def _log_erc20_transfer(
136
+ self, token_addr: str, from_addr: str, to_addr: str, amount_wei: int
137
+ ) -> None:
138
+ """Log an ERC20 transfer (or NFT transfer if detected)."""
139
+ from_label = self._resolve_address_label(from_addr)
140
+ to_label = self._resolve_address_label(to_addr)
141
+ token_label = self._resolve_token_label(token_addr)
142
+
143
+ # Try to get decimals - if None, it's an NFT (ERC721)
144
+ decimals = self.chain_interface.get_token_decimals(token_addr, fallback_to_18=False)
145
+
146
+ if decimals is not None:
147
+ amount = amount_wei / (10**decimals)
148
+ logger.info(f"[TRANSFER] {amount:.6g} {token_label}: {from_label} → {to_label}")
149
+ else:
150
+ # Likely an NFT (ERC721) - the amount is the token ID
151
+ if amount_wei > 0:
152
+ logger.info(f"[NFT TRANSFER] Token #{amount_wei} {token_label}: {from_label} → {to_label}")
153
+ else:
154
+ logger.debug(f"[NFT TRANSFER] {token_label}: {from_label} → {to_label}")
155
+
156
+ def _resolve_address_label(self, address: str) -> str:
157
+ """Resolve an address to a human-readable label.
158
+
159
+ Priority:
160
+ 1. Known wallet tag (from wallets.json)
161
+ 2. Known token name (it's a token contract)
162
+ 3. Abbreviated address
163
+
164
+ """
165
+ if not address:
166
+ return "unknown"
167
+
168
+ # 1. Check known wallets
169
+ tag = self.account_service.get_tag_by_address(address)
170
+ if tag:
171
+ return tag
172
+
173
+ # 2. Check if it's a known token contract
174
+ token_name = self.chain_interface.chain.get_token_name(address)
175
+ if token_name:
176
+ return f"{token_name}_contract"
177
+
178
+ # 3. Fallback to abbreviated address
179
+ return f"{address[:6]}...{address[-4:]}"
180
+
181
+ def _resolve_token_label(self, token_addr: str) -> str:
182
+ """Resolve a token address to its symbol.
183
+
184
+ Priority:
185
+ 1. Known token from chain config
186
+ 2. Abbreviated address
187
+
188
+ """
189
+ if not token_addr:
190
+ return "UNKNOWN"
191
+
192
+ # Check known tokens
193
+ token_name = self.chain_interface.chain.get_token_name(token_addr)
194
+ if token_name:
195
+ return token_name
196
+
197
+ # Fallback to abbreviated address
198
+ return f"{token_addr[:6]}...{token_addr[-4:]}"
199
+
14
200
 
15
201
  class TransactionService:
16
202
  """Manages transaction lifecycle: signing, sending, retrying."""
@@ -54,7 +240,7 @@ class TransactionService:
54
240
  logger.info(f"Transaction sent successfully. Tx Hash: {txn_hash.hex()}")
55
241
 
56
242
  self._log_successful_transaction(
57
- receipt, tx, signer_account, chain_name, txn_hash, tags
243
+ receipt, tx, signer_account, chain_name, txn_hash, tags, chain_interface
58
244
  )
59
245
  return True, receipt
60
246
 
@@ -115,7 +301,9 @@ class TransactionService:
115
301
  logger.exception(f"Unexpected error sending transaction: {e}")
116
302
  return False
117
303
 
118
- def _log_successful_transaction(self, receipt, tx, signer_account, chain_name, txn_hash, tags):
304
+ def _log_successful_transaction(
305
+ self, receipt, tx, signer_account, chain_name, txn_hash, tags, chain_interface
306
+ ):
119
307
  try:
120
308
  gas_cost_wei, gas_value_eur = self._calculate_gas_cost(receipt, tx, chain_name)
121
309
  final_tags = self._determine_tags(tx, tags)
@@ -132,6 +320,11 @@ class TransactionService:
132
320
  gas_value_eur=gas_value_eur,
133
321
  tags=final_tags if final_tags else None,
134
322
  )
323
+
324
+ # Log transfer events (ERC20 and native value)
325
+ transfer_logger = TransferLogger(self.account_service, chain_interface)
326
+ transfer_logger.log_transfers(receipt, tx)
327
+
135
328
  except Exception as log_err:
136
329
  logger.warning(f"Failed to log transaction: {log_err}")
137
330
 
iwa/core/utils.py CHANGED
@@ -84,3 +84,41 @@ def configure_logger():
84
84
 
85
85
  configure_logger.configured = True
86
86
  return logger
87
+
88
+ def get_version(package_name: str) -> str:
89
+ """Get package version."""
90
+ from importlib.metadata import version, PackageNotFoundError
91
+ try:
92
+ return version(package_name)
93
+ except PackageNotFoundError:
94
+ return "unknown"
95
+
96
+
97
+ def print_banner(service_name: str, service_version: str, extra_versions: dict = None) -> None:
98
+ """Print startup banner."""
99
+ try:
100
+ from rich.console import Console
101
+ from rich.panel import Panel
102
+ from rich.text import Text
103
+
104
+ console = Console(stderr=True)
105
+
106
+ # Build content
107
+ text = Text()
108
+ text.append(f"🚀 {service_name.upper()} ", style="bold cyan")
109
+ text.append(f"v{service_version}", style="bold yellow")
110
+
111
+ if extra_versions:
112
+ for name, ver in extra_versions.items():
113
+ text.append(f"\n📦 {name.upper()}: ", style="bold green")
114
+ text.append(f"v{ver}", style="bold yellow")
115
+
116
+ console.print(Panel(text, title="Startup", border_style="blue"))
117
+
118
+ except ImportError:
119
+ # Fallback if rich is not available
120
+ print(f"--- {service_name.upper()} v{service_version} ---")
121
+ if extra_versions:
122
+ for name, ver in extra_versions.items():
123
+ print(f" {name.upper()}: v{ver}")
124
+ print("-------------------------------")
iwa/core/wallet.py CHANGED
@@ -92,7 +92,8 @@ class Wallet:
92
92
  return addr, t_name, 0.0
93
93
 
94
94
  # Use ThreadPoolExecutor for parallel balance fetching
95
- with ThreadPoolExecutor(max_workers=20) as executor:
95
+ # Limited to 4 workers to avoid overwhelming RPC endpoints
96
+ with ThreadPoolExecutor(max_workers=4) as executor:
96
97
  tasks = []
97
98
  for addr in accounts_data.keys():
98
99
  for t_name in token_names: