iwa 0.0.33__py3-none-any.whl → 0.0.59__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 (83) hide show
  1. iwa/core/chain/interface.py +130 -11
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +48 -12
  4. iwa/core/chainlist.py +15 -10
  5. iwa/core/cli.py +4 -1
  6. iwa/core/contracts/cache.py +1 -1
  7. iwa/core/contracts/contract.py +1 -0
  8. iwa/core/contracts/decoder.py +10 -4
  9. iwa/core/http.py +31 -0
  10. iwa/core/ipfs.py +21 -7
  11. iwa/core/keys.py +65 -15
  12. iwa/core/models.py +58 -13
  13. iwa/core/pricing.py +10 -6
  14. iwa/core/rpc_monitor.py +1 -0
  15. iwa/core/secrets.py +27 -0
  16. iwa/core/services/account.py +1 -1
  17. iwa/core/services/balance.py +0 -23
  18. iwa/core/services/safe.py +72 -45
  19. iwa/core/services/safe_executor.py +350 -0
  20. iwa/core/services/transaction.py +43 -13
  21. iwa/core/services/transfer/erc20.py +14 -3
  22. iwa/core/services/transfer/native.py +14 -31
  23. iwa/core/services/transfer/swap.py +1 -0
  24. iwa/core/tests/test_gnosis_fee.py +91 -0
  25. iwa/core/tests/test_ipfs.py +85 -0
  26. iwa/core/tests/test_pricing.py +65 -0
  27. iwa/core/tests/test_regression_fixes.py +97 -0
  28. iwa/core/utils.py +2 -0
  29. iwa/core/wallet.py +6 -4
  30. iwa/plugins/gnosis/cow/quotes.py +2 -2
  31. iwa/plugins/gnosis/cow/swap.py +18 -32
  32. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  33. iwa/plugins/olas/constants.py +15 -5
  34. iwa/plugins/olas/contracts/activity_checker.py +3 -3
  35. iwa/plugins/olas/contracts/staking.py +0 -1
  36. iwa/plugins/olas/events.py +15 -13
  37. iwa/plugins/olas/importer.py +29 -25
  38. iwa/plugins/olas/models.py +0 -3
  39. iwa/plugins/olas/plugin.py +16 -14
  40. iwa/plugins/olas/service_manager/drain.py +16 -9
  41. iwa/plugins/olas/service_manager/lifecycle.py +23 -12
  42. iwa/plugins/olas/service_manager/staking.py +15 -10
  43. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  44. iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
  45. iwa/plugins/olas/tests/test_olas_integration.py +49 -29
  46. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  47. iwa/plugins/olas/tests/test_service_manager.py +15 -17
  48. iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
  49. iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
  50. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  51. iwa/plugins/olas/tests/test_service_staking.py +64 -38
  52. iwa/tools/drain_accounts.py +61 -0
  53. iwa/tools/list_contracts.py +2 -0
  54. iwa/tools/reset_env.py +2 -1
  55. iwa/tools/test_chainlist.py +5 -1
  56. iwa/tui/screens/wallets.py +2 -4
  57. iwa/web/routers/accounts.py +1 -1
  58. iwa/web/routers/olas/services.py +10 -5
  59. iwa/web/static/app.js +21 -9
  60. iwa/web/static/style.css +4 -0
  61. iwa/web/tests/test_web_endpoints.py +2 -2
  62. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/METADATA +6 -3
  63. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
  64. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
  65. tests/test_balance_service.py +0 -43
  66. tests/test_chain.py +13 -5
  67. tests/test_cli.py +2 -2
  68. tests/test_drain_coverage.py +12 -6
  69. tests/test_keys.py +23 -23
  70. tests/test_rate_limiter.py +2 -2
  71. tests/test_rate_limiter_retry.py +103 -0
  72. tests/test_rpc_efficiency.py +4 -1
  73. tests/test_rpc_rate_limit.py +34 -0
  74. tests/test_rpc_rotation.py +59 -11
  75. tests/test_safe_coverage.py +37 -23
  76. tests/test_safe_executor.py +361 -0
  77. tests/test_safe_integration.py +153 -0
  78. tests/test_safe_service.py +1 -1
  79. tests/test_transfer_swap_unit.py +5 -1
  80. tests/test_pricing.py +0 -160
  81. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
  82. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
  83. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,350 @@
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
+ self._client_cache: Dict[str, EthereumClient] = {}
56
+
57
+ def execute_with_retry(
58
+ self,
59
+ safe_address: str,
60
+ safe_tx: SafeTx,
61
+ signer_keys: List[str],
62
+ operation_name: str = "safe_tx",
63
+ ) -> Tuple[bool, str, Optional[Dict]]:
64
+ """Execute SafeTx with full retry mechanism.
65
+
66
+ Args:
67
+ safe_address: The address of the Safe.
68
+ safe_tx: The Safe transaction object.
69
+ signer_keys: List of private keys for signing.
70
+ operation_name: Name for logging purposes.
71
+
72
+ Returns:
73
+ Tuple of (success, tx_hash_or_error, receipt)
74
+
75
+ """
76
+ last_error = None
77
+ current_gas = safe_tx.safe_tx_gas
78
+ base_estimate = current_gas if current_gas > 0 else 0
79
+
80
+ for attempt in range(self.max_retries + 1):
81
+ SAFE_TX_STATS["total_attempts"] += 1
82
+ try:
83
+ # Prepare and execute attempt
84
+ tx_hash = self._execute_attempt(
85
+ safe_address,
86
+ safe_tx,
87
+ signer_keys,
88
+ operation_name,
89
+ attempt,
90
+ current_gas,
91
+ base_estimate,
92
+ )
93
+
94
+ # Check receipt
95
+ receipt = self.chain_interface.web3.eth.wait_for_transaction_receipt(tx_hash)
96
+ if self._check_receipt_status(receipt):
97
+ SAFE_TX_STATS["final_successes"] += 1
98
+ logger.info(
99
+ f"[{operation_name}] Success on attempt {attempt + 1}. Tx Hash: {tx_hash}"
100
+ )
101
+ return True, tx_hash, receipt
102
+
103
+ logger.error(
104
+ f"[{operation_name}] Mined but failed (status 0) on attempt {attempt + 1}."
105
+ )
106
+ raise ValueError("Transaction reverted on-chain")
107
+
108
+ except Exception as e:
109
+ updated_tx, should_retry = self._handle_execution_failure(
110
+ e, safe_address, safe_tx, attempt, operation_name
111
+ )
112
+ last_error = e
113
+ if not should_retry:
114
+ break
115
+
116
+ # Update gas/nonce for next loop if needed
117
+ safe_tx = updated_tx
118
+ # If gas error, gas is recalculated in next _execute_attempt via fresh estimation
119
+
120
+ delay = self.DEFAULT_RETRY_DELAY * (2**attempt)
121
+ time.sleep(delay)
122
+
123
+ return False, str(last_error), None
124
+
125
+ def _execute_attempt(
126
+ self,
127
+ safe_address,
128
+ safe_tx,
129
+ signer_keys,
130
+ operation_name,
131
+ attempt,
132
+ current_gas,
133
+ base_estimate,
134
+ ) -> str:
135
+ """Prepare client, estimate gas, simulate, and execute."""
136
+ # 1. (Re)Create Safe client
137
+ self._recreate_safe_client(safe_address)
138
+
139
+ # NOTE: We do NOT modify safe_tx_gas here because the transaction is already signed.
140
+ # The Safe tx hash includes safe_tx_gas, so changing it would invalidate all signatures.
141
+ # Gas estimation must happen BEFORE signing in SafeService.
142
+
143
+ # 2. Validate signatures exist before any operation
144
+ sig_len = len(safe_tx.signatures) if safe_tx.signatures else 0
145
+ if sig_len < MIN_SIGNATURE_LENGTH:
146
+ SAFE_TX_STATS["signature_errors"] += 1
147
+ raise ValueError(
148
+ f"No valid signatures on transaction (have {sig_len} bytes, need >= {MIN_SIGNATURE_LENGTH})"
149
+ )
150
+
151
+ # 3. Simulate locally
152
+ try:
153
+ safe_tx.call()
154
+ except Exception as e:
155
+ classification = self._classify_error(e)
156
+ # Signature errors (GS020, GS026) are not recoverable - fail immediately
157
+ if classification["is_signature_error"]:
158
+ SAFE_TX_STATS["signature_errors"] += 1
159
+ reason = self._decode_revert_reason(e)
160
+ logger.error(f"[{operation_name}] Signature error (not retryable): {reason or e}")
161
+ raise e
162
+ if classification["is_revert"] and not classification["is_nonce_error"]:
163
+ reason = self._decode_revert_reason(e)
164
+ logger.error(f"[{operation_name}] Simulation reverted: {reason or e}")
165
+ raise e
166
+ raise
167
+
168
+ # 4. Execute
169
+ # IMPORTANT: safe-eth-py's execute() method CLEARS signatures after execution.
170
+ # We must backup and restore them to support retries if something goes wrong (e.g. timeout after broadcast).
171
+ signatures_backup = safe_tx.signatures
172
+
173
+ try:
174
+ # Always pass the first signer key as the executor (gas payer).
175
+ # Note: This method does NOT re-sign the Safe hash if signatures are already present.
176
+ # Use EIP-1559 'FAST' to ensure adequate priority fee (fixes Gnosis FeeTooLow)
177
+ result = safe_tx.execute(signer_keys[0], eip1559_speed=TxSpeed.FAST)
178
+
179
+ # Handle both tuple return (tx_hash, tx) and bytes return
180
+ if isinstance(result, tuple):
181
+ tx_hash_bytes = result[0]
182
+ else:
183
+ tx_hash_bytes = result
184
+
185
+ # Handle both bytes and hex string returns
186
+ if isinstance(tx_hash_bytes, bytes):
187
+ return f"0x{tx_hash_bytes.hex()}"
188
+ elif isinstance(tx_hash_bytes, str):
189
+ return tx_hash_bytes if tx_hash_bytes.startswith("0x") else f"0x{tx_hash_bytes}"
190
+ else:
191
+ return str(tx_hash_bytes)
192
+
193
+ finally:
194
+ # Restore signatures for next attempt if needed
195
+ # (execute() clears them on lines 407-409 of safe_eth/safe/safe_tx.py)
196
+ if safe_tx.signatures != signatures_backup:
197
+ safe_tx.signatures = signatures_backup
198
+
199
+ def _check_receipt_status(self, receipt) -> bool:
200
+ """Check if receipt has successful status."""
201
+ status = getattr(receipt, "status", None)
202
+ if status is None and isinstance(receipt, dict):
203
+ status = receipt.get("status")
204
+ return status == 1
205
+
206
+ def _handle_execution_failure(
207
+ self,
208
+ error: Exception,
209
+ safe_address: str,
210
+ safe_tx: SafeTx,
211
+ attempt: int,
212
+ operation_name: str,
213
+ ) -> Tuple[SafeTx, bool]:
214
+ """Handle execution failure and determine next steps."""
215
+ classification = self._classify_error(error)
216
+
217
+ if attempt >= self.max_retries:
218
+ SAFE_TX_STATS["final_failures"] += 1
219
+ logger.error(f"[{operation_name}] Failed after {attempt + 1} attempts: {error}")
220
+ return safe_tx, False
221
+
222
+ strategy = "retry"
223
+ safe = self._recreate_safe_client(safe_address)
224
+
225
+ if classification["is_nonce_error"]:
226
+ strategy = "nonce refresh"
227
+ SAFE_TX_STATS["nonce_retries"] += 1
228
+ safe_tx = self._refresh_nonce(safe, safe_tx)
229
+ elif classification["is_rpc_error"]:
230
+ strategy = "RPC rotation"
231
+ SAFE_TX_STATS["rpc_rotations"] += 1
232
+ result = self.chain_interface._handle_rpc_error(error)
233
+ if not result["should_retry"]:
234
+ return safe_tx, False
235
+ elif classification["is_gas_error"]:
236
+ strategy = "gas increase"
237
+ # Gas increase handled in next attempt loop
238
+
239
+ self._log_retry(attempt + 1, error, strategy)
240
+ return safe_tx, True
241
+
242
+ def _estimate_safe_tx_gas(self, safe: Safe, safe_tx: SafeTx, base_estimate: int = 0) -> int:
243
+ """Estimate gas for a Safe transaction with buffer and hard cap."""
244
+ try:
245
+ # Use on-chain simulation via safe-eth-py
246
+ estimated = safe.estimate_tx_gas(
247
+ safe_tx.to, safe_tx.value, safe_tx.data, safe_tx.operation
248
+ )
249
+ with_buffer = int(estimated * self.gas_buffer)
250
+
251
+ # Apply x10 hard cap if we have a base estimate
252
+ if base_estimate > 0:
253
+ max_allowed = base_estimate * self.MAX_GAS_MULTIPLIER
254
+ if with_buffer > max_allowed:
255
+ logger.warning(f"Gas {with_buffer} exceeds x10 cap, capping to {max_allowed}")
256
+ return max_allowed
257
+
258
+ return with_buffer
259
+ except Exception as e:
260
+ logger.warning(f"Gas estimation failed, using fallback: {e}")
261
+ return self.DEFAULT_FALLBACK_GAS
262
+
263
+ def _recreate_safe_client(self, safe_address: str) -> Safe:
264
+ """Recreate Safe with current (possibly rotated) RPC."""
265
+ rpc_url = self.chain_interface.current_rpc
266
+ if rpc_url not in self._client_cache:
267
+ self._client_cache[rpc_url] = EthereumClient(rpc_url)
268
+ ethereum_client = self._client_cache[rpc_url]
269
+ return Safe(safe_address, ethereum_client)
270
+
271
+ def _is_nonce_error(self, error: Exception) -> bool:
272
+ """Check if error is due to Safe nonce conflict."""
273
+ error_text = str(error).lower()
274
+ # GS025 = Invalid nonce (NOT GS026 which is invalid signatures)
275
+ return any(x in error_text for x in ["nonce", "gs025", "already executed", "duplicate"])
276
+
277
+ def _is_signature_error(self, error: Exception) -> bool:
278
+ """Check if error is due to invalid Safe signatures.
279
+
280
+ GS020 = Signatures data too short
281
+ GS021 = Invalid signature data pointer
282
+ GS024 = Invalid contract signature
283
+ GS026 = Invalid owner (signature from non-owner)
284
+ """
285
+ error_text = str(error).lower()
286
+ return any(
287
+ x in error_text
288
+ for x in [
289
+ "gs020",
290
+ "gs021",
291
+ "gs024",
292
+ "gs026",
293
+ "invalid signatures",
294
+ "signatures data too short",
295
+ ]
296
+ )
297
+
298
+ def _refresh_nonce(self, safe: Safe, safe_tx: SafeTx) -> SafeTx:
299
+ """Re-fetch nonce and rebuild transaction."""
300
+ current_nonce = safe.retrieve_nonce()
301
+ logger.info(f"Refreshing Safe nonce to {current_nonce}")
302
+ return safe.build_multisig_tx(
303
+ safe_tx.to,
304
+ safe_tx.value,
305
+ safe_tx.data,
306
+ safe_tx.operation,
307
+ safe_tx_gas=safe_tx.safe_tx_gas,
308
+ base_gas=safe_tx.base_gas,
309
+ gas_price=safe_tx.gas_price,
310
+ gas_token=safe_tx.gas_token,
311
+ refund_receiver=safe_tx.refund_receiver,
312
+ # Note: signatures are NOT copied - tx hash changes with new nonce
313
+ safe_nonce=current_nonce,
314
+ )
315
+
316
+ def _classify_error(self, error: Exception) -> dict:
317
+ """Classify Safe transaction errors for retry decisions."""
318
+ err_text = str(error).lower()
319
+ is_rpc = self.chain_interface._is_rate_limit_error(
320
+ error
321
+ ) or self.chain_interface._is_connection_error(error)
322
+
323
+ return {
324
+ "is_gas_error": any(x in err_text for x in ["gas", "out of gas", "intrinsic"]),
325
+ "is_nonce_error": self._is_nonce_error(error),
326
+ "is_rpc_error": is_rpc,
327
+ "is_revert": "revert" in err_text or "execution reverted" in err_text,
328
+ "is_signature_error": self._is_signature_error(error),
329
+ }
330
+
331
+ def _decode_revert_reason(self, error: Exception) -> Optional[str]:
332
+ """Attempt to decode the revert reason."""
333
+ import re
334
+
335
+ error_text = str(error)
336
+ hex_match = re.search(r"0x[0-9a-fA-F]{8,}", error_text)
337
+ if hex_match:
338
+ try:
339
+ data = hex_match.group(0)
340
+ decoded = ErrorDecoder().decode(data)
341
+ if decoded:
342
+ name, msg, source = decoded[0]
343
+ return f"{msg} (from {source})"
344
+ except Exception:
345
+ pass
346
+ return None
347
+
348
+ def _log_retry(self, attempt: int, error: Exception, strategy: str):
349
+ """Log a retry attempt."""
350
+ logger.warning(f"Safe TX attempt {attempt} failed, strategy: {strategy}. Error: {error}")
@@ -45,19 +45,29 @@ class TransferLogger:
45
45
  if tx_hash:
