iwa 0.0.32__py3-none-any.whl → 0.0.58__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.
Files changed (65) hide show
  1. iwa/core/chain/interface.py +116 -8
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +54 -12
  4. iwa/core/cli.py +1 -1
  5. iwa/core/ipfs.py +24 -2
  6. iwa/core/keys.py +59 -15
  7. iwa/core/models.py +60 -13
  8. iwa/core/pricing.py +24 -2
  9. iwa/core/secrets.py +27 -0
  10. iwa/core/services/account.py +1 -1
  11. iwa/core/services/balance.py +0 -22
  12. iwa/core/services/safe.py +64 -43
  13. iwa/core/services/safe_executor.py +316 -0
  14. iwa/core/services/transaction.py +11 -1
  15. iwa/core/services/transfer/erc20.py +14 -2
  16. iwa/core/services/transfer/native.py +14 -31
  17. iwa/core/services/transfer/swap.py +1 -0
  18. iwa/core/tests/test_gnosis_fee.py +87 -0
  19. iwa/core/tests/test_ipfs.py +85 -0
  20. iwa/core/tests/test_pricing.py +65 -0
  21. iwa/core/tests/test_regression_fixes.py +100 -0
  22. iwa/core/wallet.py +3 -3
  23. iwa/plugins/gnosis/cow/quotes.py +2 -2
  24. iwa/plugins/gnosis/cow/swap.py +18 -32
  25. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  26. iwa/plugins/olas/importer.py +5 -7
  27. iwa/plugins/olas/models.py +0 -3
  28. iwa/plugins/olas/service_manager/drain.py +16 -7
  29. iwa/plugins/olas/service_manager/lifecycle.py +15 -4
  30. iwa/plugins/olas/service_manager/staking.py +4 -4
  31. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  32. iwa/plugins/olas/tests/test_olas_archiving.py +73 -0
  33. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  34. iwa/plugins/olas/tests/test_service_manager.py +7 -7
  35. iwa/plugins/olas/tests/test_service_manager_errors.py +1 -1
  36. iwa/plugins/olas/tests/test_service_manager_flows.py +1 -1
  37. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  38. iwa/tools/drain_accounts.py +60 -0
  39. iwa/tools/list_contracts.py +2 -0
  40. iwa/tui/screens/wallets.py +2 -2
  41. iwa/web/routers/accounts.py +1 -1
  42. iwa/web/static/app.js +21 -9
  43. iwa/web/static/style.css +4 -0
  44. iwa/web/tests/test_web_endpoints.py +2 -2
  45. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/METADATA +6 -3
  46. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/RECORD +64 -54
  47. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/WHEEL +1 -1
  48. tests/test_balance_service.py +0 -41
  49. tests/test_chain.py +13 -4
  50. tests/test_cli.py +2 -2
  51. tests/test_drain_coverage.py +12 -6
  52. tests/test_keys.py +23 -23
  53. tests/test_rate_limiter.py +2 -2
  54. tests/test_rate_limiter_retry.py +108 -0
  55. tests/test_rpc_rate_limit.py +33 -0
  56. tests/test_rpc_rotation.py +55 -7
  57. tests/test_safe_coverage.py +37 -23
  58. tests/test_safe_executor.py +335 -0
  59. tests/test_safe_integration.py +148 -0
  60. tests/test_safe_service.py +1 -1
  61. tests/test_transfer_swap_unit.py +5 -1
  62. tests/test_pricing.py +0 -160
  63. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/entry_points.txt +0 -0
  64. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/licenses/LICENSE +0 -0
  65. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/top_level.txt +0 -0
iwa/core/pricing.py CHANGED
@@ -28,6 +28,27 @@ class PriceService:
28
28
  if self.secrets.coingecko_api_key
29
29
  else None
30
30
  )
