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.
- iwa/core/chain/interface.py +130 -11
- iwa/core/chain/models.py +15 -3
- iwa/core/chain/rate_limiter.py +48 -12
- iwa/core/chainlist.py +15 -10
- iwa/core/cli.py +4 -1
- iwa/core/contracts/cache.py +1 -1
- iwa/core/contracts/contract.py +1 -0
- iwa/core/contracts/decoder.py +10 -4
- iwa/core/http.py +31 -0
- iwa/core/ipfs.py +21 -7
- iwa/core/keys.py +65 -15
- iwa/core/models.py +58 -13
- iwa/core/pricing.py +10 -6
- iwa/core/rpc_monitor.py +1 -0
- iwa/core/secrets.py +27 -0
- iwa/core/services/account.py +1 -1
- iwa/core/services/balance.py +0 -23
- iwa/core/services/safe.py +72 -45
- iwa/core/services/safe_executor.py +350 -0
- iwa/core/services/transaction.py +43 -13
- iwa/core/services/transfer/erc20.py +14 -3
- iwa/core/services/transfer/native.py +14 -31
- iwa/core/services/transfer/swap.py +1 -0
- iwa/core/tests/test_gnosis_fee.py +91 -0
- iwa/core/tests/test_ipfs.py +85 -0
- iwa/core/tests/test_pricing.py +65 -0
- iwa/core/tests/test_regression_fixes.py +97 -0
- iwa/core/utils.py +2 -0
- iwa/core/wallet.py +6 -4
- iwa/plugins/gnosis/cow/quotes.py +2 -2
- iwa/plugins/gnosis/cow/swap.py +18 -32
- iwa/plugins/gnosis/tests/test_cow.py +19 -10
- iwa/plugins/olas/constants.py +15 -5
- iwa/plugins/olas/contracts/activity_checker.py +3 -3
- iwa/plugins/olas/contracts/staking.py +0 -1
- iwa/plugins/olas/events.py +15 -13
- iwa/plugins/olas/importer.py +29 -25
- iwa/plugins/olas/models.py +0 -3
- iwa/plugins/olas/plugin.py +16 -14
- iwa/plugins/olas/service_manager/drain.py +16 -9
- iwa/plugins/olas/service_manager/lifecycle.py +23 -12
- iwa/plugins/olas/service_manager/staking.py +15 -10
- iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
- iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
- iwa/plugins/olas/tests/test_olas_integration.py +49 -29
- iwa/plugins/olas/tests/test_olas_view.py +5 -1
- iwa/plugins/olas/tests/test_service_manager.py +15 -17
- iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
- iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
- iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
- iwa/plugins/olas/tests/test_service_staking.py +64 -38
- iwa/tools/drain_accounts.py +61 -0
- iwa/tools/list_contracts.py +2 -0
- iwa/tools/reset_env.py +2 -1
- iwa/tools/test_chainlist.py +5 -1
- iwa/tui/screens/wallets.py +2 -4
- iwa/web/routers/accounts.py +1 -1
- iwa/web/routers/olas/services.py +10 -5
- iwa/web/static/app.js +21 -9
- iwa/web/static/style.css +4 -0
- iwa/web/tests/test_web_endpoints.py +2 -2
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/METADATA +6 -3
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
- tests/test_balance_service.py +0 -43
- tests/test_chain.py +13 -5
- tests/test_cli.py +2 -2
- tests/test_drain_coverage.py +12 -6
- tests/test_keys.py +23 -23
- tests/test_rate_limiter.py +2 -2
- tests/test_rate_limiter_retry.py +103 -0
- tests/test_rpc_efficiency.py +4 -1
- tests/test_rpc_rate_limit.py +34 -0
- tests/test_rpc_rotation.py +59 -11
- tests/test_safe_coverage.py +37 -23
- tests/test_safe_executor.py +361 -0
- tests/test_safe_integration.py +153 -0
- tests/test_safe_service.py +1 -1
- tests/test_transfer_swap_unit.py +5 -1
- tests/test_pricing.py +0 -160
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
- {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}")
|
iwa/core/services/transaction.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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)
|
|
@@ -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
|
+
)
|