46
46
  try:
47
47
  tx = self.chain_interface.web3.eth.get_transaction(tx_hash)
48
- native_value = getattr(tx, "value", 0) or tx.get("value", 0) if isinstance(tx, dict) else getattr(tx, "value", 0)
48
+ native_value = (
49
+ getattr(tx, "value", 0) or tx.get("value", 0)
50
+ if isinstance(tx, dict)
51
+ else getattr(tx, "value", 0)
52
+ )
49
53
  if native_value and int(native_value) > 0:
50
- from_addr = getattr(tx, "from", "") if hasattr(tx, "from") else tx.get("from", "")
54
+ from_addr = (
55
+ getattr(tx, "from", "") if hasattr(tx, "from") else tx.get("from", "")
56
+ )
51
57
  # Handle AttributeDict's special 'from' attribute
52
58
  if not from_addr and hasattr(tx, "__getitem__"):
53
59
  from_addr = tx["from"]
54
- to_addr = getattr(tx, "to", "") or (tx.get("to", "") if isinstance(tx, dict) else "")
60
+ to_addr = getattr(tx, "to", "") or (
61
+ tx.get("to", "") if isinstance(tx, dict) else ""
62
+ )
55
63
  self._log_native_transfer(from_addr, to_addr, native_value)
56
64
  except Exception as e:
57
65
  logger.debug(f"Could not get tx for native transfer logging: {e}")
58
66
 
59
67
  # Log ERC20 transfers from event logs
60
- logs = receipt.get("logs", []) if isinstance(receipt, dict) else getattr(receipt, "logs", [])
68
+ logs = (
69
+ receipt.get("logs", []) if isinstance(receipt, dict) else getattr(receipt, "logs", [])
70
+ )
61
71
 
62
72
  for log in logs:
63
73
  self._process_log(log)
@@ -156,7 +166,9 @@ class TransferLogger:
156
166
  else:
157
167
  # Likely an NFT (ERC721) - the amount is the token ID
158
168
  if amount_wei > 0:
159
- logger.info(f"[NFT TRANSFER] Token #{amount_wei} {token_label}: {from_label} → {to_label}")
169
+ logger.info(
170
+ f"[NFT TRANSFER] Token #{amount_wei} {token_label}: {from_label} → {to_label}"
171
+ )
160
172
  else:
161
173
  logger.debug(f"[NFT TRANSFER] {token_label}: {from_label} → {to_label}")
162
174
 
@@ -265,9 +277,7 @@ class TransactionService:
265
277
  """Inner operation wrapped by with_retry."""
266
278
  try:
267
279
  signed_txn = self.key_storage.sign_transaction(tx, signer_address_or_tag)
