iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Transaction service module."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from web3 import exceptions as web3_exceptions
|
|
8
|
+
|
|
9
|
+
from iwa.core.chain import ChainInterfaces
|
|
10
|
+
from iwa.core.db import log_transaction
|
|
11
|
+
from iwa.core.keys import KeyStorage
|
|
12
|
+
from iwa.core.services.account import AccountService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TransactionService:
|
|
16
|
+
"""Manages transaction lifecycle: signing, sending, retrying."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, key_storage: KeyStorage, account_service: AccountService):
|
|
19
|
+
"""Initialize TransactionService."""
|
|
20
|
+
self.key_storage = key_storage
|
|
21
|
+
self.account_service = account_service
|
|
22
|
+
|
|
23
|
+
def sign_and_send(
|
|
24
|
+
self,
|
|
25
|
+
transaction: dict,
|
|
26
|
+
signer_address_or_tag: str,
|
|
27
|
+
chain_name: str = "gnosis",
|
|
28
|
+
tags: Optional[List[str]] = None,
|
|
29
|
+
) -> Tuple[bool, Dict]:
|
|
30
|
+
"""Sign and send a transaction with retry logic for gas."""
|
|
31
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
32
|
+
tx = dict(transaction)
|
|
33
|
+
max_retries = 3
|
|
34
|
+
|
|
35
|
+
if not self._prepare_transaction(tx, signer_address_or_tag, chain_interface):
|
|
36
|
+
return False, {}
|
|
37
|
+
|
|
38
|
+
for attempt in range(1, max_retries + 1):
|
|
39
|
+
try:
|
|
40
|
+
signed_txn = self.key_storage.sign_transaction(tx, signer_address_or_tag)
|
|
41
|
+
txn_hash = chain_interface.web3.eth.send_raw_transaction(signed_txn.raw_transaction)
|
|
42
|
+
receipt = chain_interface.web3.eth.wait_for_transaction_receipt(txn_hash)
|
|
43
|
+
|
|
44
|
+
if receipt and getattr(receipt, "status", None) == 1:
|
|
45
|
+
signer_account = self.account_service.resolve_account(signer_address_or_tag)
|
|
46
|
+
chain_interface.wait_for_no_pending_tx(signer_account.address)
|
|
47
|
+
logger.info(f"Transaction sent successfully. Tx Hash: {txn_hash.hex()}")
|
|
48
|
+
|
|
49
|
+
self._log_successful_transaction(
|
|
50
|
+
receipt, tx, signer_account, chain_name, txn_hash, tags
|
|
51
|
+
)
|
|
52
|
+
return True, receipt
|
|
53
|
+
|
|
54
|
+
# Transaction reverted
|
|
55
|
+
logger.error("Transaction failed (status 0).")
|
|
56
|
+
return False, {}
|
|
57
|
+
|
|
58
|
+
except web3_exceptions.Web3RPCError as e:
|
|
59
|
+
if self._handle_gas_error(e, tx, attempt, max_retries):
|
|
60
|
+
continue
|
|
61
|
+
return False, {}
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
# Attempt RPC rotation
|
|
65
|
+
if self._handle_generic_error(e, chain_interface, attempt, max_retries):
|
|
66
|
+
continue
|
|
67
|
+
return False, {}
|
|
68
|
+
|
|
69
|
+
return False, {}
|
|
70
|
+
|
|
71
|
+
def _prepare_transaction(self, tx: dict, signer_tag: str, chain_interface) -> bool:
|
|
72
|
+
"""Ensure nonce and chainId are set."""
|
|
73
|
+
if "nonce" not in tx:
|
|
74
|
+
signer_account = self.account_service.resolve_account(signer_tag)
|
|
75
|
+
if not signer_account:
|
|
76
|
+
logger.error(f"Signer {signer_tag} not found")
|
|
77
|
+
return False
|
|
78
|
+
tx["nonce"] = chain_interface.web3.eth.get_transaction_count(signer_account.address)
|
|
79
|
+
|
|
80
|
+
if "chainId" not in tx:
|
|
81
|
+
tx["chainId"] = chain_interface.chain.chain_id
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def _handle_gas_error(self, e, tx, attempt, max_retries) -> bool:
|
|
85
|
+
err_text = str(e)
|
|
86
|
+
if self._is_gas_too_low_error(err_text) and attempt < max_retries:
|
|
87
|
+
logger.warning(
|
|
88
|
+
f"Gas too low error detected. Retrying with increased gas (Attempt {attempt}/{max_retries})..."
|
|
89
|
+
)
|
|
90
|
+
current_gas = int(tx.get("gas", 30_000))
|
|
91
|
+
tx["gas"] = int(current_gas * 1.5)
|
|
92
|
+
time.sleep(0.5 * attempt)
|
|
93
|
+
return True
|
|
94
|
+
logger.exception(f"Error sending transaction: {e}")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def _handle_generic_error(self, e, chain_interface, attempt, max_retries) -> bool:
|
|
98
|
+
if attempt < max_retries:
|
|
99
|
+
logger.warning(f"Error encountered: {e}. Attempting to rotate RPC...")
|
|
100
|
+
if chain_interface.rotate_rpc():
|
|
101
|
+
logger.info("Retrying with new RPC...")
|
|
102
|
+
time.sleep(0.5 * attempt)
|
|
103
|
+
return True
|
|
104
|
+
logger.exception(f"Unexpected error sending transaction: {e}")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def _log_successful_transaction(self, receipt, tx, signer_account, chain_name, txn_hash, tags):
|
|
108
|
+
try:
|
|
109
|
+
gas_cost_wei, gas_value_eur = self._calculate_gas_cost(receipt, tx, chain_name)
|
|
110
|
+
final_tags = self._determine_tags(tx, tags)
|
|
111
|
+
|
|
112
|
+
log_transaction(
|
|
113
|
+
tx_hash=txn_hash.hex(),
|
|
114
|
+
from_addr=signer_account.address,
|
|
115
|
+
to_addr=tx.get("to", ""),
|
|
116
|
+
token="NATIVE",
|
|
117
|
+
amount_wei=tx.get("value", 0),
|
|
118
|
+
chain=chain_name,
|
|
119
|
+
from_tag=signer_account.tag if hasattr(signer_account, "tag") else None,
|
|
120
|
+
gas_cost=str(gas_cost_wei) if gas_cost_wei else None,
|
|
121
|
+
gas_value_eur=gas_value_eur,
|
|
122
|
+
tags=final_tags if final_tags else None,
|
|
123
|
+
)
|
|
124
|
+
except Exception as log_err:
|
|
125
|
+
logger.warning(f"Failed to log transaction: {log_err}")
|
|
126
|
+
|
|
127
|
+
def _calculate_gas_cost(self, receipt, tx, chain_name):
|
|
128
|
+
gas_used = getattr(receipt, "gasUsed", 0)
|
|
129
|
+
gas_price = getattr(
|
|
130
|
+
receipt,
|
|
131
|
+
"effectiveGasPrice",
|
|
132
|
+
tx.get("gasPrice", tx.get("maxFeePerGas", 0)),
|
|
133
|
+
)
|
|
134
|
+
gas_cost_wei = gas_used * gas_price if gas_price else 0
|
|
135
|
+
|
|
136
|
+
gas_value_eur = None
|
|
137
|
+
if gas_cost_wei > 0:
|
|
138
|
+
try:
|
|
139
|
+
from iwa.core.pricing import PriceService
|
|
140
|
+
|
|
141
|
+
token_id = "dai" if chain_name.lower() == "gnosis" else "ethereum"
|
|
142
|
+
pricing = PriceService()
|
|
143
|
+
native_price = pricing.get_token_price(token_id)
|
|
144
|
+
if native_price:
|
|
145
|
+
gas_eth = float(gas_cost_wei) / 10**18
|
|
146
|
+
gas_value_eur = gas_eth * native_price
|
|
147
|
+
except Exception as price_err:
|
|
148
|
+
logger.warning(f"Failed to calculate gas value: {price_err}")
|
|
149
|
+
return gas_cost_wei, gas_value_eur
|
|
150
|
+
|
|
151
|
+
def _determine_tags(self, tx, tags):
|
|
152
|
+
final_tags = tags or []
|
|
153
|
+
data_hex = tx.get("data", "")
|
|
154
|
+
if isinstance(data_hex, bytes):
|
|
155
|
+
data_hex = data_hex.hex()
|
|
156
|
+
if data_hex.startswith("0x095ea7b3") or data_hex.startswith("095ea7b3"):
|
|
157
|
+
final_tags.append("approve")
|
|
158
|
+
|
|
159
|
+
if "olas" in str(tx.get("to", "")).lower():
|
|
160
|
+
final_tags.append("olas")
|
|
161
|
+
|
|
162
|
+
return list(set(final_tags))
|
|
163
|
+
|
|
164
|
+
def _is_gas_too_low_error(self, err_text: str) -> bool:
|
|
165
|
+
"""Check if error is due to low gas."""
|
|
166
|
+
low_gas_signals = [
|
|
167
|
+
"feetoolow",
|
|
168
|
+
"intrinsic gas too low",
|
|
169
|
+
"replacement transaction underpriced",
|
|
170
|
+
]
|
|
171
|
+
text = (err_text or "").lower()
|
|
172
|
+
return any(sig in text for sig in low_gas_signals)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Transfer service package."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from web3.types import Wei
|
|
7
|
+
|
|
8
|
+
from iwa.core.chain import ChainInterfaces
|
|
9
|
+
from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
|
|
10
|
+
from iwa.core.contracts.erc20 import ERC20Contract
|
|
11
|
+
from iwa.core.contracts.multisend import (
|
|
12
|
+
MultiSendCallOnlyContract,
|
|
13
|
+
MultiSendContract,
|
|
14
|
+
)
|
|
15
|
+
from iwa.core.models import StoredSafeAccount
|
|
16
|
+
from iwa.core.services.transfer.base import TransferServiceBase
|
|
17
|
+
from iwa.core.services.transfer.erc20 import ERC20TransferMixin
|
|
18
|
+
from iwa.core.services.transfer.multisend import MultiSendMixin
|
|
19
|
+
from iwa.core.services.transfer.native import NativeTransferMixin
|
|
20
|
+
from iwa.core.services.transfer.swap import SwapMixin
|
|
21
|
+
from iwa.plugins.gnosis.cow import CowSwap, OrderType
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"TransferService",
|
|
25
|
+
# Re-export for backward compatibility
|
|
26
|
+
"MultiSendCallOnlyContract",
|
|
27
|
+
"MultiSendContract",
|
|
28
|
+
"StoredSafeAccount",
|
|
29
|
+
"CowSwap",
|
|
30
|
+
"OrderType",
|
|
31
|
+
"ERC20Contract",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TransferService(
|
|
36
|
+
NativeTransferMixin, ERC20TransferMixin, MultiSendMixin, SwapMixin, TransferServiceBase
|
|
37
|
+
):
|
|
38
|
+
"""Service for handling transfers, swaps, and approvals.
|
|
39
|
+
|
|
40
|
+
Composed of mixins for specific functionalities:
|
|
41
|
+
- NativeTransferMixin: Native currency transfers and wrapping
|
|
42
|
+
- ERC20TransferMixin: ERC20 tokens transfers and approvals
|
|
43
|
+
- MultiSendMixin: Batch transactions and draining
|
|
44
|
+
- SwapMixin: CowSwap integration
|
|
45
|
+
- TransferServiceBase: Shared implementations and helpers
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def send(
|
|
49
|
+
self,
|
|
50
|
+
from_address_or_tag: str,
|
|
51
|
+
to_address_or_tag: str,
|
|
52
|
+
amount_wei: Wei,
|
|
53
|
+
token_address_or_name: str = "native",
|
|
54
|
+
chain_name: str = "gnosis",
|
|
55
|
+
) -> Optional[str]:
|
|
56
|
+
"""Send native currency or ERC20 token.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
from_address_or_tag: Source account address or tag
|
|
60
|
+
to_address_or_tag: Destination address or tag
|
|
61
|
+
amount_wei: Amount in wei
|
|
62
|
+
token_address_or_name: Token address, name, or "native"
|
|
63
|
+
chain_name: Chain name (default: "gnosis")
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Transaction hash if successful, None otherwise.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
# Resolve accounts
|
|
70
|
+
from_account = self.account_service.resolve_account(from_address_or_tag)
|
|
71
|
+
if not from_account:
|
|
72
|
+
logger.error(f"From account '{from_address_or_tag}' not found in wallet.")
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
to_address, to_tag = self._resolve_destination(to_address_or_tag)
|
|
76
|
+
if not to_address:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# SECURITY: Validate destination is whitelisted
|
|
80
|
+
if not self._is_whitelisted_destination(to_address):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# SECURITY: Validate token is supported
|
|
84
|
+
if not self._is_supported_token(token_address_or_name, chain_name):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
# Resolve chain and token
|
|
88
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
89
|
+
token_address = self.account_service.get_token_address(
|
|
90
|
+
token_address_or_name, chain_interface.chain
|
|
91
|
+
)
|
|
92
|
+
if not token_address:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Resolve tags and symbols for logging
|
|
96
|
+
from_tag = self.account_service.get_tag_by_address(from_account.address)
|
|
97
|
+
token_symbol = self._resolve_token_symbol(
|
|
98
|
+
token_address, token_address_or_name, chain_interface
|
|
99
|
+
)
|
|
100
|
+
is_safe = getattr(from_account, "threshold", None) is not None
|
|
101
|
+
|
|
102
|
+
# Native currency transfer
|
|
103
|
+
if token_address == NATIVE_CURRENCY_ADDRESS:
|
|
104
|
+
amount_eth = float(chain_interface.web3.from_wei(amount_wei, "ether"))
|
|
105
|
+
logger.info(
|
|
106
|
+
f"Sending {amount_eth:.4f} {chain_interface.chain.native_currency} "
|
|
107
|
+
f"from {from_address_or_tag} to {to_address_or_tag}"
|
|
108
|
+
)
|
|
109
|
+
if is_safe:
|
|
110
|
+
return self._send_native_via_safe(
|
|
111
|
+
from_account,
|
|
112
|
+
from_address_or_tag,
|
|
113
|
+
to_address,
|
|
114
|
+
amount_wei,
|
|
115
|
+
chain_name,
|
|
116
|
+
from_tag,
|
|
117
|
+
to_tag,
|
|
118
|
+
token_symbol,
|
|
119
|
+
)
|
|
120
|
+
return self._send_native_via_eoa(
|
|
121
|
+
from_account,
|
|
122
|
+
to_address,
|
|
123
|
+
amount_wei,
|
|
124
|
+
chain_name,
|
|
125
|
+
chain_interface,
|
|
126
|
+
from_tag,
|
|
127
|
+
to_tag,
|
|
128
|
+
token_symbol,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# ERC20 token transfer
|
|
132
|
+
erc20 = ERC20Contract(token_address, chain_name)
|
|
133
|
+
transaction = erc20.prepare_transfer_tx(from_account.address, to_address, amount_wei)
|
|
134
|
+
if not transaction:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
amount_eth = float(chain_interface.web3.from_wei(amount_wei, "ether"))
|
|
138
|
+
logger.info(
|
|
139
|
+
f"Sending {amount_eth:.4f} {token_address_or_name} "
|
|
140
|
+
f"from {from_address_or_tag} to {to_address_or_tag}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if is_safe:
|
|
144
|
+
return self._send_erc20_via_safe(
|
|
145
|
+
from_account,
|
|
146
|
+
from_address_or_tag,
|
|
147
|
+
to_address,
|
|
148
|
+
amount_wei,
|
|
149
|
+
chain_name,
|
|
150
|
+
erc20,
|
|
151
|
+
transaction,
|
|
152
|
+
from_tag,
|
|
153
|
+
to_tag,
|
|
154
|
+
token_symbol,
|
|
155
|
+
)
|
|
156
|
+
return self._send_erc20_via_eoa(
|
|
157
|
+
from_account,
|
|
158
|
+
from_address_or_tag,
|
|
159
|
+
to_address,
|
|
160
|
+
amount_wei,
|
|
161
|
+
chain_name,
|
|
162
|
+
transaction,
|
|
163
|
+
from_tag,
|
|
164
|
+
to_tag,
|
|
165
|
+
token_symbol,
|
|
166
|
+
)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Transfer service base module."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from web3.types import Wei
|
|
7
|
+
|
|
8
|
+
from iwa.core.chain import ChainInterfaces
|
|
9
|
+
from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
|
|
10
|
+
from iwa.core.models import Config, EthereumAddress
|
|
11
|
+
from iwa.core.pricing import PriceService
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from iwa.core.keys import KeyStorage
|
|
15
|
+
from iwa.core.services.account import AccountService
|
|
16
|
+
from iwa.core.services.balance import BalanceService
|
|
17
|
+
from iwa.core.services.safe import SafeService
|
|
18
|
+
from iwa.core.services.transaction import TransactionService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Coingecko IDs for tokens and native currencies
|
|
22
|
+
TOKEN_COINGECKO_IDS = {
|
|
23
|
+
"XDAI": "xdai",
|
|
24
|
+
"ETH": "ethereum",
|
|
25
|
+
"OLAS": "autonolas",
|
|
26
|
+
"USDC": "usdc",
|
|
27
|
+
"WXDAI": "xdai",
|
|
28
|
+
"SDAI": "savings-xdai",
|
|
29
|
+
"EURE": "monerium-eur-money",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
CHAIN_COINGECKO_IDS = {
|
|
33
|
+
"gnosis": "dai",
|
|
34
|
+
"ethereum": "ethereum",
|
|
35
|
+
"base": "ethereum",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TransferServiceBase:
|
|
40
|
+
"""Base class for TransferService with shared helpers."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
key_storage: "KeyStorage",
|
|
45
|
+
account_service: "AccountService",
|
|
46
|
+
balance_service: "BalanceService",
|
|
47
|
+
safe_service: "SafeService",
|
|
48
|
+
transaction_service: "TransactionService",
|
|
49
|
+
):
|
|
50
|
+
"""Initialize TransferService."""
|
|
51
|
+
self.key_storage = key_storage
|
|
52
|
+
self.account_service = account_service
|
|
53
|
+
self.balance_service = balance_service
|
|
54
|
+
self.safe_service = safe_service
|
|
55
|
+
self.transaction_service = transaction_service
|
|
56
|
+
|
|
57
|
+
def _resolve_destination(self, to_address_or_tag: str) -> tuple[Optional[str], Optional[str]]:
|
|
58
|
+
"""Resolve destination address and tag.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Tuple of (address, tag) or (None, None) if invalid.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
to_account = self.account_service.resolve_account(to_address_or_tag)
|
|
65
|
+
if to_account:
|
|
66
|
+
return to_account.address, getattr(to_account, "tag", None)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
to_address = EthereumAddress(to_address_or_tag)
|
|
70
|
+
# Try to find tag in whitelist
|
|
71
|
+
to_tag = self._resolve_whitelist_tag(to_address)
|
|
72
|
+
return to_address, to_tag
|
|
73
|
+
except ValueError:
|
|
74
|
+
logger.error(f"Invalid destination address: {to_address_or_tag}")
|
|
75
|
+
return None, None
|
|
76
|
+
|
|
77
|
+
def _resolve_whitelist_tag(self, address: str) -> Optional[str]:
|
|
78
|
+
"""Resolve tag from whitelist for an address."""
|
|
79
|
+
config = Config()
|
|
80
|
+
if config.core and config.core.whitelist:
|
|
81
|
+
try:
|
|
82
|
+
target_addr = EthereumAddress(address)
|
|
83
|
+
for name, addr in config.core.whitelist.items():
|
|
84
|
+
if addr == target_addr:
|
|
85
|
+
return name
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _calculate_gas_info(
|
|
91
|
+
self, receipt: Optional[dict], chain_name: str
|
|
92
|
+
) -> tuple[Optional[int], Optional[float]]:
|
|
93
|
+
"""Calculate gas cost and gas value in EUR from transaction receipt.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
receipt: Transaction receipt containing gasUsed and effectiveGasPrice.
|
|
97
|
+
chain_name: Name of the chain for price lookup.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Tuple of (gas_cost_wei, gas_value_eur) or (None, None) if unavailable.
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
if not receipt:
|
|
104
|
+
return None, None
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
gas_used = receipt.get("gasUsed", 0)
|
|
108
|
+
effective_gas_price = receipt.get("effectiveGasPrice", 0)
|
|
109
|
+
gas_cost_wei = gas_used * effective_gas_price
|
|
110
|
+
|
|
111
|
+
# Get native token price
|
|
112
|
+
coingecko_id = CHAIN_COINGECKO_IDS.get(chain_name, "ethereum")
|
|
113
|
+
price_service = PriceService()
|
|
114
|
+
native_price_eur = price_service.get_token_price(coingecko_id, "eur")
|
|
115
|
+
|
|
116
|
+
gas_value_eur = None
|
|
117
|
+
if native_price_eur and gas_cost_wei > 0:
|
|
118
|
+
gas_cost_eth = gas_cost_wei / 10**18
|
|
119
|
+
gas_value_eur = gas_cost_eth * native_price_eur
|
|
120
|
+
|
|
121
|
+
return gas_cost_wei, gas_value_eur
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.warning(f"Failed to calculate gas info: {e}")
|
|
124
|
+
return None, None
|
|
125
|
+
|
|
126
|
+
def _get_token_price_info(
|
|
127
|
+
self, token_symbol: str, amount_wei: Wei, chain_name: str
|
|
128
|
+
) -> tuple[Optional[float], Optional[float]]:
|
|
129
|
+
"""Calculate token price and total value in EUR.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
token_symbol: Token symbol (e.g. 'OLAS', 'ETH')
|
|
133
|
+
amount_wei: Amount in wei
|
|
134
|
+
chain_name: Chain name
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Tuple of (price_eur, value_eur) or (None, None) if unavailable.
|
|
138
|
+
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
# Map symbol to coingecko id
|
|
142
|
+
symbol_upper = token_symbol.upper()
|
|
143
|
+
cg_id = TOKEN_COINGECKO_IDS.get(symbol_upper)
|
|
144
|
+
if not cg_id:
|
|
145
|
+
# Try name mapping if it's native signal
|
|
146
|
+
if symbol_upper in ["NATIVE", "TOKEN"]:
|
|
147
|
+
cg_id = CHAIN_COINGECKO_IDS.get(chain_name.lower())
|
|
148
|
+
|
|
149
|
+
if not cg_id:
|
|
150
|
+
return None, None
|
|
151
|
+
|
|
152
|
+
price_service = PriceService()
|
|
153
|
+
price_eur = price_service.get_token_price(cg_id, "eur")
|
|
154
|
+
|
|
155
|
+
if price_eur is None:
|
|
156
|
+
return None, None
|
|
157
|
+
|
|
158
|
+
# Get decimals for value calculation
|
|
159
|
+
interface = ChainInterfaces().get(chain_name)
|
|
160
|
+
decimals = 18
|
|
161
|
+
if symbol_upper not in ["NATIVE", "TOKEN", "XDAI", "ETH"]:
|
|
162
|
+
token_address = interface.chain.get_token_address(token_symbol)
|
|
163
|
+
if token_address:
|
|
164
|
+
decimals = interface.get_token_decimals(token_address)
|
|
165
|
+
|
|
166
|
+
value_eur = (amount_wei / 10**decimals) * price_eur
|
|
167
|
+
return price_eur, value_eur
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.warning(f"Failed to calculate token price info for {token_symbol}: {e}")
|
|
170
|
+
return None, None
|
|
171
|
+
|
|
172
|
+
def _is_whitelisted_destination(self, to_address: str) -> bool:
|
|
173
|
+
"""Check if destination address is whitelisted.
|
|
174
|
+
|
|
175
|
+
An address is whitelisted if it's:
|
|
176
|
+
1. One of our own accounts (from wallets.json)
|
|
177
|
+
2. In the explicit whitelist in config.yaml [core.whitelist]
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if allowed, False if blocked.
|
|
181
|
+
|
|
182
|
+
"""
|
|
183
|
+
# Normalize address for comparison
|
|
184
|
+
try:
|
|
185
|
+
target_addr = EthereumAddress(to_address)
|
|
186
|
+
except ValueError:
|
|
187
|
+
logger.error(f"Invalid address format: {to_address}")
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
# Check 1: Is it one of our own wallets?
|
|
191
|
+
if self.account_service.resolve_account(to_address):
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
# Check 2: Is it in the config whitelist?
|
|
195
|
+
config = Config()
|
|
196
|
+
if config.core and config.core.whitelist:
|
|
197
|
+
if target_addr in config.core.whitelist.values():
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
# Not in whitelist - block transaction
|
|
201
|
+
logger.error(
|
|
202
|
+
f"SECURITY: Destination {to_address} is NOT whitelisted. "
|
|
203
|
+
"Transaction blocked. Add to config.yaml [core.whitelist] to allow."
|
|
204
|
+
)
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def _is_supported_token(self, token_address_or_name: str, chain_name: str) -> bool:
|
|
208
|
+
"""Validate that the token is supported on this chain.
|
|
209
|
+
|
|
210
|
+
Supported tokens are:
|
|
211
|
+
1. Native currency
|
|
212
|
+
2. Tokens defined in chain.tokens (defaults + custom_tokens)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if token is supported, False otherwise.
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
# Native currency is always allowed
|
|
219
|
+
if token_address_or_name.lower() in ("native", NATIVE_CURRENCY_ADDRESS.lower()):
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
223
|
+
supported_tokens = chain_interface.tokens
|
|
224
|
+
|
|
225
|
+
# Check by name (e.g., "OLAS")
|
|
226
|
+
if token_address_or_name.upper() in supported_tokens:
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
# Check by address
|
|
230
|
+
try:
|
|
231
|
+
token_addr = EthereumAddress(token_address_or_name)
|
|
232
|
+
if token_addr in supported_tokens.values():
|
|
233
|
+
return True
|
|
234
|
+
except ValueError:
|
|
235
|
+
pass # Not a valid address, already checked by name
|
|
236
|
+
|
|
237
|
+
# Token not supported
|
|
238
|
+
supported_list = ", ".join(supported_tokens.keys())
|
|
239
|
+
logger.error(
|
|
240
|
+
f"SECURITY: Token '{token_address_or_name}' is NOT supported on {chain_name}. "
|
|
241
|
+
f"Supported tokens: {supported_list}. "
|
|
242
|
+
"Add to config.yaml [core.custom_tokens] to allow."
|
|
243
|
+
)
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def _resolve_token_symbol(
|
|
247
|
+
self, token_address: str, token_address_or_name: str, chain_interface
|
|
248
|
+
) -> str:
|
|
249
|
+
"""Resolve token symbol for logging."""
|
|
250
|
+
if token_address == NATIVE_CURRENCY_ADDRESS:
|
|
251
|
+
return chain_interface.chain.native_currency
|
|
252
|
+
|
|
253
|
+
if not token_address_or_name.startswith("0x"):
|
|
254
|
+
return token_address_or_name
|
|
255
|
+
|
|
256
|
+
for name, addr in chain_interface.tokens.items():
|
|
257
|
+
if addr == token_address:
|
|
258
|
+
return name
|
|
259
|
+
|
|
260
|
+
return token_address_or_name
|