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,82 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and restore wallet backup."""
3
+
4
+ import json
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
10
+ DATA_DIR = PROJECT_ROOT / "data"
11
+ WALLET_PATH = DATA_DIR / "wallet.json"
12
+ BACKUP_DIR = DATA_DIR / "backup"
13
+
14
+
15
+ def validate_wallet_backup(backup_path: Path) -> tuple[bool, str, int]:
16
+ """Validate that backup file is a valid wallet using Pydantic models.
17
+
18
+ Returns:
19
+ Tuple of (is_valid, message, account_count)
20
+
21
+ """
22
+ from iwa.core.keys import EncryptedAccount, StoredSafeAccount
23
+ from iwa.core.models import EthereumAddress
24
+
25
+ try:
26
+ with open(backup_path, "r", encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ except json.JSONDecodeError as e:
29
+ return False, f"Invalid JSON: {e}", 0
30
+
31
+ if not isinstance(data, dict) or "accounts" not in data:
32
+ return False, "Missing 'accounts' key", 0
33
+
34
+ accounts = data.get("accounts", {})
35
+ if not isinstance(accounts, dict):
36
+ return False, "'accounts' must be a dictionary", 0
37
+
38
+ # Validate each account using Pydantic models
39
+ for address, account_data in accounts.items():
40
+ try:
41
+ # Validate address
42
+ EthereumAddress(address)
43
+
44
+ # Validate account structure
45
+ if "signers" in account_data:
46
+ StoredSafeAccount(**account_data)
47
+ else:
48
+ EncryptedAccount(**account_data)
49
+ except Exception as e:
50
+ return False, f"Invalid account {address[:10]}...: {e}", 0
51
+
52
+ return True, "Valid wallet structure", len(accounts)
53
+
54
+
55
+ def restore_backup(backup_name: str) -> int:
56
+ """Restore wallet from backup with validation."""
57
+ backup_path = BACKUP_DIR / backup_name
58
+
59
+ if not backup_path.exists():
60
+ print(f"Error: Backup file not found: {backup_path}")
61
+ return 1
62
+
63
+ is_valid, message, num_accounts = validate_wallet_backup(backup_path)
64
+ if not is_valid:
65
+ print(f"Error: {message}")
66
+ return 1
67
+
68
+ print(f"Backup validated: {num_accounts} account(s) found")
69
+
70
+ # Restore
71
+ shutil.copy2(backup_path, WALLET_PATH)
72
+ print(f"Restored wallet from {backup_name}")
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ if len(sys.argv) != 2:
78
+ print("Usage: restore_backup.py <backup_filename>")
79
+ print("Example: restore_backup.py wallet.json.20260102_101400.bkp")
80
+ sys.exit(1)
81
+
82
+ sys.exit(restore_backup(sys.argv[1]))
iwa/tui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """TUI package for IWA."""
iwa/tui/app.py ADDED
@@ -0,0 +1,174 @@
1
+ """Main TUI Application module."""
2
+
3
+ from loguru import logger
4
+ from textual.app import App, ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.widgets import Footer, Header, TabbedContent, TabPane
7
+
8
+ from iwa.core.wallet import Wallet
9
+ from iwa.tui.rpc import RPCView
10
+ from iwa.tui.screens.wallets import WalletsScreen
11
+
12
+
13
+ class IwaApp(App):
14
+ """Iwa TUI Application."""
15
+
16
+ # ... (keep constants) ...
17
+ TITLE = "Iwa"
18
+
19
+ CSS = """
20
+ .header {
21
+ margin: 1 0;
22
+ text-style: bold;
23
+ }
24
+ .form-row {
25
+ height: 5;
26
+ margin: 1 0;
27
+ align: left middle;
28
+ }
29
+ .label {
30
+ margin: 1 1;
31
+ width: auto;
32
+ }
33
+ Input {
34
+ width: 15;
35
+ height: 3;
36
+ margin-right: 1;
37
+ }
38
+ Select {
39
+ width: 30;
40
+ height: 3;
41
+ margin-right: 1;
42
+ }
43
+ Button {
44
+ margin-right: 1;
45
+ }
46
+ #chain_row {
47
+ height: 3;
48
+ margin: 1 0;
49
+ align: left middle;
50
+ }
51
+ #chain_row Label {
52
+ margin: 1 1;
53
+ height: 1;
54
+ }
55
+ #tokens_row {
56
+ height: 3;
57
+ margin: 0 0;
58
+ }
59
+ #tokens_row Label {
60
+ margin: 1 1;
61
+ height: 1;
62
+ }
63
+ #token_toggles {
64
+ height: 3;
65
+ margin: 0 0;
66
+ }
67
+ #accounts_table {
68
+ margin-top: 1;
69
+ height: 1fr;
70
+ min-height: 10;
71
+ }
72
+ #tx_table {
73
+ height: 10;
74
+ }
75
+ Tab {
76
+ width: 20;
77
+ }
78
+ .btn-group {
79
+ width: 100%;
80
+ height: auto;
81
+ align: center middle;
82
+ margin-top: 1;
83
+ margin-bottom: 1;
84
+ }
85
+ .create-btn {
86
+ margin-left: 1;
87
+ margin-right: 1;
88
+ width: auto;
89
+ min-width: 20;
90
+ }
91
+ """
92
+
93
+ BINDINGS = [
94
+ Binding("q", "quit", "Quit"),
95
+ Binding("r", "refresh", "Refresh"),
96
+ ]
97
+
98
+ def __init__(self):
99
+ """Initialize the App."""
100
+ super().__init__()
101
+ # Configure logger for TUI
102
+ logger.remove()
103
+ logger.add("iwa.log", rotation="10 MB", level="INFO")
104
+
105
+ self.wallet = Wallet()
106
+
107
+ # Use PluginService from wallet
108
+ self.plugins = self.wallet.plugin_service.get_all_plugins()
109
+
110
+ def compose(self) -> ComposeResult:
111
+ """Compose the application layout.
112
+
113
+ Sets up the Header, TabbedContent with Wallets, Plugins, and RPC views,
114
+ and the Footer.
115
+
116
+ Returns:
117
+ ComposeResult: The widgets to be shown in the app.
118
+
119
+ """
120
+ yield Header(show_clock=True)
121
+
122
+ with TabbedContent(initial="wallets-tab"):
123
+ # Wallets first (default)
124
+ with TabPane("Wallets", id="wallets-tab"):
125
+ yield WalletsScreen(self.wallet)
126
+
127
+ # Plugin tabs (Olas)
128
+ for _name, plugin in self.plugins.items():
129
+ view = plugin.get_tui_view(wallet=self.wallet)
130
+ if view:
131
+ with TabPane(plugin.name.capitalize(), id=f"{plugin.name.lower()}-tab"):
132
+ yield view
133
+
134
+ # RPC Status last
135
+ with TabPane("RPC Status", id="rpc-tab"):
136
+ yield RPCView()
137
+
138
+ yield Footer()
139
+
140
+ def action_refresh(self) -> None:
141
+ """Action handler for the 'refresh' key binding.
142
+
143
+ Triggers a refresh of the currently active view. Currently specifically
144
+ targets the WalletsScreen to reload accounts and balances.
145
+ """
146
+ # Ideally, propagate refresh to active tab
147
+ # For now, just refresh wallets view explicitly if it's there
148
+ try:
149
+ wallets_screen = self.query_one(WalletsScreen)
150
+ wallets_screen.refresh_accounts()
151
+ except Exception:
152
+ pass
153
+
154
+ def copy_to_clipboard(self, text: str) -> None:
155
+ """Copy the provided text to the system clipboard.
156
+
157
+ Uses `pyperclip` to handle cross-platform clipboard operations.
158
+ Logs an error if the copy operation fails.
159
+
160
+ Args:
161
+ text: The string to copy.
162
+
163
+ """
164
+ try:
165
+ import pyperclip
166
+
167
+ pyperclip.copy(str(text))
168
+ except Exception as e:
169
+ logger.error(f"Failed to copy to clipboard: {e}")
170
+
171
+
172
+ if __name__ == "__main__":
173
+ app = IwaApp()
174
+ app.run()
@@ -0,0 +1,5 @@
1
+ """Modals for the IWA TUI."""
2
+
3
+ from .base import CreateEOAModal, CreateSafeModal
4
+
5
+ __all__ = ["CreateEOAModal", "CreateSafeModal"]
iwa/tui/modals/base.py ADDED
@@ -0,0 +1,406 @@
1
+ """Modal screens for the IWA TUI."""
2
+
3
+ from typing import List, Tuple
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import (
9
+ Button,
10
+ Input,
11
+ Label,
12
+ Select,
13
+ SelectionList,
14
+ )
15
+
16
+ from iwa.core.chain import ChainInterfaces
17
+
18
+
19
+ class CreateEOAModal(ModalScreen):
20
+ """Modal screen for creating a new EOA wallet."""
21
+
22
+ CSS = """
23
+ CreateEOAModal {
24
+ align: center middle;
25
+ }
26
+ #dialog {
27
+ padding: 1 2;
28
+ width: 60;
29
+ height: auto;
30
+ border: thick $background 80%;
31
+ background: $surface;
32
+ }
33
+ #dialog Label {
34
+ width: 100%;
35
+ margin-bottom: 1;
36
+ }
37
+ .header {
38
+ text-align: center;
39
+ text-style: bold;
40
+ margin-bottom: 2;
41
+ }
42
+ #tag_input {
43
+ width: 100%;
44
+ margin-bottom: 2;
45
+ }
46
+ #btn_row {
47
+ height: 3;
48
+ width: 100%;
49
+ align: center middle;
50
+ }
51
+ Button {
52
+ margin: 0 1;
53
+ }
54
+ """
55
+
56
+ def compose(self) -> ComposeResult:
57
+ """Compose the modal UI."""
58
+ with Vertical(id="dialog"):
59
+ yield Label("Create New EOA Wallet", classes="header")
60
+ yield Label("Tag (Name):")
61
+ yield Input(placeholder="e.g. My EOA", id="tag_input")
62
+ with Horizontal(id="btn_row"):
63
+ yield Button("Cancel", id="cancel")
64
+ yield Button("Create", variant="primary", id="create")
65
+
66
+ def on_button_pressed(self, event: Button.Pressed) -> None:
67
+ """Handle button press."""
68
+ if event.button.id == "create":
69
+ tag = self.query_one("#tag_input").value
70
+ self.dismiss(tag)
71
+ elif event.button.id == "cancel":
72
+ self.dismiss(None)
73
+
74
+
75
+ class CreateSafeModal(ModalScreen):
76
+ """Modal screen for creating a new Safe wallet."""
77
+
78
+ CSS = """
79
+ CreateSafeModal {
80
+ align: center middle;
81
+ }
82
+ #dialog {
83
+ padding: 1 2;
84
+ width: 70;
85
+ height: auto;
86
+ max-height: 90%;
87
+ border: thick $background 80%;
88
+ background: $surface;
89
+ overflow-y: auto;
90
+ }
91
+ #dialog Label {
92
+ width: 100%;
93
+ margin-bottom: 1;
94
+ }
95
+ .header {
96
+ text-align: center;
97
+ text-style: bold;
98
+ margin-bottom: 2;
99
+ }
100
+ #tag_input {
101
+ width: 100%;
102
+ margin-bottom: 2;
103
+ }
104
+ #threshold_input {
105
+ width: 100%;
106
+ margin-bottom: 2;
107
+ }
108
+ SelectionList {
109
+ height: 8;
110
+ margin-bottom: 2;
111
+ border: solid $secondary;
112
+ }
113
+ #btn_row {
114
+ height: 3;
115
+ width: 100%;
116
+ align: center middle;
117
+ }
118
+ Button {
119
+ margin: 0 1;
120
+ }
121
+ """
122
+
123
+ def __init__(self, existing_accounts: List[Tuple[str, str]]):
124
+ """Init with list of (tag, address) tuples."""
125
+ super().__init__()
126
+ self.existing_accounts = existing_accounts
127
+
128
+ def compose(self) -> ComposeResult:
129
+ """Compose the modal UI."""
130
+ with Vertical(id="dialog"):
131
+ yield Label("Create New Safe Wallet", classes="header")
132
+
133
+ yield Label("Tag (Name):")
134
+ yield Input(placeholder="e.g. My Safe", id="tag_input")
135
+
136
+ yield Label("Threshold (Min signatures):")
137
+ yield Input(placeholder="1", id="threshold_input", type="integer")
138
+
139
+ yield Label("Owners (select multiple):")
140
+ options = [(f"{tag} ({addr})", addr) for tag, addr in self.existing_accounts]
141
+ yield SelectionList[str](*options, id="owners_list")
142
+
143
+ yield Label("Chains (select multiple):")
144
+ chain_options = [(name.title(), name) for name, _ in ChainInterfaces().items()]
145
+ yield SelectionList[str](*chain_options, id="chains_list")
146
+
147
+ with Horizontal(id="btn_row"):
148
+ yield Button("Cancel", id="cancel")
149
+ yield Button("Create", variant="primary", id="create")
150
+
151
+ def on_button_pressed(self, event: Button.Pressed) -> None:
152
+ """Handle button press."""
153
+ if event.button.id == "create":
154
+ tag = self.query_one("#tag_input", Input).value
155
+ threshold = int(self.query_one("#threshold_input", Input).value or "1")
156
+ owners = self.query_one("#owners_list", SelectionList).selected
157
+ chains = self.query_one("#chains_list", SelectionList).selected
158
+ self.dismiss({"tag": tag, "threshold": threshold, "owners": owners, "chains": chains})
159
+ elif event.button.id == "cancel":
160
+ self.dismiss(None)
161
+
162
+
163
+ class StakeServiceModal(ModalScreen):
164
+ """Modal screen for selecting a staking contract."""
165
+
166
+ CSS = """
167
+ StakeServiceModal {
168
+ align: center middle;
169
+ }
170
+ #dialog {
171
+ padding: 1 2;
172
+ width: 60;
173
+ height: auto;
174
+ border: thick $background 80%;
175
+ background: $surface;
176
+ }
177
+ #dialog Label {
178
+ width: 100%;
179
+ margin-bottom: 1;
180
+ }
181
+ .header {
182
+ text-align: center;
183
+ text-style: bold;
184
+ margin-bottom: 2;
185
+ }
186
+ Select {
187
+ width: 100%;
188
+ margin-bottom: 2;
189
+ }
190
+ #btn_row {
191
+ height: 3;
192
+ width: 100%;
193
+ align: center middle;
194
+ }
195
+ Button {
196
+ margin: 0 1;
197
+ }
198
+ """
199
+
200
+ def __init__(self, contracts: List[Tuple[str, str]]):
201
+ """Init with list of (name, address) tuples."""
202
+ super().__init__()
203
+ self.contracts = contracts
204
+
205
+ def compose(self) -> ComposeResult:
206
+ """Compose the modal UI."""
207
+ with Vertical(id="dialog"):
208
+ yield Label("Stake Service", classes="header")
209
+ yield Label("Select Staking Contract:")
210
+ options = [(name, addr) for name, addr in self.contracts]
211
+ yield Select(options, prompt="Select a contract...", id="contract_select")
212
+ with Horizontal(id="btn_row"):
213
+ yield Button("Cancel", id="cancel")
214
+ yield Button("Stake", variant="primary", id="stake")
215
+
216
+ def on_button_pressed(self, event: Button.Pressed) -> None:
217
+ """Handle button press."""
218
+ if event.button.id == "stake":
219
+ contract_address = self.query_one("#contract_select", Select).value
220
+ if contract_address == Select.BLANK:
221
+ return
222
+ self.dismiss(contract_address)
223
+ elif event.button.id == "cancel":
224
+ self.dismiss(None)
225
+
226
+
227
+ class CreateServiceModal(ModalScreen):
228
+ """Modal screen for creating a new Olas service."""
229
+
230
+ CSS = """
231
+ CreateServiceModal {
232
+ align: center middle;
233
+ }
234
+ #dialog {
235
+ padding: 1 2;
236
+ width: 65;
237
+ height: auto;
238
+ border: thick $background 80%;
239
+ background: $surface;
240
+ }
241
+ #dialog Label {
242
+ width: 100%;
243
+ margin-bottom: 1;
244
+ }
245
+ .header {
246
+ text-align: center;
247
+ text-style: bold;
248
+ margin-bottom: 2;
249
+ }
250
+ Input {
251
+ width: 100%;
252
+ margin-bottom: 2;
253
+ }
254
+ Select {
255
+ width: 100%;
256
+ margin-bottom: 2;
257
+ }
258
+ #btn_row {
259
+ height: 3;
260
+ width: 100%;
261
+ align: center middle;
262
+ }
263
+ Button {
264
+ margin: 0 1;
265
+ }
266
+ """
267
+
268
+ def __init__(
269
+ self,
270
+ chains: List[str],
271
+ default_chain: str = "gnosis",
272
+ staking_contracts: List[Tuple[str, str]] = None,
273
+ ):
274
+ """Init with list of available chains and staking contracts."""
275
+ super().__init__()
276
+ self.chains = chains
277
+ self.default_chain = default_chain
278
+ self.staking_contracts = staking_contracts or []
279
+
280
+ def compose(self) -> ComposeResult:
281
+ """Compose the modal UI."""
282
+ with Vertical(id="dialog"):
283
+ yield Label("Create New Olas Service", classes="header")
284
+
285
+ yield Label("Service Name:")
286
+ yield Input(placeholder="e.g. My Trader", id="name_input")
287
+
288
+ yield Label("Chain:")
289
+ chain_options = [(c.title(), c) for c in self.chains]
290
+ yield Select(chain_options, value=self.default_chain, id="chain_select")
291
+
292
+ yield Label("Agent Type:")
293
+ agent_options = [("Trader", "trader")]
294
+ yield Select(agent_options, value="trader", id="agent_type_select")
295
+
296
+ yield Label("Staking Contract:")
297
+ contract_options = [("None (don't stake)", "")]
298
+ contract_options.extend([(name, addr) for name, addr in self.staking_contracts])
299
+ yield Select(contract_options, value="", id="staking_select")
300
+
301
+ with Horizontal(id="btn_row"):
302
+ yield Button("Cancel", id="cancel")
303
+ yield Button("Create", variant="primary", id="create")
304
+
305
+ def on_button_pressed(self, event: Button.Pressed) -> None:
306
+ """Handle button press."""
307
+ if event.button.id == "create":
308
+ name = self.query_one("#name_input", Input).value
309
+ chain = self.query_one("#chain_select", Select).value
310
+ agent_type = self.query_one("#agent_type_select", Select).value
311
+ staking_contract = self.query_one("#staking_select", Select).value
312
+ if not name or chain == Select.BLANK:
313
+ return
314
+ self.dismiss(
315
+ {
316
+ "name": name,
317
+ "chain": chain,
318
+ "agent_type": agent_type if agent_type != Select.BLANK else "trader",
319
+ "staking_contract": staking_contract
320
+ if staking_contract != Select.BLANK
321
+ else None,
322
+ }
323
+ )
324
+ elif event.button.id == "cancel":
325
+ self.dismiss(None)
326
+
327
+
328
+ class FundServiceModal(ModalScreen):
329
+ """Modal screen for funding Olas service accounts."""
330
+
331
+ CSS = """
332
+ FundServiceModal {
333
+ align: center middle;
334
+ }
335
+ #dialog {
336
+ padding: 1 2;
337
+ width: 60;
338
+ height: auto;
339
+ border: thick $background 80%;
340
+ background: $surface;
341
+ }
342
+ #dialog Label {
343
+ width: 100%;
344
+ margin-bottom: 1;
345
+ }
346
+ .header {
347
+ text-align: center;
348
+ text-style: bold;
349
+ margin-bottom: 2;
350
+ }
351
+ .desc {
352
+ color: $text-muted;
353
+ margin-bottom: 2;
354
+ }
355
+ Input {
356
+ width: 100%;
357
+ margin-bottom: 2;
358
+ }
359
+ #btn_row {
360
+ height: 3;
361
+ width: 100%;
362
+ align: center middle;
363
+ }
364
+ Button {
365
+ margin: 0 1;
366
+ }
367
+ """
368
+
369
+ def __init__(self, service_key: str, native_symbol: str = "xDAI"):
370
+ """Init with service key and native currency symbol."""
371
+ super().__init__()
372
+ self.service_key = service_key
373
+ self.native_symbol = native_symbol
374
+
375
+ def compose(self) -> ComposeResult:
376
+ """Compose the modal UI."""
377
+ with Vertical(id="dialog"):
378
+ yield Label("Fund Service", classes="header")
379
+ yield Label(f"Send {self.native_symbol} from master wallet:", classes="desc")
380
+ yield Label(f"Agent Amount ({self.native_symbol}):")
381
+ yield Input(placeholder="0.0", id="agent_amount", type="number")
382
+ yield Label(f"Safe Amount ({self.native_symbol}):")
383
+ yield Input(placeholder="0.0", id="safe_amount", type="number")
384
+ with Horizontal(id="btn_row"):
385
+ yield Button("Cancel", id="cancel")
386
+ yield Button("Fund", variant="primary", id="fund")
387
+
388
+ def on_button_pressed(self, event: Button.Pressed) -> None:
389
+ """Handle button press."""
390
+ if event.button.id == "fund":
391
+ try:
392
+ agent_amount = float(self.query_one("#agent_amount", Input).value or "0")
393
+ safe_amount = float(self.query_one("#safe_amount", Input).value or "0")
394
+ except ValueError:
395
+ return
396
+ if agent_amount <= 0 and safe_amount <= 0:
397
+ return
398
+ self.dismiss(
399
+ {
400
+ "service_key": self.service_key,
401
+ "agent_amount": agent_amount,
402
+ "safe_amount": safe_amount,
403
+ }
404
+ )
405
+ elif event.button.id == "cancel":
406
+ self.dismiss(None)