268
- txn_hash = chain_interface.web3.eth.send_raw_transaction(
269
- signed_txn.raw_transaction
270
- )
280
+ txn_hash = chain_interface.web3.eth.send_raw_transaction(signed_txn.raw_transaction)
271
281
  receipt = chain_interface.web3.eth.wait_for_transaction_receipt(txn_hash)
272
282
 
273
283
  status = getattr(receipt, "status", None)
@@ -320,6 +330,15 @@ class TransactionService:
320
330
 
321
331
  if "chainId" not in tx:
322
332
  tx["chainId"] = chain_interface.chain.chain_id
333
+
334
+ # Safety net: Ensure fees are set if missing (prevents FeeTooLow on Gnosis)
335
+ if "gasPrice" not in tx and "maxFeePerGas" not in tx:
336
+ try:
337
+ fees = chain_interface.get_suggested_fees()
338
+ tx.update(fees)
339
+ except Exception as e:
340
+ logger.debug(f"Failed to auto-fill fees in _prepare_transaction: {e}")
341
+
323
342
  return True
324
343
 
325
344
  def _handle_gas_retry(self, e: Exception, tx: dict, state: dict) -> None:
@@ -403,10 +422,12 @@ class TransactionService:
403
422
  signer_account: StoredSafeAccount,