31
+ self.session = requests.Session()
32
+ # Configure retry strategy
33
+ from requests.adapters import HTTPAdapter
34
+ from urllib3.util.retry import Retry
35
+
36
+ retry_strategy = Retry(
37
+ total=3,
38
+ backoff_factor=1,
39
+ status_forcelist=[429, 500, 502, 503, 504],
40
+ )
41
+ adapter = HTTPAdapter(max_retries=retry_strategy)
42
+ self.session.mount("https://", adapter)
43
+ self.session.mount("http://", adapter)
44
+
45
+ def close(self):
46
+ """Close the session."""
47
+ self.session.close()
48
+
49
+ def __del__(self):
50
+ """Cleanup on deletion."""
51
+ self.close()
31
52
 
32
53
  def get_token_price(self, token_id: str, vs_currency: str = "eur") -> Optional[float]:
33
54
  """Get token price in specified currency.
@@ -66,7 +87,8 @@ class PriceService:
66
87
  if self.api_key:
67
88
  headers["x-cg-demo-api-key"] = self.api_key
68
89
 
69
- response = requests.get(url, params=params, headers=headers, timeout=10)
90
+ # Use session instead of direct requests
91
+ response = self.session.get(url, params=params, headers=headers, timeout=10)
70
92
 
71
93
  if response.status_code == 401 and self.api_key:
72
94
  logger.warning("CoinGecko API key invalid (401). Retrying without key...")
@@ -74,7 +96,7 @@ class PriceService:
74
96
  headers.pop("x-cg-demo-api-key", None)
75
97
  # Re-run with base URL
76
98
  url = f"{self.BASE_URL}/simple/price"
77
- response = requests.get(url, params=params, headers=headers, timeout=10)
99
+ response = self.session.get(url, params=params, headers=headers, timeout=10)
78
100
 
79
101
  if response.status_code == 429:
80
102
  logger.warning(
iwa/core/secrets.py CHANGED
@@ -72,6 +72,33 @@ class Secrets(BaseSettings):
72
72
 
73
73
  return self
74
74
 
75
+ @model_validator(mode="after")
76
+ def strip_quotes_from_secrets(self) -> "Secrets":
77
+ """Strip leading/trailing quotes from SecretStr fields.
78
+
79
+ Docker env_file often preserves quotes (e.g. KEY="val" -> "val"),
80
+ which causes API authentication failures.
81
+ """
82
+ for field_name, field_value in self:
83
+ if isinstance(field_value, SecretStr):
84
+ raw_value = field_value.get_secret_value()
85
+ # Check for matching quotes at start and end
86
+ if len(raw_value) >= 2 and (
87
+ (raw_value.startswith('"') and raw_value.endswith('"'))
88
+ or (raw_value.startswith("'") and raw_value.endswith("'"))
89
+ ):
90
+ clean_value = raw_value[1:-1]
91
+ setattr(self, field_name, SecretStr(clean_value))
92
+ elif isinstance(field_value, str):
93
+ # Also strip quotes from plain string fields (like health_url)
94
+ if len(field_value) >= 2 and (
95
+ (field_value.startswith('"') and field_value.endswith('"'))
96
+ or (field_value.startswith("'") and field_value.endswith("'"))
97
+ ):
98
+ clean_value = field_value[1:-1]
99
+ setattr(self, field_name, clean_value)
100
+ return self
101
+
75
102
 
76
103
  # Global secrets instance
77
104
  secrets = Secrets()
@@ -20,7 +20,7 @@ class AccountService:
20
20
  self.key_storage = key_storage
21
21
 
22
22
  @property
23
- def master_account(self) -> Optional[StoredSafeAccount]:
23
+ def master_account(self) -> Optional[Union["EncryptedAccount", StoredSafeAccount]]:
24
24
  """Get master account."""
25
25
  return self.key_storage.master_account
26
26
 
@@ -1,9 +1,7 @@
1
1
  """Balance service module."""
2
2
 
3
- import time
4
3
  from typing import TYPE_CHECKING, Optional, Union
5
4
 
6
- from loguru import logger
7
5
  from web3.types import Wei
8
6
 
9
7
  from iwa.core.chain import ChainInterfaces
@@ -91,23 +89,3 @@ class BalanceService:
91
89
  contract = ERC20Contract(chain_name=chain_name, address=token_address)
92
90
  return contract.balance_of_wei(account.address)
93
91
 
94
- def get_erc20_balance_with_retry(
95
- self,
96
- account_address: str,
97
- token_address_or_name: str,
98
- chain_name: str = "gnosis",
99
- retries: int = 3,
100
- ) -> Optional[float]:
101
- """Fetch balance with retry logic."""
102
- for attempt in range(retries):
103
- try:
104
- return self.get_erc20_balance_eth(
105
- account_address, token_address_or_name, chain_name
106
- )
107
- except Exception as e:
108
- if attempt == retries - 1:
109
- logger.error(
110
- f"Failed to fetch balance for {token_address_or_name} after {retries} attempts: {e}"
111
- )
112
- time.sleep(1)
113
- return None
iwa/core/services/safe.py CHANGED
@@ -222,25 +222,25 @@ class SafeService:
222
222
  threshold: int,
223
223
  tag: Optional[str],
224
224
  ) -> StoredSafeAccount:
225
- # Check if already exists
226
- accounts = self.key_storage.accounts
227
- if contract_address in accounts and isinstance(
228
- accounts[contract_address], StoredSafeAccount
229
- ):
230
- safe_account = accounts[contract_address]
231
- if chain_name not in safe_account.chains:
232
- safe_account.chains.append(chain_name)
233
- else:
234
- safe_account = StoredSafeAccount(
235
- tag=tag or f"Safe {contract_address[:6]}",
236
- address=contract_address,
237
- chains=[chain_name],
238
- threshold=threshold,
239
- signers=owner_addresses,
240
- )
241
- accounts[contract_address] = safe_account
225
+ # Check if already exists (by address)
226
+ existing = self.key_storage.find_stored_account(contract_address)
227
+ if existing and isinstance(existing, StoredSafeAccount):
228
+ if chain_name not in existing.chains:
229
+ existing.chains.append(chain_name)
230
+ self.key_storage.save()
231
+ return existing
232
+
233
+ # Create new Safe account object
234
+ safe_account = StoredSafeAccount(
235
+ tag=tag or f"Safe {contract_address[:6]}",
236
+ address=contract_address,
237
+ chains=[chain_name],
238
+ threshold=threshold,
239
+ signers=owner_addresses,
240
+ )
242
241
 
243
- self.key_storage.save()
242
+ # Register via centralized method (enforces tag uniqueness)
243
+ self.key_storage.register_account(safe_account)
244
244
  return safe_account
245
245
 
246
246
  def redeploy_safes(self):
@@ -290,30 +290,51 @@ class SafeService:
290
290
 
291
291
  return signer_pkeys
292
292
 
293
- def _sign_and_execute_safe_tx(self, safe_tx: SafeTx, signer_keys: List[str]) -> str:
293
+ def _sign_and_execute_safe_tx(
294
+ self,
295
+ safe_tx: SafeTx,
296
+ signer_keys: List[str],
297
+ chain_name: str,
298
+ safe_address: str,
299
+ ) -> str:
294
300
  """Sign and execute a SafeTx internally (INTERNAL USE ONLY).
