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.
- iwa/core/chain/interface.py +116 -8
- iwa/core/chain/models.py +15 -3
- iwa/core/chain/rate_limiter.py +54 -12
- iwa/core/cli.py +1 -1
- iwa/core/ipfs.py +24 -2
- iwa/core/keys.py +59 -15
- iwa/core/models.py +60 -13
- iwa/core/pricing.py +24 -2
- iwa/core/secrets.py +27 -0
- iwa/core/services/account.py +1 -1
- iwa/core/services/balance.py +0 -22
- iwa/core/services/safe.py +64 -43
- iwa/core/services/safe_executor.py +316 -0
- iwa/core/services/transaction.py +11 -1
- iwa/core/services/transfer/erc20.py +14 -2
- iwa/core/services/transfer/native.py +14 -31
- iwa/core/services/transfer/swap.py +1 -0
- iwa/core/tests/test_gnosis_fee.py +87 -0
- iwa/core/tests/test_ipfs.py +85 -0
- iwa/core/tests/test_pricing.py +65 -0
- iwa/core/tests/test_regression_fixes.py +100 -0
- iwa/core/wallet.py +3 -3
- 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/importer.py +5 -7
- iwa/plugins/olas/models.py +0 -3
- iwa/plugins/olas/service_manager/drain.py +16 -7
- iwa/plugins/olas/service_manager/lifecycle.py +15 -4
- iwa/plugins/olas/service_manager/staking.py +4 -4
- iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
- iwa/plugins/olas/tests/test_olas_archiving.py +73 -0
- iwa/plugins/olas/tests/test_olas_view.py +5 -1
- iwa/plugins/olas/tests/test_service_manager.py +7 -7
- iwa/plugins/olas/tests/test_service_manager_errors.py +1 -1
- iwa/plugins/olas/tests/test_service_manager_flows.py +1 -1
- iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
- iwa/tools/drain_accounts.py +60 -0
- iwa/tools/list_contracts.py +2 -0
- iwa/tui/screens/wallets.py +2 -2
- iwa/web/routers/accounts.py +1 -1
- 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.32.dist-info → iwa-0.0.58.dist-info}/METADATA +6 -3
- {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/RECORD +64 -54
- {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/WHEEL +1 -1
- tests/test_balance_service.py +0 -41
- tests/test_chain.py +13 -4
- 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 +108 -0
- tests/test_rpc_rate_limit.py +33 -0
- tests/test_rpc_rotation.py +55 -7
- tests/test_safe_coverage.py +37 -23
- tests/test_safe_executor.py +335 -0
- tests/test_safe_integration.py +148 -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.32.dist-info → iwa-0.0.58.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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 =
|
|
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()
|
iwa/core/services/account.py
CHANGED
|
@@ -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
|
|
iwa/core/services/balance.py
CHANGED
|
@@ -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
|
-
|
|
227
|
-
if
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
315
|
+
if pk:
|
|
316
|
+
safe_tx.sign(pk)
|
|
317
|
+
|
|
318
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
319
|
+
executor = SafeTransactionExecutor(chain_interface)
|
|
305
320
|
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
)
|
iwa/core/services/transaction.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
|