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.
Files changed (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {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