295
301
 
296
302
  This method handles the signing and execution of a Safe transaction,
297
303
  keeping private keys internal to SafeService.
298
304
 
305
+ Uses SafeTransactionExecutor for retry logic and gas handling.
306
+
299
307
  SECURITY: Keys are overwritten with zeros and cleared after use.
300
308
  """
309
+ from iwa.core.chain import ChainInterfaces
310
+ from iwa.core.services.safe_executor import SafeTransactionExecutor
311
+
301
312
  try:
302
- # Sign with all available signers
313
+ # Sign with all available signers (local operation)
303
314
  for pk in signer_keys:
304
- safe_tx.sign(pk)
315
+ if pk:
316
+ safe_tx.sign(pk)
317
+
318
+ chain_interface = ChainInterfaces().get(chain_name)
319
+ executor = SafeTransactionExecutor(chain_interface)
305
320
 
306
- # Verify the transaction will succeed
307
- safe_tx.call()
321
+ success, tx_hash_or_error, receipt = executor.execute_with_retry(
322
+ safe_address=safe_address,
323
+ safe_tx=safe_tx,
324
+ signer_keys=signer_keys,
325
+ operation_name=f"safe_tx_{safe_address[:10]}",
326
+ )
308
327
 
309
- # Execute using the first signer
310
- safe_tx.execute(signer_keys[0])
328
+ if success:
329
+ return tx_hash_or_error
330
+ else:
331
+ raise ValueError(f"Safe transaction failed: {tx_hash_or_error}")
311
332
 
312
- return f"0x{safe_tx.tx_hash.hex()}"
313
333
  finally:
314
334
  # SECURITY: Overwrite keys with zeros before clearing (best effort)
315
335
  for i in range(len(signer_keys)):
316
- signer_keys[i] = "0" * len(signer_keys[i]) if signer_keys[i] else ""
336
+ if signer_keys[i]:
337
+ signer_keys[i] = "0" * len(signer_keys[i])
317
338
  signer_keys.clear()
318
339
 
319
340
  def execute_safe_transaction(
@@ -358,17 +379,16 @@ class SafeService:
358
379
 
359
380
  # Get signer keys, execute, and immediately clear
360
381
  signer_keys = self._get_signer_keys(safe_account)
361
- try:
362
- tx_hash = self._sign_and_execute_safe_tx(safe_tx, signer_keys)
363
- logger.info(f"Safe transaction executed. Tx Hash: {tx_hash}")
364
- return tx_hash
365
- finally:
366
- # Clear keys from memory (best effort)
367
- for i in range(len(signer_keys)):
368
- signer_keys[i] = None
369
- del signer_keys
382
+ tx_hash = self._sign_and_execute_safe_tx(
383
+ safe_tx=safe_tx,
384
+ signer_keys=signer_keys,
385
+ chain_name=chain_name,
386
+ safe_address=safe_account.address,
387
+ )
388
+ logger.info(f"Safe transaction executed. Tx Hash: {tx_hash}")
389
+ return tx_hash
370
390
 
371
- def get_sign_and_execute_callback(self, safe_address_or_tag: str):
391
+ def get_sign_and_execute_callback(self, safe_address_or_tag: str, chain_name: str):
372
392
  """Get a callback function that signs and executes a SafeTx.