404
423
  chain_interface,
405
424
  chain_name: str,
406
- tags: List[str] = None
425
+ tags: List[str] = None,
407
426
  ) -> Tuple[bool, Dict]:
408
427
  """Execute transaction via SafeService."""
409
- logger.info(f"Routing transaction via Safe {self._resolve_label(signer_account.address, chain_name)}...")
428
+ logger.info(
429
+ f"Routing transaction via Safe {self._resolve_label(signer_account.address, chain_name)}..."
430
+ )
410
431
 
411
432
  try:
412
433
  # Extract basic params
@@ -422,10 +443,11 @@ class TransactionService:
422
443
  to=to_addr,
423
444
  value=value,
424
445
  chain_name=chain_name,
425
- data=data
446
+ data=data,
426
447
  )
427
448
 
428
- # Wait for receipt
449
+ # Receipt is already waited for inside execute_safe_transaction/executor
450
+ # but we can fetch it again here to be safe and continue with Olas logging
429
451
  receipt = chain_interface.web3.eth.wait_for_transaction_receipt(tx_hash)
430
452
 
431
453
  status = getattr(receipt, "status", None)
@@ -435,7 +457,13 @@ class TransactionService:
435
457
  if receipt and status == 1:
436
458
  logger.info(f"Safe transaction executed successfully. Tx Hash: {tx_hash}")
