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/chain/interface.py
CHANGED
|
@@ -34,9 +34,11 @@ class ChainInterface:
|
|
|
34
34
|
chain: SupportedChain = getattr(SupportedChains(), chain.lower())
|
|
35
35
|
|
|
36
36
|
self.chain = chain
|
|
37
|
-
|
|
37
|
+
# Enforce strict 1.0 RPS limit to prevent synchronization issues
|
|
38
|
+
self._rate_limiter = get_rate_limiter(chain.name, rate=1.0, burst=1)
|
|
38
39
|
self._current_rpc_index = 0
|
|
39
40
|
self._rpc_failure_counts: Dict[int, int] = {}
|
|
41
|
+
self._last_rotation_time = 0.0 # Monotonic timestamp of last rotation
|
|
40
42
|
|
|
41
43
|
if self.chain.rpc and self.chain.rpc.startswith("http://"):
|
|
42
44
|
logger.warning(
|
|
@@ -213,12 +215,24 @@ class ChainInterface:
|
|
|
213
215
|
]
|
|
214
216
|
return any(signal in err_text for signal in server_error_signals)
|
|
215
217
|
|
|
218
|
+
def _is_gas_error(self, error: Exception) -> bool:
|
|
219
|
+
"""Check if error is related to gas limits or fees."""
|
|
220
|
+
err_text = str(error).lower()
|
|
221
|
+
gas_signals = [
|
|
222
|
+
"intrinsic gas too low",
|
|
223
|
+
"feetoolow",
|
|
224
|
+
"gas limit",
|
|
225
|
+
"underpriced",
|
|
226
|
+
]
|
|
227
|
+
return any(signal in err_text for signal in gas_signals)
|
|
228
|
+
|
|
216
229
|
def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
|
|
217
230
|
"""Handle RPC errors with smart rotation and retry logic."""
|
|
218
231
|
result: Dict[str, Union[bool, int]] = {
|
|
219
232
|
"is_rate_limit": self._is_rate_limit_error(error),
|
|
220
233
|
"is_connection_error": self._is_connection_error(error),
|
|
221
234
|
"is_server_error": self._is_server_error(error),
|
|
235
|
+
"is_gas_error": self._is_gas_error(error),
|
|
222
236
|
"is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
|
|
223
237
|
"rotated": False,
|
|
224
238
|
"should_retry": False,
|
|
@@ -242,9 +256,11 @@ class ChainInterface:
|
|
|
242
256
|
|
|
243
257
|
if should_rotate:
|
|
244
258
|
error_type = "rate limit" if result["is_rate_limit"] else "connection"
|
|
259
|
+
# Extract the original URL from the error message for clarity
|
|
260
|
+
error_msg = str(error)
|
|
245
261
|
logger.warning(
|
|
246
262
|
f"RPC {error_type} error on {self.chain.name} "
|
|
247
|
-
f"(RPC #{self._current_rpc_index}): {
|
|
263
|
+
f"(current RPC #{self._current_rpc_index}): {error_msg}"
|
|
248
264
|
)
|
|
249
265
|
|
|
250
266
|
if self.rotate_rpc():
|
|
@@ -253,27 +269,49 @@ class ChainInterface:
|
|
|
253
269
|
logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
|
|
254
270
|
else:
|
|
255
271
|
if result["is_rate_limit"]:
|
|
256
|
-
|
|
272
|
+
# Rotation was skipped (cooldown or single RPC) - still allow retry with current RPC
|
|
273
|
+
# We don't trigger backoff here because that would block ALL threads.
|
|
274
|
+
# Instead, we let the individual thread retry (which has its own exponential backoff).
|
|
257
275
|
result["should_retry"] = True
|
|
258
|
-
logger.
|
|
276
|
+
logger.info(
|
|
277
|
+
f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
|
|
278
|
+
)
|
|
259
279
|
|
|
260
280
|
elif result["is_server_error"]:
|
|
261
281
|
logger.warning(f"Server error on {self.chain.name}: {error}")
|
|
262
282
|
result["should_retry"] = True
|
|
263
283
|
|
|
284
|
+
elif result["is_gas_error"]:
|
|
285
|
+
logger.warning(f"Gas/Fee error detected: {error}. Allowing retry for adjustment.")
|
|
286
|
+
result["should_retry"] = True
|
|
287
|
+
|
|
264
288
|
return result
|
|
265
289
|
|
|
266
290
|
def rotate_rpc(self) -> bool:
|
|
267
291
|
"""Rotate to the next available RPC."""
|
|
292
|
+
# Minimum time between rotations to prevent cascade rotations from parallel requests
|
|
293
|
+
# failing simultaneously
|
|
294
|
+
cooldown_seconds = 2.0
|
|
295
|
+
|
|
268
296
|
with self._rotation_lock:
|
|
269
297
|
if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
|
|
270
298
|
return False
|
|
271
299
|
|
|
300
|
+
# Cooldown: prevent cascade rotations from in-flight requests
|
|
301
|
+
now = time.monotonic()
|
|
302
|
+
if now - self._last_rotation_time < cooldown_seconds:
|
|
303
|
+
logger.debug(
|
|
304
|
+
f"RPC rotation skipped for {self.chain.name} (cooldown active, "
|
|
305
|
+
f"{cooldown_seconds - (now - self._last_rotation_time):.1f}s remaining)"
|
|
306
|
+
)
|
|
307
|
+
return False
|
|
308
|
+
|
|
272
309
|
# Simple Round Robin rotation
|
|
273
310
|
self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
|
|
274
311
|
# Internal call to _init_web3 already expects to be under lock if called from here,
|
|
275
312
|
# but _init_web3 itself doesn't have a lock. Let's make it consistent.
|
|
276
313
|
self._init_web3_under_lock()
|
|
314
|
+
self._last_rotation_time = now
|
|
277
315
|
|
|
278
316
|
logger.info(
|
|
279
317
|
f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
|
|
@@ -423,18 +461,88 @@ class ChainInterface:
|
|
|
423
461
|
return 500_000
|
|
424
462
|
|
|
425
463
|
def calculate_transaction_params(
|
|
426
|
-
self, built_method: Callable, tx_params: Dict[str, Union[str, int]]
|
|
464
|
+
self, built_method: Optional[Callable], tx_params: Dict[str, Union[str, int]]
|
|
427
465
|
) -> Dict[str, Union[str, int]]:
|
|
428
|
-
"""Calculate transaction parameters for a contract function call."""
|
|
466
|
+
"""Calculate transaction parameters for a contract function call or native transfer."""
|
|
467
|
+
# Baseline parameters
|
|
429
468
|
params = {
|
|
430
469
|
"from": tx_params["from"],
|
|
431
470
|
"value": tx_params.get("value", 0),
|
|
432
471
|
"nonce": self.web3.eth.get_transaction_count(tx_params["from"]),
|
|
433
|
-
"gas": self.estimate_gas(built_method, tx_params),
|
|
434
|
-
"gasPrice": self.web3.eth.gas_price,
|
|
435
472
|
}
|
|
473
|
+
|
|
474
|
+
# Add 'to' only for native transfers (built_method is None)
|
|
475
|
+
# Contract calls already have the target address in the contract object
|
|
476
|
+
if not built_method and "to" in tx_params:
|
|
477
|
+
params["to"] = tx_params["to"]
|
|
478
|
+
elif not built_method and "to" in params: # Fallback if added to params earlier (though not here yet)
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
# Determine gas
|
|
482
|
+
if built_method:
|
|
483
|
+
# Contract function call
|
|
484
|
+
params["gas"] = self.estimate_gas(built_method, tx_params)
|
|
485
|
+
elif "gas" in tx_params:
|
|
486
|
+
# Manual gas override
|
|
487
|
+
params["gas"] = tx_params["gas"]
|
|
488
|
+
else:
|
|
489
|
+
# Native transfer - dynamic estimation
|
|
490
|
+
try:
|
|
491
|
+
# web3.eth.estimate_gas returns gas for the dict it receives
|
|
492
|
+
est_params = {
|
|
493
|
+
"from": params["from"],
|
|
494
|
+
"to": params["to"],
|
|
495
|
+
"value": params["value"]
|
|
496
|
+
}
|
|
497
|
+
# Remove None 'to' for contract creation simulation if needed, but usually send() has to
|
|
498
|
+
if not est_params["to"]:
|
|
499
|
+
est_params.pop("to")
|
|
500
|
+
|
|
501
|
+
estimated = self.web3.eth.estimate_gas(est_params)
|
|
502
|
+
# Apply 10% buffer for safety
|
|
503
|
+
params["gas"] = int(estimated * 1.1)
|
|
504
|
+
logger.debug(f"[GAS] Estimated native transfer gas: {params['gas']} (raw: {estimated})")
|
|
505
|
+
except Exception as e:
|
|
506
|
+
logger.debug(f"[GAS] Native estimation failed, fallback to 21000: {e}")
|
|
507
|
+
params["gas"] = 21_000
|
|
508
|
+
|
|
509
|
+
# Add EIP-1559 or Legacy fees
|
|
510
|
+
params.update(self.get_suggested_fees())
|
|
436
511
|
return params
|
|
437
512
|
|
|
513
|
+
def get_suggested_fees(self) -> Dict[str, int]:
|
|
514
|
+
"""Calculate suggested fees for a transaction (EIP-1559 or legacy)."""
|
|
515
|
+
try:
|
|
516
|
+
# Check for EIP-1559 support
|
|
517
|
+
latest_block = self.web3.eth.get_block("latest")
|
|
518
|
+
base_fee = latest_block.get("baseFeePerGas")
|
|
519
|
+
|
|
520
|
+
if base_fee is not None:
|
|
521
|
+
# EIP-1559 logic
|
|
522
|
+
max_priority_fee = int(self.web3.eth.max_priority_fee)
|
|
523
|
+
|
|
524
|
+
# Gnosis specific: ensure min priority fee (critical for validation)
|
|
525
|
+
if self.chain.name.lower() == "gnosis":
|
|
526
|
+
if max_priority_fee < 1:
|
|
527
|
+
max_priority_fee = 1 # Network minimum is 1 wei
|
|
528
|
+
|
|
529
|
+
# Global minimum for EIP-1559
|
|
530
|
+
if max_priority_fee < 1:
|
|
531
|
+
max_priority_fee = 1
|
|
532
|
+
|
|
533
|
+
# Buffer max_fee to handle base fee expansion
|
|
534
|
+
max_fee = int(base_fee * 1.5) + max_priority_fee
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
"maxFeePerGas": max_fee,
|
|
538
|
+
"maxPriorityFeePerGas": max_priority_fee
|
|
539
|
+
}
|
|
540
|
+
except Exception as e:
|
|
541
|
+
logger.debug(f"Failed to calculate EIP-1559 fees: {e}, falling back to legacy")
|
|
542
|
+
|
|
543
|
+
# Legacy fallback
|
|
544
|
+
return {"gasPrice": self.web3.eth.gas_price}
|
|
545
|
+
|
|
438
546
|
def wait_for_no_pending_tx(
|
|
439
547
|
self, from_address: EthereumAddress, max_wait_seconds: int = 60, poll_interval: float = 2.0
|
|
440
548
|
):
|
iwa/core/chain/models.py
CHANGED
|
@@ -26,6 +26,9 @@ class SupportedChain(BaseModel):
|
|
|
26
26
|
|
|
27
27
|
def get_token_address(self, token_address_or_name: str) -> Optional[EthereumAddress]:
|
|
28
28
|
"""Get token address"""
|
|
29
|
+
if not token_address_or_name:
|
|
30
|
+
return None
|
|
31
|
+
|
|
29
32
|
try:
|
|
30
33
|
address = EthereumAddress(token_address_or_name)
|
|
31
34
|
except Exception:
|
|
@@ -35,9 +38,18 @@ class SupportedChain(BaseModel):
|
|
|
35
38
|
return address
|
|
36
39
|
|
|
37
40
|
if address is None:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
# Try direct lookup
|
|
42
|
+
token_addr = self.tokens.get(token_address_or_name, None)
|
|
43
|
+
if token_addr:
|
|
44
|
+
return token_addr
|
|
45
|
+
|
|
46
|
+
# Try case-insensitive lookup
|
|
47
|
+
target_lower = token_address_or_name.lower()
|
|
48
|
+
for name, addr in self.tokens.items():
|
|
49
|
+
if name.lower() == target_lower:
|
|
50
|
+
return addr
|
|
51
|
+
|
|
52
|
+
return None
|
|
41
53
|
|
|
42
54
|
def get_token_name(self, token_address: str) -> Optional[str]:
|
|
43
55
|
"""Get token name from address."""
|
iwa/core/chain/rate_limiter.py
CHANGED
|
@@ -108,12 +108,11 @@ def get_rate_limiter(chain_name: str, rate: float = None, burst: int = None) ->
|
|
|
108
108
|
class RateLimitedEth:
|
|
109
109
|
"""Wrapper around web3.eth that applies rate limiting transparently."""
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
READ_METHODS = {
|
|
112
112
|
"get_balance",
|
|
113
113
|
"get_code",
|
|
114
114
|
"get_transaction_count",
|
|
115
115
|
"estimate_gas",
|
|
116
|
-
"send_raw_transaction",
|
|
117
116
|
"wait_for_transaction_receipt",
|
|
118
117
|
"get_block",
|
|
119
118
|
"get_transaction",
|
|
@@ -122,6 +121,16 @@ class RateLimitedEth:
|
|
|
122
121
|
"get_logs",
|
|
123
122
|
}
|
|
124
123
|
|
|
124
|
+
WRITE_METHODS = {
|
|
125
|
+
"send_raw_transaction",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Helper sets for efficient lookup
|
|
129
|
+
RPC_METHODS = READ_METHODS | WRITE_METHODS
|
|
130
|
+
|
|
131
|
+
DEFAULT_READ_RETRIES = 3
|
|
132
|
+
DEFAULT_READ_RETRY_DELAY = 0.5
|
|
133
|
+
|
|
125
134
|
def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
|
|
126
135
|
"""Initialize RateLimitedEth wrapper."""
|
|
127
136
|
object.__setattr__(self, "_eth", web3_eth)
|
|
@@ -133,7 +142,7 @@ class RateLimitedEth:
|
|
|
133
142
|
attr = getattr(self._eth, name)
|
|
134
143
|
|
|
135
144
|
if name in self.RPC_METHODS and callable(attr):
|
|
136
|
-
return self.
|
|
145
|
+
return self._wrap_with_retry(attr, name)
|
|
137
146
|
|
|
138
147
|
return attr
|
|
139
148
|
|
|
@@ -151,23 +160,56 @@ class RateLimitedEth:
|
|
|
151
160
|
else:
|
|
152
161
|
delattr(self._eth, name)
|
|
153
162
|
|
|
154
|
-
|
|
155
|
-
|
|
163
|
+
@property
|
|
164
|
+
def block_number(self):
|
|
165
|
+
"""Get block number with retry."""
|
|
166
|
+
return self._execute_with_retry(lambda: self._eth.block_number, "block_number")
|
|
156
167
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
@property
|
|
169
|
+
def gas_price(self):
|
|
170
|
+
"""Get gas price with retry."""
|
|
171
|
+
return self._execute_with_retry(lambda: self._eth.gas_price, "gas_price")
|
|
172
|
+
|
|
173
|
+
def _wrap_with_retry(self, method, method_name):
|
|
174
|
+
"""Wrap method with rate limiting and retry for reads."""
|
|
162
175
|
|
|
163
176
|
def wrapper(*args, **kwargs):
|
|
164
177
|
if not self._rate_limiter.acquire(timeout=30.0):
|
|
165
|
-
raise TimeoutError(f"Rate limit timeout
|
|
178
|
+
raise TimeoutError(f"Rate limit timeout for {method_name}")
|
|
179
|
+
|
|
180
|
+
# Writes: no auto-retry (handled by caller or not safe)
|
|
181
|
+
if method_name in self.WRITE_METHODS:
|
|
182
|
+
return method(*args, **kwargs)
|
|
166
183
|
|
|
167
|
-
|
|
184
|
+
# Reads: with retry
|
|
185
|
+
return self._execute_with_retry(method, method_name, *args, **kwargs)
|
|
168
186
|
|
|
169
187
|
return wrapper
|
|
170
188
|
|
|
189
|
+
def _execute_with_retry(self, method, method_name, *args, **kwargs):
|
|
190
|
+
"""Execute read operation with retry logic."""
|
|
191
|
+
last_error = None
|
|
192
|
+
for attempt in range(self.DEFAULT_READ_RETRIES + 1):
|
|
193
|
+
try:
|
|
194
|
+
return method(*args, **kwargs)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
last_error = e
|
|
197
|
+
# Use chain interface to handle error (logging, rotation, etc.)
|
|
198
|
+
result = self._chain_interface._handle_rpc_error(e)
|
|
199
|
+
|
|
200
|
+
if not result["should_retry"] or attempt >= self.DEFAULT_READ_RETRIES:
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
delay = self.DEFAULT_READ_RETRY_DELAY * (2**attempt)
|
|
204
|
+
logger.debug(
|
|
205
|
+
f"{method_name} attempt {attempt + 1} failed, retrying in {delay:.1f}s..."
|
|
206
|
+
)
|
|
207
|
+
time.sleep(delay)
|
|
208
|
+
|
|
209
|
+
if last_error:
|
|
210
|
+
raise last_error
|
|
211
|
+
raise RuntimeError(f"{method_name} failed unexpectedly")
|
|
212
|
+
|
|
171
213
|
|
|
172
214
|
class RateLimitedWeb3:
|
|
173
215
|
"""Wrapper around Web3 instance that applies rate limiting transparently."""
|
iwa/core/cli.py
CHANGED
|
@@ -40,7 +40,7 @@ def account_create(
|
|
|
40
40
|
"""Create a new wallet account"""
|
|
41
41
|
key_storage = KeyStorage()
|
|
42
42
|
try:
|
|
43
|
-
key_storage.
|
|
43
|
+
key_storage.generate_new_account(tag)
|
|
44
44
|
except ValueError as e:
|
|
45
45
|
typer.echo(f"Error: {e}")
|
|
46
46
|
raise typer.Exit(code=1) from e
|
iwa/core/ipfs.py
CHANGED
|
@@ -7,13 +7,20 @@ direct HTTP API calls, avoiding heavy dependencies like open-aea.
|
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
9
|
import uuid
|
|
10
|
-
from typing import Any, Dict, Optional, Tuple
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
|
|
11
11
|
|
|
12
12
|
import aiohttp
|
|
13
13
|
from multiformats import CID
|
|
14
14
|
|
|
15
15
|
from iwa.core.models import Config
|
|
16
16
|
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
# Global session for sync requests
|
|
21
|
+
_SYNC_SESSION: Optional["requests.Session"] = None
|
|
22
|
+
|
|
23
|
+
|
|
17
24
|
|
|
18
25
|
def _compute_cid_v1_hex(data: bytes) -> str:
|
|
19
26
|
"""Compute CIDv1 hex representation from raw data.
|
|
@@ -84,6 +91,21 @@ def push_to_ipfs_sync(
|
|
|
84
91
|
:return: Tuple of (CIDv1 string, CIDv1 hex representation).
|
|
85
92
|
"""
|
|
86
93
|
import requests
|
|
94
|
+
from requests.adapters import HTTPAdapter
|
|
95
|
+
from urllib3.util.retry import Retry
|
|
96
|
+
|
|
97
|
+
global _SYNC_SESSION
|
|
98
|
+
|
|
99
|
+
if _SYNC_SESSION is None:
|
|
100
|
+
_SYNC_SESSION = requests.Session()
|
|
101
|
+
retry_strategy = Retry(
|
|
102
|
+
total=3,
|
|
103
|
+
backoff_factor=1,
|
|
104
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
105
|
+
)
|
|
106
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
107
|
+
_SYNC_SESSION.mount("http://", adapter)
|
|
108
|
+
_SYNC_SESSION.mount("https://", adapter)
|
|
87
109
|
|
|
88
110
|
url = api_url or Config().core.ipfs_api_url
|
|
89
111
|
endpoint = f"{url}/api/v0/add"
|
|
@@ -92,7 +114,7 @@ def push_to_ipfs_sync(
|
|
|
92
114
|
|
|
93
115
|
files = {"file": ("data", data, "application/octet-stream")}
|
|
94
116
|
|
|
95
|
-
response =
|
|
117
|
+
response = _SYNC_SESSION.post(endpoint, files=files, params=params, timeout=60)
|
|
96
118
|
response.raise_for_status()
|
|
97
119
|
result = response.json()
|
|
98
120
|
|
iwa/core/keys.py
CHANGED
|
@@ -210,14 +210,14 @@ class KeyStorage(BaseModel):
|
|
|
210
210
|
if not self.get_address_by_tag("master"):
|
|
211
211
|
logger.info("Master account not found. Creating new 'master' account...")
|
|
212
212
|
try:
|
|
213
|
-
self.
|
|
213
|
+
self.generate_new_account("master")
|
|
214
214
|
except Exception as e:
|
|
215
215
|
logger.error(f"Failed to create master account: {e}")
|
|
216
216
|
|
|
217
217
|
@property
|
|
218
|
-
def master_account(self) -> EncryptedAccount:
|
|
218
|
+
def master_account(self) -> Optional[Union[EncryptedAccount, StoredSafeAccount]]:
|
|
219
219
|
"""Get the master account"""
|
|
220
|
-
master_account = self.
|
|
220
|
+
master_account = self.find_stored_account("master")
|
|
221
221
|
|
|
222
222
|
if not master_account:
|
|
223
223
|
return list(self.accounts.values())[0]
|
|
@@ -231,19 +231,34 @@ class KeyStorage(BaseModel):
|
|
|
231
231
|
# Use backup directory relative to wallet path (supports tests with tmp_path)
|
|
232
232
|
backup_dir = self._path.parent / "backup"
|
|
233
233
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
try:
|
|
235
|
+
os.chmod(backup_dir, 0o700)
|
|
236
|
+
except OSError as e:
|
|
237
|
+
logger.debug(f"Could not chmod backup dir (expected in some Docker setups): {e}")
|
|
234
238
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
235
239
|
backup_path = backup_dir / f"wallet.json.{timestamp}.bkp"
|
|
236
240
|
shutil.copy2(self._path, backup_path)
|
|
241
|
+
try:
|
|
242
|
+
os.chmod(backup_path, 0o600)
|
|
243
|
+
except OSError as e:
|
|
244
|
+
logger.debug(f"Could not chmod backup file: {e}")
|
|
237
245
|
logger.debug(f"Backed up wallet to {backup_path}")
|
|
238
246
|
|
|
239
247
|
# Ensure directory exists
|
|
240
248
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
241
249
|
|
|
242
250
|
with open(self._path, "w", encoding="utf-8") as f:
|
|
243
|
-
json
|
|
251
|
+
# Use mode='json' to ensure all types (EthereumAddress) are correctly serialized
|
|
252
|
+
json.dump(self.model_dump(mode='json'), f, indent=4)
|
|
253
|
+
f.flush()
|
|
254
|
+
os.fsync(f.fileno()) # Force write to disk (critical for Docker volumes)
|
|
244
255
|
|
|
245
|
-
|
|
246
|
-
|
|
256
|
+
try:
|
|
257
|
+
os.chmod(self._path, 0o600)
|
|
258
|
+
except OSError as e:
|
|
259
|
+
logger.debug(f"Could not chmod wallet file: {e}")
|
|
260
|
+
|
|
261
|
+
logger.info(f"[KeyStorage] Wallet saved to {self._path} ({len(self.accounts)} accounts)")
|
|
247
262
|
|
|
248
263
|
@staticmethod
|
|
249
264
|
def _encrypt_mnemonic(mnemonic: str, password: str) -> dict:
|
|
@@ -328,21 +343,20 @@ class KeyStorage(BaseModel):
|
|
|
328
343
|
encrypted_acct = EncryptedAccount.encrypt_private_key(
|
|
329
344
|
private_key_hex, self._password, "master"
|
|
330
345
|
)
|
|
331
|
-
self.
|
|
332
|
-
self.save()
|
|
333
|
-
|
|
346
|
+
self.register_account(encrypted_acct)
|
|
334
347
|
return encrypted_acct, mnemonic_str
|
|
335
348
|
|
|
336
|
-
def
|
|
337
|
-
"""
|
|
349
|
+
def generate_new_account(self, tag: str) -> EncryptedAccount:
|
|
350
|
+
"""Generate a brand new EOA account and register it with the given tag."""
|
|
351
|
+
# Note: register_account(tag) check is inside, but we handle 'master' logic here
|
|
338
352
|
tags = [acct.tag for acct in self.accounts.values()]
|
|
339
353
|
if not tags:
|
|
340
354
|
tag = "master" # First account is always master
|
|
341
|
-
if tag in tags:
|
|
342
|
-
raise ValueError(f"Tag '{tag}' already exists in wallet.")
|
|
343
355
|
|
|
344
356
|
# Master account: derive from mnemonic
|
|
345
357
|
if tag == "master":
|
|
358
|
+
if "master" in tags:
|
|
359
|
+
raise ValueError("Master account already exists in wallet.")
|
|
346
360
|
encrypted_acct, mnemonic = self._create_master_from_mnemonic()
|
|
347
361
|
self._pending_mnemonic = mnemonic # Store temporarily for display
|
|
348
362
|
return encrypted_acct
|
|
@@ -350,10 +364,24 @@ class KeyStorage(BaseModel):
|
|
|
350
364
|
# Non-master: random key as before
|
|
351
365
|
acct = Account.create()
|
|
352
366
|
encrypted = EncryptedAccount.encrypt_private_key(acct.key.hex(), self._password, tag)
|
|
353
|
-
self.
|
|
354
|
-
self.save()
|
|
367
|
+
self.register_account(encrypted)
|
|
355
368
|
return encrypted
|
|
356
369
|
|
|
370
|
+
def register_account(self, account: Union[EncryptedAccount, StoredSafeAccount]):
|
|
371
|
+
"""Register an account (EOA or Safe) in the storage with strict tag uniqueness checks."""
|
|
372
|
+
if not account.tag:
|
|
373
|
+
# Allow untagged accounts (rare but possible)
|
|
374
|
+
pass
|
|
375
|
+
else:
|
|
376
|
+
# Check for duplicate tags
|
|
377
|
+
for existing in self.accounts.values():
|
|
378
|
+
if existing.tag == account.tag and existing.address != account.address:
|
|
379
|
+
raise ValueError(f"Tag '{account.tag}' is already used by address {existing.address}")
|
|
380
|
+
|
|
381
|
+
self.accounts[account.address] = account
|
|
382
|
+
logger.info(f"[KeyStorage] Registering account: tag='{account.tag}', address={account.address}")
|
|
383
|
+
self.save()
|
|
384
|
+
|
|
357
385
|
def get_pending_mnemonic(self) -> Optional[str]:
|
|
358
386
|
"""Get and clear the pending mnemonic (for one-time display).
|
|
359
387
|
|
|
@@ -419,6 +447,22 @@ class KeyStorage(BaseModel):
|
|
|
419
447
|
del self.accounts[account.address]
|
|
420
448
|
self.save()
|
|
421
449
|
|
|
450
|
+
def rename_account(self, address_or_tag: str, new_tag: str):
|
|
451
|
+
"""Rename an account's tag with uniqueness check."""
|
|
452
|
+
account = self.find_stored_account(address_or_tag)
|
|
453
|
+
if not account:
|
|
454
|
+
raise ValueError(f"Account '{address_or_tag}' not found.")
|
|
455
|
+
|
|
456
|
+
# Check if new tag is already used by a DIFFERENT account
|
|
457
|
+
for existing in self.accounts.values():
|
|
458
|
+
if existing.tag == new_tag and existing.address != account.address:
|
|
459
|
+
raise ValueError(f"Tag '{new_tag}' is already used by address {existing.address}")
|
|
460
|
+
|
|
461
|
+
old_tag = account.tag
|
|
462
|
+
account.tag = new_tag
|
|
463
|
+
logger.info(f"[KeyStorage] Renaming account: '{old_tag}' -> '{new_tag}' (address={account.address})")
|
|
464
|
+
self.save()
|
|
465
|
+
|
|
422
466
|
def _get_private_key(self, address: str) -> Optional[str]:
|
|
423
467
|
"""Get private key (Internal)"""
|
|
424
468
|
account = self.accounts.get(EthereumAddress(address))
|
iwa/core/models.py
CHANGED
|
@@ -6,14 +6,26 @@ from typing import Dict, List, Optional, Type, TypeVar
|
|
|
6
6
|
|
|
7
7
|
import tomli
|
|
8
8
|
import tomli_w
|
|
9
|
-
import yaml
|
|
10
9
|
from pydantic import BaseModel, Field, PrivateAttr
|
|
11
10
|
from pydantic_core import core_schema
|
|
11
|
+
from ruamel.yaml import YAML
|
|
12
12
|
|
|
13
13
|
from iwa.core.types import EthereumAddress # noqa: F401 - re-exported for backwards compatibility
|
|
14
14
|
from iwa.core.utils import singleton
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _update_yaml_recursive(target: Dict, source: Dict) -> None:
|
|
18
|
+
"""Recursively update a ruamel.yaml CommentedMap with data from a dict.
|
|
19
|
+
|
|
20
|
+
This preserves comments and structure in the target map.
|
|
21
|
+
"""
|
|
22
|
+
for key, value in source.items():
|
|
23
|
+
if isinstance(value, dict) and key in target and isinstance(target[key], dict):
|
|
24
|
+
_update_yaml_recursive(target[key], value)
|
|
25
|
+
else:
|
|
26
|
+
target[key] = value
|
|
27
|
+
|
|
28
|
+
|
|
17
29
|
class EncryptedData(BaseModel):
|
|
18
30
|
"""Encrypted data structure with explicit KDF parameters."""
|
|
19
31
|
|
|
@@ -67,6 +79,14 @@ class CoreConfig(BaseModel):
|
|
|
67
79
|
)
|
|
68
80
|
tenderly_olas_funds: float = Field(default=100000.0, description="OLAS amount for vNet funding")
|
|
69
81
|
|
|
82
|
+
# Safe Transaction Retry System
|
|
83
|
+
safe_tx_max_retries: int = Field(
|
|
84
|
+
default=6, description="Maximum retries for Safe transactions"
|
|
85
|
+
)
|
|
86
|
+
safe_tx_gas_buffer: float = Field(
|
|
87
|
+
default=1.5, description="Gas buffer multiplier for Safe transactions"
|
|
88
|
+
)
|
|
89
|
+
|
|
70
90
|
|
|
71
91
|
T = TypeVar("T", bound="StorableModel")
|
|
72
92
|
|
|
@@ -106,16 +126,31 @@ class StorableModel(BaseModel):
|
|
|
106
126
|
self._path = path
|
|
107
127
|
|
|
108
128
|
def save_yaml(self, path: Optional[Path] = None) -> None:
|
|
109
|
-
"""Save to YAML file"""
|
|
129
|
+
"""Save to YAML file preserving comments if file exists."""
|
|
110
130
|
if path is None:
|
|
111
131
|
if getattr(self, "_path", None) is None:
|
|
112
132
|
raise ValueError("Save path not specified and no previous path stored.")
|
|
113
133
|
path = self._path
|
|
114
134
|
|
|
115
135
|
path = path.with_suffix(".yaml")
|
|
136
|
+
ryaml = YAML()
|
|
137
|
+
ryaml.preserve_quotes = True
|
|
138
|
+
ryaml.indent(mapping=2, sequence=4, offset=2)
|
|
139
|
+
|
|
140
|
+
data = self.model_dump(mode="json")
|
|
141
|
+
|
|
142
|
+
if path.exists():
|
|
143
|
+
with path.open("r", encoding="utf-8") as f:
|
|
144
|
+
try:
|
|
145
|
+
target = ryaml.load(f) or {}
|
|
146
|
+
_update_yaml_recursive(target, data)
|
|
147
|
+
data = target
|
|
148
|
+
except Exception:
|
|
149
|
+
# Fallback to overwrite if load fails
|
|
150
|
+
pass
|
|
116
151
|
|
|
117
152
|
with path.open("w", encoding="utf-8") as f:
|
|
118
|
-
|
|
153
|
+
ryaml.dump(data, f)
|
|
119
154
|
self._storage_format = "yaml"
|
|
120
155
|
self._path = path
|
|
121
156
|
|
|
@@ -171,8 +206,9 @@ class StorableModel(BaseModel):
|
|
|
171
206
|
def load_yaml(cls: Type[T], path: str | Path) -> T:
|
|
172
207
|
"""Load from YAML file"""
|
|
173
208
|
path = Path(path)
|
|
209
|
+
ryaml = YAML()
|
|
174
210
|
with path.open("r", encoding="utf-8") as f:
|
|
175
|
-
data =
|
|
211
|
+
data = ryaml.load(f)
|
|
176
212
|
obj = cls(**data)
|
|
177
213
|
obj._storage_format = "yaml"
|
|
178
214
|
obj._path = path
|
|
@@ -223,10 +259,9 @@ class Config(StorableModel):
|
|
|
223
259
|
return
|
|
224
260
|
|
|
225
261
|
try:
|
|
226
|
-
|
|
227
|
-
|
|
262
|
+
ryaml = YAML()
|
|
228
263
|
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
|
229
|
-
data =
|
|
264
|
+
data = ryaml.load(f) or {}
|
|
230
265
|
|
|
231
266
|
# Load core config
|
|
232
267
|
if "core" in data:
|
|
@@ -270,25 +305,37 @@ class Config(StorableModel):
|
|
|
270
305
|
self.save_config()
|
|
271
306
|
|
|
272
307
|
def save_config(self) -> None:
|
|
273
|
-
"""Persist current config to config.yaml."""
|
|
274
|
-
import yaml
|
|
275
|
-
|
|
308
|
+
"""Persist current config to config.yaml preserving comments."""
|
|
276
309
|
from iwa.core.constants import CONFIG_PATH
|
|
277
310
|
|
|
278
311
|
data = {}
|
|
279
312
|
|
|
280
313
|
if self.core:
|
|
281
|
-
data["core"] = self.core.model_dump()
|
|
314
|
+
data["core"] = self.core.model_dump(mode="json")
|
|
282
315
|
|
|
283
316
|
data["plugins"] = {}
|
|
284
317
|
for plugin_name, plugin_config in self.plugins.items():
|
|
285
318
|
if isinstance(plugin_config, BaseModel):
|
|
286
|
-
data["plugins"][plugin_name] = plugin_config.model_dump()
|
|
319
|
+
data["plugins"][plugin_name] = plugin_config.model_dump(mode="json")
|
|
287
320
|
elif isinstance(plugin_config, dict):
|
|
288
321
|
data["plugins"][plugin_name] = plugin_config
|
|
289
322
|
|
|
323
|
+
ryaml = YAML()
|
|
324
|
+
ryaml.preserve_quotes = True
|
|
325
|
+
ryaml.indent(mapping=2, sequence=4, offset=2)
|
|
326
|
+
|
|
327
|
+
if CONFIG_PATH.exists():
|
|
328
|
+
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
|
329
|
+
try:
|
|
330
|
+
target = ryaml.load(f) or {}
|
|
331
|
+
_update_yaml_recursive(target, data)
|
|
332
|
+
data = target
|
|
333
|
+
except Exception:
|
|
334
|
+
# Fallback to overwrite if load fails
|
|
335
|
+
pass
|
|
336
|
+
|
|
290
337
|
with CONFIG_PATH.open("w", encoding="utf-8") as f:
|
|
291
|
-
|
|
338
|
+
ryaml.dump(data, f)
|
|
292
339
|
|
|
293
340
|
self._path = CONFIG_PATH
|
|
294
341
|
self._storage_format = "yaml"
|