373
393
 
374
394
  This method returns a callback that can be passed to SafeMultisig.send_tx().
@@ -376,6 +396,7 @@ class SafeService:
376
396
 
377
397
  Args:
378
398
  safe_address_or_tag: The Safe account address or tag
399
+ chain_name: The chain name for context
379
400
 
380
401
  Returns:
381
402
  A callable that takes a SafeTx and returns the transaction hash
@@ -387,11 +408,11 @@ class SafeService:
387
408
 
388
409
  def _sign_and_execute(safe_tx: SafeTx) -> str:
389
410
  signer_keys = self._get_signer_keys(safe_account)
390
- try:
391
- return self._sign_and_execute_safe_tx(safe_tx, signer_keys)
392
- finally:
393
- for i in range(len(signer_keys)):
394
- signer_keys[i] = None
395
- del signer_keys
411
+ return self._sign_and_execute_safe_tx(
412
+ safe_tx=safe_tx,
413
+ signer_keys=signer_keys,
414
+ chain_name=chain_name,
415
+ safe_address=safe_account.address,
416
+ )
396
417
 
397
418
  return _sign_and_execute
@@ -0,0 +1,316 @@
1
+ """Safe transaction executor with retry logic and gas handling."""
2
+
3
+ import time
4
+ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
5
+
6
+ from loguru import logger
7
+ from safe_eth.eth import EthereumClient, TxSpeed
8
+ from safe_eth.safe import Safe
9
+ from safe_eth.safe.safe_tx import SafeTx
10
+
11
+ from iwa.core.contracts.decoder import ErrorDecoder
12
+ from iwa.core.models import Config
13
+
14
+ if TYPE_CHECKING:
15
+ from iwa.core.chain import ChainInterface
16
+
17
+
18
+ # Simple in-memory counters for debugging
19
+ SAFE_TX_STATS = {
20
+ "total_attempts": 0,
21
+ "gas_retries": 0,
22
+ "nonce_retries": 0,
23
+ "rpc_rotations": 0,
24
+ "final_successes": 0,
25
+ "final_failures": 0,
26
+ "signature_errors": 0,
27
+ }
28
+
29
+ # Minimum signature length (65 bytes per signature for ECDSA)
30
+ MIN_SIGNATURE_LENGTH = 65
31
+
32
+
33
+ class SafeTransactionExecutor:
34
+ """Execute Safe transactions with retry, gas estimation, and RPC rotation."""
35
+
36
+ DEFAULT_MAX_RETRIES = 6
37
+ DEFAULT_RETRY_DELAY = 1.0
38
+ GAS_BUFFER_PERCENTAGE = 1.5 # 50% buffer
39
+ MAX_GAS_MULTIPLIER = 10 # Hard cap: never exceed 10x original estimate
40
+ DEFAULT_FALLBACK_GAS = 500_000 # Fallback when estimation fails
41
+
42
+ def __init__(
43
+ self,
44
+ chain_interface: "ChainInterface",
45
+ max_retries: Optional[int] = None,
46
+ gas_buffer: Optional[float] = None,
47
+ ):
48
+ """Initialize the executor."""
49
+ self.chain_interface = chain_interface
50
+
51
+ # Use centralized config with fallbacks
52
+ config = Config().core
53
+ self.max_retries = max_retries or config.safe_tx_max_retries
54
+ self.gas_buffer = gas_buffer or config.safe_tx_gas_buffer
55
+
56
+ def execute_with_retry(
57
+ self,
58
+ safe_address: str,
59
+ safe_tx: SafeTx,
60
+ signer_keys: List[str],
61
+ operation_name: str = "safe_tx",
62
+ ) -> Tuple[bool, str, Optional[Dict]]:
63
+ """Execute SafeTx with full retry mechanism.
64
+
65
+ Args:
66
+ safe_address: The address of the Safe.
67
+ safe_tx: The Safe transaction object.
68
+ signer_keys: List of private keys for signing.
69
+ operation_name: Name for logging purposes.
70
+
71
+ Returns:
72
+ Tuple of (success, tx_hash_or_error, receipt)
73
+
74
+ """
75
+ last_error = None
76
+ current_gas = safe_tx.safe_tx_gas
77
+ base_estimate = current_gas if current_gas > 0 else 0
78
+
79
+ for attempt in range(self.max_retries + 1):
80
+ SAFE_TX_STATS["total_attempts"] += 1
81
+ try:
82
+ # Prepare and execute attempt
83
+ tx_hash = self._execute_attempt(
84
+ safe_address, safe_tx, signer_keys, operation_name, attempt, current_gas, base_estimate
85
+ )
86
+
87
+ # Check receipt
88
+ receipt = self.chain_interface.web3.eth.wait_for_transaction_receipt(tx_hash)
89
+ if self._check_receipt_status(receipt):
90
+ SAFE_TX_STATS["final_successes"] += 1
91
+ logger.info(f"[{operation_name}] Success on attempt {attempt + 1}. Tx Hash: {tx_hash}")
92
+ return True, tx_hash, receipt
93
+
94
+ logger.error(f"[{operation_name}] Mined but failed (status 0) on attempt {attempt + 1}.")
95
+ raise ValueError("Transaction reverted on-chain")
96
+
97
+ except Exception as e:
98
+ updated_tx, should_retry = self._handle_execution_failure(
99
+ e, safe_address, safe_tx, attempt, operation_name
100
+ )
101
+ last_error = e
102
+ if not should_retry:
103
+ break
104
+
105
+ # Update gas/nonce for next loop if needed
106
+ safe_tx = updated_tx
107
+ # If gas error, gas is recalculated in next _execute_attempt via fresh estimation
108
+
109
+ delay = self.DEFAULT_RETRY_DELAY * (2**attempt)
110
+ time.sleep(delay)
111
+
112
+ return False, str(last_error), None
113
+
114
+ def _execute_attempt(
115
+ self, safe_address, safe_tx, signer_keys, operation_name, attempt, current_gas, base_estimate
116
+ ) -> str:
117
+ """Prepare client, estimate gas, simulate, and execute."""
118
+ # 1. (Re)Create Safe client
119
+ self._recreate_safe_client(safe_address)
120
+
121
+ # NOTE: We do NOT modify safe_tx_gas here because the transaction is already signed.
122
+ # The Safe tx hash includes safe_tx_gas, so changing it would invalidate all signatures.
123
+ # Gas estimation must happen BEFORE signing in SafeService.
124
+
125
+ # 2. Validate signatures exist before any operation
126
+ sig_len = len(safe_tx.signatures) if safe_tx.signatures else 0
127
+ if sig_len < MIN_SIGNATURE_LENGTH:
128
+ SAFE_TX_STATS["signature_errors"] += 1
129
+ raise ValueError(
130
+ f"No valid signatures on transaction (have {sig_len} bytes, need >= {MIN_SIGNATURE_LENGTH})"
131
+ )
132
+
133
+ # 3. Simulate locally
134
+ try:
135
+ safe_tx.call()
136
+ except Exception as e:
137
+ classification = self._classify_error(e)
138
+ # Signature errors (GS020, GS026) are not recoverable - fail immediately
139
+ if classification["is_signature_error"]:
140
+ SAFE_TX_STATS["signature_errors"] += 1
141
+ reason = self._decode_revert_reason(e)
142
+ logger.error(f"[{operation_name}] Signature error (not retryable): {reason or e}")
143
+ raise e
144
+ if classification["is_revert"] and not classification["is_nonce_error"]:
145
+ reason = self._decode_revert_reason(e)
146
+ logger.error(f"[{operation_name}] Simulation reverted: {reason or e}")
147
+ raise e
148
+ raise
149
+
150
+ # 4. Execute
151
+ # IMPORTANT: safe-eth-py's execute() method CLEARS signatures after execution.
152
+ # We must backup and restore them to support retries if something goes wrong (e.g. timeout after broadcast).
153
+ signatures_backup = safe_tx.signatures
154
+
155
+ try:
156
+ # Always pass the first signer key as the executor (gas payer).
157
+ # Note: This method does NOT re-sign the Safe hash if signatures are already present.
158
+ # Use EIP-1559 'FAST' to ensure adequate priority fee (fixes Gnosis FeeTooLow)
159
+ result = safe_tx.execute(signer_keys[0], eip1559_speed=TxSpeed.FAST)
160
+
161
+ # Handle both tuple return (tx_hash, tx) and bytes return
162
+ if isinstance(result, tuple):
163
+ tx_hash_bytes = result[0]
164
+ else:
165
+ tx_hash_bytes = result
166
+
167
+ # Handle both bytes and hex string returns
168
+ if isinstance(tx_hash_bytes, bytes):
169
+ return f"0x{tx_hash_bytes.hex()}"
170
+ elif isinstance(tx_hash_bytes, str):
171
+ return tx_hash_bytes if tx_hash_bytes.startswith("0x") else f"0x{tx_hash_bytes}"
172
+ else:
173
+ return str(tx_hash_bytes)
174
+
175
+ finally:
176
+ # Restore signatures for next attempt if needed
177
+ # (execute() clears them on lines 407-409 of safe_eth/safe/safe_tx.py)
178
+ if safe_tx.signatures != signatures_backup:
179
+ safe_tx.signatures = signatures_backup
180
+
181
+ def _check_receipt_status(self, receipt) -> bool:
182
+ """Check if receipt has successful status."""
183
+ status = getattr(receipt, "status", None)
184
+ if status is None and isinstance(receipt, dict):
185
+ status = receipt.get("status")
186
+ return status == 1
187
+
188
+ def _handle_execution_failure(
189
+ self, error: Exception, safe_address: str, safe_tx: SafeTx, attempt: int, operation_name: str
190
+ ) -> Tuple[SafeTx, bool]:
191
+ """Handle execution failure and determine next steps."""
192
+ classification = self._classify_error(error)
193
+
194
+ if attempt >= self.max_retries:
195
+ SAFE_TX_STATS["final_failures"] += 1
196
+ logger.error(f"[{operation_name}] Failed after {attempt + 1} attempts: {error}")
197
+ return safe_tx, False
198
+
199
+ strategy = "retry"
200
+ safe = self._recreate_safe_client(safe_address)
201
+
202
+ if classification["is_nonce_error"]:
203
+ strategy = "nonce refresh"
204
+ SAFE_TX_STATS["nonce_retries"] += 1
205
+ safe_tx = self._refresh_nonce(safe, safe_tx)
206
+ elif classification["is_rpc_error"]:
207
+ strategy = "RPC rotation"
208
+ SAFE_TX_STATS["rpc_rotations"] += 1
209
+ result = self.chain_interface._handle_rpc_error(error)
210
+ if not result["should_retry"]:
211
+ return safe_tx, False
212
+ elif classification["is_gas_error"]:
213
+ strategy = "gas increase"
214
+ # Gas increase handled in next attempt loop
215
+
216
+ self._log_retry(attempt + 1, error, strategy)
217
+ return safe_tx, True
218
+
219
+ def _estimate_safe_tx_gas(self, safe: Safe, safe_tx: SafeTx, base_estimate: int = 0) -> int:
220
+ """Estimate gas for a Safe transaction with buffer and hard cap."""
221
+ try:
222
+ # Use on-chain simulation via safe-eth-py
223
+ estimated = safe.estimate_tx_gas(safe_tx.to, safe_tx.value, safe_tx.data, safe_tx.operation)
224
+ with_buffer = int(estimated * self.gas_buffer)
225
+
226
+ # Apply x10 hard cap if we have a base estimate
227
+ if base_estimate > 0:
228
+ max_allowed = base_estimate * self.MAX_GAS_MULTIPLIER
229
+ if with_buffer > max_allowed:
230
+ logger.warning(f"Gas {with_buffer} exceeds x10 cap, capping to {max_allowed}")
231
+ return max_allowed
232
+
233
+ return with_buffer
234
+ except Exception as e:
235
+ logger.warning(f"Gas estimation failed, using fallback: {e}")
236
+ return self.DEFAULT_FALLBACK_GAS
237
+
238
+ def _recreate_safe_client(self, safe_address: str) -> Safe:
239
+ """Recreate Safe with current (possibly rotated) RPC."""
240
+ ethereum_client = EthereumClient(self.chain_interface.current_rpc)
241
+ return Safe(safe_address, ethereum_client)
242
+
243
+ def _is_nonce_error(self, error: Exception) -> bool:
244
+ """Check if error is due to Safe nonce conflict."""
245
+ error_text = str(error).lower()
246
+ # GS025 = Invalid nonce (NOT GS026 which is invalid signatures)
247
+ return any(x in error_text for x in [
248
+ "nonce", "gs025", "already executed", "duplicate"
249
+ ])
250
+
251
+ def _is_signature_error(self, error: Exception) -> bool:
252
+ """Check if error is due to invalid Safe signatures.
253
+
254
+ GS020 = Signatures data too short
255
+ GS021 = Invalid signature data pointer
256
+ GS024 = Invalid contract signature
257
+ GS026 = Invalid owner (signature from non-owner)
258
+ """
259
+ error_text = str(error).lower()
260
+ return any(x in error_text for x in [
261
+ "gs020", "gs021", "gs024", "gs026",
262
+ "invalid signatures", "signatures data too short"
263
+ ])
264
+
265
+ def _refresh_nonce(self, safe: Safe, safe_tx: SafeTx) -> SafeTx:
266
+ """Re-fetch nonce and rebuild transaction."""
267
+ current_nonce = safe.retrieve_nonce()
268
+ logger.info(f"Refreshing Safe nonce to {current_nonce}")
269
+ return safe.build_multisig_tx(
270
+ safe_tx.to,
271
+ safe_tx.value,
272
+ safe_tx.data,
273
+ safe_tx.operation,
274
+ safe_tx_gas=safe_tx.safe_tx_gas,
275
+ base_gas=safe_tx.base_gas,
276
+ gas_price=safe_tx.gas_price,
277
+ gas_token=safe_tx.gas_token,
278
+ refund_receiver=safe_tx.refund_receiver,
279
+ # Note: signatures are NOT copied - tx hash changes with new nonce
280
+ safe_nonce=current_nonce,
281
+ )
282
+
283
+ def _classify_error(self, error: Exception) -> dict:
284
+ """Classify Safe transaction errors for retry decisions."""
285
+ err_text = str(error).lower()
286
+ is_rpc = self.chain_interface._is_rate_limit_error(error) or self.chain_interface._is_connection_error(error)
287
+
288
+ return {
289
+ "is_gas_error": any(x in err_text for x in ["gas", "out of gas", "intrinsic"]),
290
+ "is_nonce_error": self._is_nonce_error(error),
291
+ "is_rpc_error": is_rpc,
292
+ "is_revert": "revert" in err_text or "execution reverted" in err_text,
293
+ "is_signature_error": self._is_signature_error(error),
294
+ }
295
+
296
+ def _decode_revert_reason(self, error: Exception) -> Optional[str]:
297
+ """Attempt to decode the revert reason."""
298
+ import re
299
+ error_text = str(error)
300
+ hex_match = re.search(r"0x[0-9a-fA-F]{8,}", error_text)
301
+ if hex_match:
302
+ try:
303
+ data = hex_match.group(0)
304
+ decoded = ErrorDecoder().decode(data)
305
+ if decoded:
306
+ name, msg, source = decoded[0]
307
+ return f"{msg} (from {source})"
308
+ except Exception:
309
+ pass
310
+ return None
311
+
312
+ def _log_retry(self, attempt: int, error: Exception, strategy: str):
313
+ """Log a retry attempt."""
314
+ logger.warning(
315
+ f"Safe TX attempt {attempt} failed, strategy: {strategy}. Error: {error}"
316
+ )
@@ -320,6 +320,15 @@ class TransactionService:
320
320
 