437
459
  self._log_successful_transaction(
438
- receipt, tx, signer_account, chain_name, bytes.fromhex(tx_hash.replace("0x", "")), tags, chain_interface
460
+ receipt,
461
+ tx,
462
+ signer_account,
463
+ chain_name,
464
+ bytes.fromhex(tx_hash.replace("0x", "")),
465
+ tags,
466
+ chain_interface,
439
467
  )
440
468
  return True, receipt
441
469
  else:
@@ -450,11 +478,13 @@ class TransactionService:
450
478
  # Extract hex data from common error patterns
451
479
  # Pattern 1: ('execution reverted', '0x...')
452
480
  import re
481
+
453
482
  hex_match = re.search(r"0x[0-9a-fA-F]{8,}", error_text)
454
483
 
455
484
  if hex_match:
456
485
  try:
457
486
  from iwa.core.contracts.decoder import ErrorDecoder
487
+
458
488
  data = hex_match.group(0)
459
489
  decoded = ErrorDecoder().decode(data)
460
490
  if decoded:
@@ -14,7 +14,6 @@ if TYPE_CHECKING:
14
14
  from iwa.core.services.transfer import TransferService
15
15
 
16
16
 
17
-
18
17
  class ERC20TransferMixin:
19
18
  """Mixin for ERC20 token transfers and approvals."""
20
19
 
@@ -57,11 +56,23 @@ class ERC20TransferMixin:
57
56
  chain_name=chain_name,
58
57
  data=transaction["data"],
59
58
  )
60
- # Get receipt for gas calculation
59
+ # Get receipt for gas calculation with retry
61
60
  receipt = None
62
61
  try:
63
62
  interface = ChainInterfaces().get(chain_name)
64
- receipt = interface.web3.eth.get_transaction_receipt(tx_hash)
63
+ import time
64
+
65
+ for _ in range(5):
66
+ try:
67
+ receipt = interface.web3.eth.get_transaction_receipt(tx_hash)
68
+ if receipt:
69
+ break
70
+ except Exception:
71
+ pass
72
+ time.sleep(2)
73
+
74
+ if not receipt:
75
+ logger.warning(f"Could not get receipt for Safe tx {tx_hash} after retries")
65
76
  except Exception as e:
66
77
  logger.warning(f"Could not get receipt for Safe tx {tx_hash}: {e}")
67
78
 
@@ -85,24 +85,14 @@ class NativeTransferMixin:
85
85
  ) -> Optional[str]:
86
86
  """Send native currency via EOA using unified TransactionService."""
87
87
  # Build transaction dict
88
- try:
89
- gas_price = chain_interface.web3.eth.gas_price
90
- gas_estimate = chain_interface.web3.eth.estimate_gas({
88
+ tx = chain_interface.calculate_transaction_params(
89
+ built_method=None,
90
+ tx_params={
91
91
  "from": from_account.address,
92
92
  "to": to_address,
93
93
  "value": amount_wei,
94
- })
95
- except Exception as e:
96
- logger.error(f"Failed to estimate gas for native transfer: {e}")
97
- return None
98
-
99
- tx = {
100
- "from": from_account.address,
101
- "to": to_address,
102
- "value": amount_wei,
103
- "gas": gas_estimate,
104
- "gasPrice": gas_price,
105
- }
94
+ },
95
+ )
106
96
 
107
97
  # Use unified TransactionService
108
98
  success, receipt = self.transaction_service.sign_and_send(
@@ -189,17 +179,13 @@ class NativeTransferMixin:
189
179
  logger.info(f"Wrapping {amount_eth:.4f} xDAI → WXDAI...")
190
180
 
191
181
  try:
192
- tx = contract.functions.deposit().build_transaction(
193
- {
194
- "from": account.address,
195
- "value": amount_wei,
196
- "gas": 100000,
197
- "gasPrice": chain_interface.web3._web3.eth.gas_price,
198
- "nonce": chain_interface.web3._web3.eth.get_transaction_count(account.address),
199
- }
182
+ tx_params = chain_interface.calculate_transaction_params(
183
+ built_method=contract.functions.deposit(),
184
+ tx_params={"from": account.address, "value": amount_wei},
200
185
  )
186
+ transaction = contract.functions.deposit().build_transaction(tx_params)
201
187
 
202
- signed = self.key_storage.sign_transaction(tx, account.address)
188
+ signed = self.key_storage.sign_transaction(transaction, account.address)
203
189
  tx_hash = chain_interface.web3._web3.eth.send_raw_transaction(signed.raw_transaction)
204
190
  receipt = chain_interface.web3._web3.eth.wait_for_transaction_receipt(
205
191
  tx_hash, timeout=60
@@ -270,14 +256,11 @@ class NativeTransferMixin:
270
256
  logger.info(f"Unwrapping {amount_eth:.4f} WXDAI → xDAI...")
271
257
 
272
258
  try:
273
- tx = contract.functions.withdraw(amount_wei).build_transaction(
274
- {
275
- "from": account.address,
276
- "gas": 100000,
277
- "gasPrice": chain_interface.web3._web3.eth.gas_price,
278
- "nonce": chain_interface.web3._web3.eth.get_transaction_count(account.address),
279
- }
259
+ tx_params = chain_interface.calculate_transaction_params(
260
+ built_method=contract.functions.withdraw(amount_wei),
261
+ tx_params={"from": account.address},
280
262
  )
263
+ tx = contract.functions.withdraw(amount_wei).build_transaction(tx_params)
281
264
 
282
265
  signed = self.key_storage.sign_transaction(tx, account.address)
283
266
  tx_hash = chain_interface.web3._web3.eth.send_raw_transaction(signed.raw_transaction)
@@ -104,6 +104,7 @@ class SwapMixin:
104
104
  sell_token_name=sell_token_name,
105
105
  buy_token_name=buy_token_name,
106
106
  order_type=order_type,
107
+ wait_for_execution=True,
107
108
  )
108
109
 
109
110
  if result:
@@ -0,0 +1,91 @@
1
+ """Test Gnosis fee calculation fix."""
2
+
3
+ import unittest
4
+ from unittest.mock import MagicMock
5
+
6
+ from iwa.core.chain.interface import ChainInterface
7
+
8
+
9
+ class TestGnosisFeeFix(unittest.TestCase):
10
+ """Test fee calculation for Gnosis chain."""
11
+
12
+ def setUp(self):
13
+ """Set up test fixtures."""
14
+ self.chain_interface = ChainInterface("gnosis")
15
+ # Mock web3 to avoid real connection
16
+ self.chain_interface.web3 = MagicMock()
17
+ self.chain_interface.web3.eth = MagicMock()
18
+
19
+ def test_fee_too_low_fix(self):
20
+ """Test that maxPriorityFeePerGas is forced to at least 1 wei on Gnosis."""
21
+ # 1. Setup EIP-1559 environment (block has baseFeePerGas)
22
+ mock_block = {"baseFeePerGas": 5000}
23
+ self.chain_interface.web3.eth.get_block.return_value = mock_block
24
+
25
+ # 2. Simulate RPC returning 0 priority fee (cause of the error)
26
+ self.chain_interface.web3.eth.max_priority_fee = 0
27
+
28
+ # 3. Setup dummy function for gas estimation
29
+ mock_func = MagicMock()
30
+ mock_func.estimate_gas.return_value = 100_000
31
+
32
+ # 4. Call calculation
33
+ tx_params = {"from": "0x123", "value": 0}
34
+ params = self.chain_interface.calculate_transaction_params(mock_func, tx_params)
35
+
36
+ # 5. Verify the fix
37
+ # Should have EIP-1559 fields
38
+ self.assertIn("maxFeePerGas", params)
39
+ self.assertIn("maxPriorityFeePerGas", params)
40
+ self.assertNotIn("gasPrice", params)
41
+
42
+ # CRITICAL ASSERTION: maxPriorityFeePerGas must be >= 1
43
+ # If the fix works, it should be 1. If it fails (old behavior), it would be 0.
44
+ self.assertEqual(
45
+ params["maxPriorityFeePerGas"], 1, "Priority fee should be forced to 1 wei"
46
+ )
47
+
48
+ # Verify max fee calculation: (base * 1.5) + priority
49
+ expected_max_fee = int(5000 * 1.5) + 1
50
+ self.assertEqual(params["maxFeePerGas"], expected_max_fee)
51
+
52
+ def test_legacy_fallback(self):
53
+ """Test fallback to legacy gasPrice if baseFeePerGas is missing."""
54
+ # Setup legacy block (no baseFeePerGas)
55
+ self.chain_interface.web3.eth.get_block.return_value = {}
56
+ self.chain_interface.web3.eth.gas_price = 2000000000
57
+
58
+ mock_func = MagicMock()
59
+ mock_func.estimate_gas.return_value = 100_000
60
+
61
+ tx_params = {"from": "0x123", "value": 0}
62
+ params = self.chain_interface.calculate_transaction_params(mock_func, tx_params)
63
+
64
+ self.assertIn("gasPrice", params)
65
+ self.assertNotIn("maxFeePerGas", params)
66
+ self.assertEqual(params["gasPrice"], 2000000000)
67
+
68
+ def test_other_chain_behavior(self):
69
+ """Test that other chains (e.g. Ethereum) don't necessarily upgrade 0 to 1 (unless generic rule applies)."""
70
+ # Our fix in interface.py applies the fallback logic:
71
+ # if max_priority_fee < 1: max_priority_fee = 1
72
+ # This is now generic in the cleaned up code (lines 449-450: if max_priority_fee < 1: max_priority_fee = 1)
73
+ # So it should apply to ALL chains that support EIP-1559.
74
+
75
+ # We'll use Ethereum to verify generic behavior
76
+ eth_interface = ChainInterface("ethereum")
77
+ eth_interface.web3 = MagicMock()
78
+ eth_interface.web3.eth = MagicMock()
79
+
80
+ mock_block = {"baseFeePerGas": 100_000}
81
+ eth_interface.web3.eth.get_block.return_value = mock_block
82
+ eth_interface.web3.eth.max_priority_fee = 0
83
+
84
+ mock_func = MagicMock()
85
+ mock_func.estimate_gas.return_value = 21000
86
+
87
+ params = eth_interface.calculate_transaction_params(mock_func, {"from": "0x123"})
88
+
89
+ self.assertEqual(
90
+ params["maxPriorityFeePerGas"], 1, "Generic fallback should apply to all chains"
91
+ )