321
321
  if "chainId" not in tx:
322
322
  tx["chainId"] = chain_interface.chain.chain_id
323
+
324
+ # Safety net: Ensure fees are set if missing (prevents FeeTooLow on Gnosis)
325
+ if "gasPrice" not in tx and "maxFeePerGas" not in tx:
326
+ try:
327
+ fees = chain_interface.get_suggested_fees()
328
+ tx.update(fees)
329
+ except Exception as e:
330
+ logger.debug(f"Failed to auto-fill fees in _prepare_transaction: {e}")
331
+
323
332
  return True
324
333
 
325
334
  def _handle_gas_retry(self, e: Exception, tx: dict, state: dict) -> None:
@@ -425,7 +434,8 @@ class TransactionService:
425
434
  data=data
426
435
  )
427
436
 
428
- # Wait for receipt
437
+ # Receipt is already waited for inside execute_safe_transaction/executor
438
+ # but we can fetch it again here to be safe and continue with Olas logging
429
439
  receipt = chain_interface.web3.eth.wait_for_transaction_receipt(tx_hash)
430
440
 
431
441
  status = getattr(receipt, "status", None)
@@ -57,11 +57,23 @@ class ERC20TransferMixin:
57
57
  chain_name=chain_name,
58
58
  data=transaction["data"],
59
59
  )
60
- # Get receipt for gas calculation
60
+ # Get receipt for gas calculation with retry
61
61
  receipt = None
62
62
  try:
63
63
  interface = ChainInterfaces().get(chain_name)
64
- receipt = interface.web3.eth.get_transaction_receipt(tx_hash)
64
+ import time
65
+
66
+ for _ in range(5):
67
+ try:
68
+ receipt = interface.web3.eth.get_transaction_receipt(tx_hash)
69
+ if receipt:
70
+ break
71
+ except Exception:
72
+ pass
73
+ time.sleep(2)
74
+
75
+ if not receipt:
76
+ logger.warning(f"Could not get receipt for Safe tx {tx_hash} after retries")
65
77
  except Exception as e:
66
78
  logger.warning(f"Could not get receipt for Safe tx {tx_hash}: {e}")
67
79