olas-operate-middleware 0.1.0rc59__py3-none-any.whl → 0.13.2__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 (98) hide show
  1. olas_operate_middleware-0.13.2.dist-info/METADATA +75 -0
  2. olas_operate_middleware-0.13.2.dist-info/RECORD +101 -0
  3. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info}/WHEEL +1 -1
  4. operate/__init__.py +17 -0
  5. operate/account/user.py +35 -9
  6. operate/bridge/bridge_manager.py +470 -0
  7. operate/bridge/providers/lifi_provider.py +377 -0
  8. operate/bridge/providers/native_bridge_provider.py +677 -0
  9. operate/bridge/providers/provider.py +469 -0
  10. operate/bridge/providers/relay_provider.py +457 -0
  11. operate/cli.py +1565 -417
  12. operate/constants.py +60 -12
  13. operate/data/README.md +19 -0
  14. operate/data/contracts/{service_staking_token → dual_staking_token}/__init__.py +2 -2
  15. operate/data/contracts/dual_staking_token/build/DualStakingToken.json +443 -0
  16. operate/data/contracts/dual_staking_token/contract.py +132 -0
  17. operate/data/contracts/dual_staking_token/contract.yaml +23 -0
  18. operate/{ledger/base.py → data/contracts/foreign_omnibridge/__init__.py} +2 -19
  19. operate/data/contracts/foreign_omnibridge/build/ForeignOmnibridge.json +1372 -0
  20. operate/data/contracts/foreign_omnibridge/contract.py +130 -0
  21. operate/data/contracts/foreign_omnibridge/contract.yaml +23 -0
  22. operate/{ledger/solana.py → data/contracts/home_omnibridge/__init__.py} +2 -20
  23. operate/data/contracts/home_omnibridge/build/HomeOmnibridge.json +1421 -0
  24. operate/data/contracts/home_omnibridge/contract.py +80 -0
  25. operate/data/contracts/home_omnibridge/contract.yaml +23 -0
  26. operate/data/contracts/l1_standard_bridge/__init__.py +20 -0
  27. operate/data/contracts/l1_standard_bridge/build/L1StandardBridge.json +831 -0
  28. operate/data/contracts/l1_standard_bridge/contract.py +158 -0
  29. operate/data/contracts/l1_standard_bridge/contract.yaml +23 -0
  30. operate/data/contracts/l2_standard_bridge/__init__.py +20 -0
  31. operate/data/contracts/l2_standard_bridge/build/L2StandardBridge.json +626 -0
  32. operate/data/contracts/l2_standard_bridge/contract.py +130 -0
  33. operate/data/contracts/l2_standard_bridge/contract.yaml +23 -0
  34. operate/data/contracts/mech_activity/__init__.py +20 -0
  35. operate/data/contracts/mech_activity/build/MechActivity.json +111 -0
  36. operate/data/contracts/mech_activity/contract.py +44 -0
  37. operate/data/contracts/mech_activity/contract.yaml +23 -0
  38. operate/data/contracts/optimism_mintable_erc20/__init__.py +20 -0
  39. operate/data/contracts/optimism_mintable_erc20/build/OptimismMintableERC20.json +491 -0
  40. operate/data/contracts/optimism_mintable_erc20/contract.py +45 -0
  41. operate/data/contracts/optimism_mintable_erc20/contract.yaml +23 -0
  42. operate/data/contracts/recovery_module/__init__.py +20 -0
  43. operate/data/contracts/recovery_module/build/RecoveryModule.json +811 -0
  44. operate/data/contracts/recovery_module/contract.py +61 -0
  45. operate/data/contracts/recovery_module/contract.yaml +23 -0
  46. operate/data/contracts/requester_activity_checker/__init__.py +20 -0
  47. operate/data/contracts/requester_activity_checker/build/RequesterActivityChecker.json +111 -0
  48. operate/data/contracts/requester_activity_checker/contract.py +33 -0
  49. operate/data/contracts/requester_activity_checker/contract.yaml +23 -0
  50. operate/data/contracts/staking_token/__init__.py +20 -0
  51. operate/data/contracts/staking_token/build/StakingToken.json +1336 -0
  52. operate/data/contracts/{service_staking_token → staking_token}/contract.py +27 -13
  53. operate/data/contracts/staking_token/contract.yaml +23 -0
  54. operate/data/contracts/uniswap_v2_erc20/contract.yaml +3 -1
  55. operate/data/contracts/uniswap_v2_erc20/tests/__init__.py +20 -0
  56. operate/data/contracts/uniswap_v2_erc20/tests/test_contract.py +363 -0
  57. operate/keys.py +118 -33
  58. operate/ledger/__init__.py +159 -56
  59. operate/ledger/profiles.py +321 -18
  60. operate/migration.py +555 -0
  61. operate/{http → operate_http}/__init__.py +3 -2
  62. operate/{http → operate_http}/exceptions.py +6 -4
  63. operate/operate_types.py +544 -0
  64. operate/pearl.py +13 -1
  65. operate/quickstart/analyse_logs.py +118 -0
  66. operate/quickstart/claim_staking_rewards.py +104 -0
  67. operate/quickstart/reset_configs.py +106 -0
  68. operate/quickstart/reset_password.py +70 -0
  69. operate/quickstart/reset_staking.py +145 -0
  70. operate/quickstart/run_service.py +726 -0
  71. operate/quickstart/stop_service.py +72 -0
  72. operate/quickstart/terminate_on_chain_service.py +83 -0
  73. operate/quickstart/utils.py +298 -0
  74. operate/resource.py +62 -3
  75. operate/services/agent_runner.py +202 -0
  76. operate/services/deployment_runner.py +868 -0
  77. operate/services/funding_manager.py +929 -0
  78. operate/services/health_checker.py +280 -0
  79. operate/services/manage.py +2356 -620
  80. operate/services/protocol.py +1246 -340
  81. operate/services/service.py +756 -391
  82. operate/services/utils/mech.py +103 -0
  83. operate/services/utils/tendermint.py +86 -12
  84. operate/settings.py +70 -0
  85. operate/utils/__init__.py +135 -0
  86. operate/utils/gnosis.py +407 -80
  87. operate/utils/single_instance.py +226 -0
  88. operate/utils/ssl.py +133 -0
  89. operate/wallet/master.py +708 -123
  90. operate/wallet/wallet_recovery_manager.py +507 -0
  91. olas_operate_middleware-0.1.0rc59.dist-info/METADATA +0 -304
  92. olas_operate_middleware-0.1.0rc59.dist-info/RECORD +0 -41
  93. operate/data/contracts/service_staking_token/build/ServiceStakingToken.json +0 -1273
  94. operate/data/contracts/service_staking_token/contract.yaml +0 -23
  95. operate/ledger/ethereum.py +0 -48
  96. operate/types.py +0 -260
  97. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info}/entry_points.txt +0 -0
  98. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info/licenses}/LICENSE +0 -0
operate/cli.py CHANGED
@@ -18,107 +18,320 @@
18
18
  # ------------------------------------------------------------------------------
19
19
 
20
20
  """Operate app CLI module."""
21
-
22
21
  import asyncio
23
- import logging
22
+ import atexit
23
+ import multiprocessing
24
24
  import os
25
+ import shutil
25
26
  import signal
26
27
  import traceback
27
28
  import typing as t
28
29
  import uuid
30
+ from concurrent.futures import ThreadPoolExecutor
31
+ from contextlib import asynccontextmanager, suppress
32
+ from http import HTTPStatus
29
33
  from pathlib import Path
34
+ from time import time
35
+ from types import FrameType
30
36
 
37
+ import autonomy.chain.tx
31
38
  from aea.helpers.logging import setup_logger
32
39
  from clea import group, params, run
33
- from compose.project import ProjectError
34
- from docker.errors import APIError
35
40
  from fastapi import FastAPI, Request
36
41
  from fastapi.middleware.cors import CORSMiddleware
37
42
  from fastapi.responses import JSONResponse
38
43
  from typing_extensions import Annotated
39
- from uvicorn.main import run as uvicorn
44
+ from uvicorn.config import Config
45
+ from uvicorn.server import Server
40
46
 
41
- from operate import services
47
+ from operate import __version__, services
42
48
  from operate.account.user import UserAccount
43
- from operate.constants import KEY, KEYS, OPERATE, SERVICES
44
- from operate.ledger import get_ledger_type_from_chain_type
45
- from operate.types import ChainType, DeploymentStatus
46
- from operate.wallet.master import MasterWalletManager
49
+ from operate.bridge.bridge_manager import BridgeManager
50
+ from operate.constants import (
51
+ AGENT_RUNNER_PREFIX,
52
+ DEPLOYMENT_DIR,
53
+ KEYS_DIR,
54
+ MIN_PASSWORD_LENGTH,
55
+ MSG_INVALID_MNEMONIC,
56
+ MSG_INVALID_PASSWORD,
57
+ MSG_NEW_PASSWORD_MISSING,
58
+ OPERATE,
59
+ OPERATE_HOME,
60
+ SERVICES_DIR,
61
+ USER_JSON,
62
+ VERSION_FILE,
63
+ WALLETS_DIR,
64
+ WALLET_RECOVERY_DIR,
65
+ ZERO_ADDRESS,
66
+ )
67
+ from operate.keys import KeysManager
68
+ from operate.ledger.profiles import (
69
+ DEFAULT_EOA_TOPUPS,
70
+ DEFAULT_NEW_SAFE_FUNDS,
71
+ ERC20_TOKENS,
72
+ )
73
+ from operate.migration import MigrationManager
74
+ from operate.operate_types import (
75
+ Chain,
76
+ ChainAmounts,
77
+ DeploymentStatus,
78
+ LedgerType,
79
+ Version,
80
+ )
81
+ from operate.quickstart.analyse_logs import analyse_logs
82
+ from operate.quickstart.claim_staking_rewards import claim_staking_rewards
83
+ from operate.quickstart.reset_configs import reset_configs
84
+ from operate.quickstart.reset_password import reset_password
85
+ from operate.quickstart.reset_staking import reset_staking
86
+ from operate.quickstart.run_service import run_service
87
+ from operate.quickstart.stop_service import stop_service
88
+ from operate.quickstart.terminate_on_chain_service import terminate_service
89
+ from operate.services.deployment_runner import stop_deployment_manager
90
+ from operate.services.funding_manager import FundingInProgressError, FundingManager
91
+ from operate.services.health_checker import HealthChecker
92
+ from operate.settings import Settings
93
+ from operate.utils import subtract_dicts
94
+ from operate.utils.gnosis import get_assets_balances
95
+ from operate.utils.single_instance import AppSingleInstance, ParentWatchdog
96
+ from operate.wallet.master import InsufficientFundsException, MasterWalletManager
97
+ from operate.wallet.wallet_recovery_manager import (
98
+ WalletRecoveryError,
99
+ WalletRecoveryManager,
100
+ )
101
+
102
+
103
+ # TODO Backport to Open Autonomy
104
+ def should_rebuild(error: str) -> bool:
105
+ """Check if we should rebuild the transaction."""
106
+ for _error in (
107
+ "wrong transaction nonce",
108
+ "OldNonce",
109
+ "nonce too low",
110
+ "replacement transaction underpriced",
111
+ ):
112
+ if _error in error:
113
+ return True
114
+ return False
115
+
116
+
117
+ autonomy.chain.tx.ERRORS_TO_RETRY += ("replacement transaction underpriced",)
118
+ autonomy.chain.tx.should_rebuild = should_rebuild
119
+ # End backport to Open Autonomy
47
120
 
48
121
 
49
- DEFAULT_HARDHAT_KEY = (
50
- "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
51
- ).encode()
52
122
  DEFAULT_MAX_RETRIES = 3
53
123
  USER_NOT_LOGGED_IN_ERROR = JSONResponse(
54
- content={"error": "User not logged in!"}, status_code=401
124
+ content={"error": "User not logged in."}, status_code=HTTPStatus.UNAUTHORIZED
125
+ )
126
+ USER_LOGGED_IN_ERROR = JSONResponse(
127
+ content={"error": "User must be logged out to perform this operation."},
128
+ status_code=HTTPStatus.FORBIDDEN,
55
129
  )
130
+ ACCOUNT_NOT_FOUND_ERROR = JSONResponse(
131
+ content={"error": "User account not found."},
132
+ status_code=HTTPStatus.NOT_FOUND,
133
+ )
134
+ TRY_TO_SHUTDOWN_PREVIOUS_INSTANCE = True
135
+
136
+ logger = setup_logger(name="operate")
56
137
 
57
138
 
58
- def service_not_found_error(service: str) -> JSONResponse:
139
+ def service_not_found_error(service_config_id: str) -> JSONResponse:
59
140
  """Service not found error response"""
60
141
  return JSONResponse(
61
- content={"error": f"Service {service} not found"}, status_code=404
142
+ content={"error": f"Service {service_config_id} not found"},
143
+ status_code=HTTPStatus.NOT_FOUND,
62
144
  )
63
145
 
64
146
 
65
- class OperateApp:
147
+ class OperateApp: # pylint: disable=too-many-instance-attributes
66
148
  """Operate app."""
67
149
 
68
150
  def __init__(
69
151
  self,
70
152
  home: t.Optional[Path] = None,
71
- logger: t.Optional[logging.Logger] = None,
72
153
  ) -> None:
73
154
  """Initialize object."""
74
- super().__init__()
75
- self._path = (home or (Path.cwd() / OPERATE)).resolve()
76
- self._services = self._path / SERVICES
77
- self._keys = self._path / KEYS
78
- self._master_key = self._path / KEY
155
+ self._path = (home or OPERATE_HOME).resolve()
156
+ self._services = self._path / SERVICES_DIR
157
+ self._keys = self._path / KEYS_DIR
79
158
  self.setup()
159
+ self._backup_operate_if_new_version()
80
160
 
81
- self.logger = logger or setup_logger(name="operate")
82
- self.keys_manager = services.manage.KeysManager(
161
+ self._password: t.Optional[str] = os.environ.get("OPERATE_USER_PASSWORD")
162
+ self._keys_manager: KeysManager = KeysManager(
83
163
  path=self._keys,
84
- logger=self.logger,
164
+ logger=logger,
165
+ password=self._password,
166
+ )
167
+ self.settings = Settings(path=self._path)
168
+
169
+ self._wallet_manager = MasterWalletManager(
170
+ path=self._path / WALLETS_DIR,
171
+ password=self.password,
172
+ )
173
+ self._wallet_manager.setup()
174
+ self._funding_manager = FundingManager(
175
+ keys_manager=self._keys_manager,
176
+ wallet_manager=self._wallet_manager,
177
+ logger=logger,
85
178
  )
86
- self.password: t.Optional[str] = os.environ.get("OPERATE_USER_PASSWORD")
179
+
180
+ self._migration_manager = MigrationManager(self._path, logger)
181
+ self._migration_manager.migrate_user_account()
182
+ self._migration_manager.migrate_services(self.service_manager())
183
+ self._migration_manager.migrate_wallets(self.wallet_manager)
184
+ self._migration_manager.migrate_qs_configs()
185
+
186
+ @property
187
+ def password(self) -> t.Optional[str]:
188
+ """Get the password."""
189
+ return self._password
190
+
191
+ @password.setter
192
+ def password(self, value: t.Optional[str]) -> None:
193
+ """Set the password."""
194
+ self._password = value
195
+ self._keys_manager.password = value
196
+ self._wallet_manager.password = value
197
+ self._migration_manager.migrate_keys(self._keys_manager)
198
+
199
+ def _backup_operate_if_new_version(self) -> None:
200
+ """Backup .operate directory if this is a new version."""
201
+ current_version = Version(__version__)
202
+ backup_required = False
203
+ version_file = self._path / VERSION_FILE
204
+ if not version_file.exists():
205
+ backup_required = True
206
+ found_version = "0.10.21" # first version with version file
207
+ else:
208
+ found_version = Version(version_file.read_text())
209
+ if current_version.major > found_version.major or (
210
+ current_version.major == found_version.major
211
+ and current_version.minor > found_version.minor
212
+ ):
213
+ backup_required = True
214
+
215
+ if not backup_required:
216
+ return
217
+
218
+ backup_path = self._path.parent / f"{OPERATE}_v{found_version}_bak"
219
+ if backup_path.exists():
220
+ logger.info(f"Backup directory {backup_path} already exists.")
221
+ backup_path = (
222
+ self._path.parent / f"{OPERATE}_v{found_version}_bak_{int(time())}"
223
+ )
224
+
225
+ logger.info(f"Backing up existing {OPERATE} directory to {backup_path}")
226
+ shutil.copytree(self._path, backup_path, ignore_dangling_symlinks=True)
227
+ version_file.write_text(str(current_version))
228
+
229
+ # remove recoverable files from the backup to save space
230
+ service_dir = backup_path / SERVICES_DIR
231
+ for service_path in service_dir.iterdir():
232
+ deployment_dir = service_path / DEPLOYMENT_DIR
233
+ if deployment_dir.exists():
234
+ shutil.rmtree(deployment_dir)
235
+
236
+ for agent_runner_path in service_path.glob(f"{AGENT_RUNNER_PREFIX}*"):
237
+ agent_runner_path.unlink()
238
+
239
+ logger.info("Backup completed.")
87
240
 
88
241
  def create_user_account(self, password: str) -> UserAccount:
89
242
  """Create a user account."""
90
243
  self.password = password
91
244
  return UserAccount.new(
92
245
  password=password,
93
- path=self._path / "user.json",
246
+ path=self._path / USER_JSON,
94
247
  )
95
248
 
96
- def service_manager(self) -> services.manage.ServiceManager:
249
+ def update_password(self, old_password: str, new_password: str) -> None:
250
+ """Updates current password"""
251
+
252
+ if not new_password:
253
+ raise ValueError(MSG_NEW_PASSWORD_MISSING)
254
+
255
+ if not (
256
+ self.user_account.is_valid(old_password)
257
+ and self.wallet_manager.is_password_valid(old_password)
258
+ ):
259
+ raise ValueError(MSG_INVALID_PASSWORD)
260
+
261
+ wallet_manager = self.wallet_manager
262
+ wallet_manager.password = old_password
263
+ wallet_manager.update_password(new_password)
264
+ self._keys_manager.update_password(new_password)
265
+ self.user_account.update(old_password, new_password)
266
+
267
+ def update_password_with_mnemonic(self, mnemonic: str, new_password: str) -> None:
268
+ """Updates current password using the mnemonic"""
269
+
270
+ if not new_password:
271
+ raise ValueError(MSG_NEW_PASSWORD_MISSING)
272
+
273
+ mnemonic = mnemonic.strip().lower()
274
+ if not self.wallet_manager.is_mnemonic_valid(mnemonic):
275
+ raise ValueError(MSG_INVALID_MNEMONIC)
276
+
277
+ wallet_manager = self.wallet_manager
278
+ wallet_manager.update_password_with_mnemonic(mnemonic, new_password)
279
+ self.user_account.force_update(new_password)
280
+
281
+ def service_manager(
282
+ self, skip_dependency_check: t.Optional[bool] = False
283
+ ) -> services.manage.ServiceManager:
97
284
  """Load service manager."""
98
285
  return services.manage.ServiceManager(
99
286
  path=self._services,
100
287
  keys_manager=self.keys_manager,
101
288
  wallet_manager=self.wallet_manager,
102
- logger=self.logger,
289
+ funding_manager=self.funding_manager,
290
+ logger=logger,
291
+ skip_dependency_check=skip_dependency_check,
103
292
  )
104
293
 
294
+ @property
295
+ def funding_manager(self) -> FundingManager:
296
+ """Load funding manager."""
297
+ return self._funding_manager
298
+
105
299
  @property
106
300
  def user_account(self) -> t.Optional[UserAccount]:
107
301
  """Load user account."""
108
- return (
109
- UserAccount.load(self._path / "user.json")
110
- if (self._path / "user.json").exists()
111
- else None
112
- )
302
+ if (self._path / USER_JSON).exists():
303
+ return UserAccount.load(self._path / USER_JSON)
304
+ return None
305
+
306
+ @property
307
+ def keys_manager(self) -> KeysManager:
308
+ """Load keys manager."""
309
+ return self._keys_manager
113
310
 
114
311
  @property
115
312
  def wallet_manager(self) -> MasterWalletManager:
116
- """Load master wallet."""
117
- manager = MasterWalletManager(
118
- path=self._path / "wallets",
119
- password=self.password,
313
+ """Load wallet manager."""
314
+ return self._wallet_manager
315
+
316
+ @property
317
+ def wallet_recovery_manager(self) -> WalletRecoveryManager:
318
+ """Load wallet recovery manager."""
319
+ manager = WalletRecoveryManager(
320
+ path=self._path / WALLET_RECOVERY_DIR,
321
+ wallet_manager=self.wallet_manager,
322
+ service_manager=self.service_manager(),
323
+ logger=logger,
324
+ )
325
+ return manager
326
+
327
+ @property
328
+ def bridge_manager(self) -> BridgeManager:
329
+ """Load bridge manager."""
330
+ manager = BridgeManager(
331
+ path=self._path / "bridge",
332
+ wallet_manager=self.wallet_manager,
333
+ logger=logger,
120
334
  )
121
- manager.setup()
122
335
  return manager
123
336
 
124
337
  def setup(self) -> None:
@@ -132,7 +345,7 @@ class OperateApp:
132
345
  """Json representation of the app."""
133
346
  return {
134
347
  "name": "Operate HTTP server",
135
- "version": "0.1.0.rc0",
348
+ "version": (__version__),
136
349
  "home": str(self._path),
137
350
  }
138
351
 
@@ -141,207 +354,304 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
141
354
  home: t.Optional[Path] = None,
142
355
  ) -> FastAPI:
143
356
  """Create FastAPI object."""
357
+ HEALTH_CHECKER_OFF = os.environ.get("HEALTH_CHECKER_OFF", "0") == "1"
358
+ number_of_fails = int(
359
+ os.environ.get(
360
+ "HEALTH_CHECKER_TRIES", str(HealthChecker.NUMBER_OF_FAILS_DEFAULT)
361
+ )
362
+ )
144
363
 
145
- logger = setup_logger(name="operate")
146
- operate = OperateApp(home=home, logger=logger)
147
- funding_jobs: t.Dict[str, asyncio.Task] = {}
148
- healthcheck_jobs: t.Dict[str, asyncio.Task] = {}
364
+ if HEALTH_CHECKER_OFF:
365
+ logger.warning("Healthchecker is off!!!")
366
+ operate = OperateApp(home=home)
149
367
 
368
+ funding_job: t.Optional[asyncio.Task] = None
369
+ health_checker = HealthChecker(
370
+ operate.service_manager(), number_of_fails=number_of_fails, logger=logger
371
+ )
150
372
  # Create shutdown endpoint
151
373
  shutdown_endpoint = uuid.uuid4().hex
152
374
  (operate._path / "operate.kill").write_text( # pylint: disable=protected-access
153
375
  shutdown_endpoint
154
376
  )
377
+ thread_pool_executor = ThreadPoolExecutor(max_workers=12)
155
378
 
156
- def schedule_funding_job(
157
- service: str,
158
- from_safe: bool = True,
159
- ) -> None:
160
- """Schedule a funding job."""
161
- logger.info(f"Starting funding job for {service}")
162
- if service in funding_jobs:
163
- logger.info(f"Cancelling existing funding job for {service}")
164
- cancel_funding_job(service=service)
165
-
166
- loop = asyncio.get_running_loop()
167
- funding_jobs[service] = loop.create_task(
168
- operate.service_manager().funding_job(
169
- hash=service,
170
- loop=loop,
171
- from_safe=from_safe,
172
- )
173
- )
379
+ async def run_in_executor(fn: t.Callable, *args: t.Any) -> t.Any:
380
+ loop = asyncio.get_event_loop()
381
+ future = loop.run_in_executor(thread_pool_executor, fn, *args)
382
+ res = await future
383
+ exception = future.exception()
384
+ if exception is not None:
385
+ raise exception
386
+ return res
174
387
 
175
388
  def schedule_healthcheck_job(
176
- service: str,
389
+ service_config_id: str,
177
390
  ) -> None:
178
391
  """Schedule a healthcheck job."""
179
- logger.info(f"Starting healthcheck job for {service}")
180
- if service in healthcheck_jobs:
181
- logger.info(f"Cancelling existing healthcheck_jobs job for {service}")
182
- cancel_healthcheck_job(service=service)
183
-
184
- loop = asyncio.get_running_loop()
185
- healthcheck_jobs[service] = loop.create_task(
186
- operate.service_manager().healthcheck_job(
187
- hash=service,
392
+ if not HEALTH_CHECKER_OFF:
393
+ # dont start health checker if it's switched off
394
+ health_checker.start_for_service(service_config_id)
395
+
396
+ def schedule_funding_job() -> None:
397
+ """Schedule the funding job."""
398
+ cancel_funding_job() # cancel previous job if any
399
+ logger.info("Starting the funding job")
400
+
401
+ loop = asyncio.get_event_loop()
402
+ nonlocal funding_job
403
+ funding_job = loop.create_task(
404
+ operate.funding_manager.funding_job(
405
+ service_manager=operate.service_manager(),
406
+ loop=loop,
188
407
  )
189
408
  )
190
409
 
191
- def cancel_funding_job(service: str) -> None:
410
+ def cancel_funding_job() -> None:
192
411
  """Cancel funding job."""
193
- if service not in funding_jobs:
412
+ nonlocal funding_job
413
+ if funding_job is None:
194
414
  return
195
- status = funding_jobs[service].cancel()
415
+
416
+ status = funding_job.cancel()
196
417
  if not status:
197
- logger.info(f"Funding job cancellation for {service} failed")
418
+ logger.info("Funding job cancellation failed")
198
419
 
199
420
  def pause_all_services_on_startup() -> None:
200
421
  logger.info("Stopping services on startup...")
201
- service_hashes = [i["hash"] for i in operate.service_manager().json]
422
+ pause_all_services()
423
+ logger.info("Stopping services on startup done.")
424
+
425
+ def pause_all_services() -> None:
426
+ service_manager = operate.service_manager()
427
+ if not service_manager.validate_services():
428
+ logger.error(
429
+ "Some services are not valid. Only pausing the valid services."
430
+ )
431
+
432
+ service_config_ids = [
433
+ i["service_config_id"] for i in operate.service_manager().json
434
+ ]
202
435
 
203
- for service in service_hashes:
204
- if not operate.service_manager().exists(service=service):
436
+ for service_config_id in service_config_ids:
437
+ logger.info(f"Stopping service {service_config_id=}")
438
+ if not operate.service_manager().exists(
439
+ service_config_id=service_config_id
440
+ ):
205
441
  continue
206
- deployment = operate.service_manager().create_or_load(service).deployment
442
+ deployment = (
443
+ operate.service_manager()
444
+ .load(service_config_id=service_config_id)
445
+ .deployment
446
+ )
207
447
  if deployment.status == DeploymentStatus.DELETED:
208
448
  continue
209
- logger.info(f"stopping service {service}")
210
- deployment.stop(force=True)
211
- logger.info(f"Cancelling funding job for {service}")
212
- cancel_funding_job(service=service)
213
- logger.info("Stopping services on startup done.")
449
+ logger.info(f"stopping service {service_config_id}")
450
+ try:
451
+ deployment.stop(force=True)
452
+ except Exception: # pylint: disable=broad-except
453
+ logger.exception(
454
+ f"Deployment {service_config_id} stopping failed. but continue"
455
+ )
456
+ logger.info(f"Cancelling funding job for {service_config_id}")
457
+ health_checker.stop_for_service(service_config_id=service_config_id)
214
458
 
215
- def cancel_healthcheck_job(service: str) -> None:
216
- """Cancel healthcheck job."""
217
- if service not in healthcheck_jobs:
218
- return
219
- status = healthcheck_jobs[service].cancel()
220
- if not status:
221
- logger.info(f"Healthcheck job cancellation for {service} failed")
459
+ def pause_all_services_on_exit(signum: int, frame: t.Optional[FrameType]) -> None:
460
+ logger.info("Stopping services on exit...")
461
+ pause_all_services()
462
+ logger.info("Stopping services on exit done.")
463
+
464
+ signal.signal(signal.SIGINT, pause_all_services_on_exit)
465
+ signal.signal(signal.SIGTERM, pause_all_services_on_exit)
222
466
 
223
467
  # on backend app started we assume there are now started agents, so we force to pause all
224
468
  pause_all_services_on_startup()
225
469
 
226
- app = FastAPI()
470
+ # stop all services at middleware exit
471
+ atexit.register(pause_all_services)
472
+
473
+ @asynccontextmanager
474
+ async def lifespan(app: FastAPI):
475
+ async def stop_app():
476
+ logger.info("Stopping services due to parent death...")
477
+ stop_deployment_manager()
478
+ await run_in_executor(pause_all_services)
479
+ app._server.should_exit = True # pylint: disable=protected-access
480
+ logger.info("App stopped due to parent death.")
481
+
482
+ watchdog = ParentWatchdog(on_parent_exit=stop_app)
483
+ watchdog.start()
484
+
485
+ yield # --- app is running ---
486
+
487
+ with suppress(Exception):
488
+ cancel_funding_job()
489
+
490
+ with suppress(Exception):
491
+ await watchdog.stop()
492
+
493
+ app = FastAPI(lifespan=lifespan)
227
494
 
228
495
  app.add_middleware(
229
496
  CORSMiddleware,
230
497
  allow_origins=["*"],
231
- allow_methods=["GET", "POST", "PUT", "DELETE"],
498
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
232
499
  )
233
500
 
234
- def with_retries(f: t.Callable) -> t.Callable:
235
- """Retries decorator."""
236
-
237
- async def _call(request: Request) -> JSONResponse:
238
- """Call the endpoint."""
239
- logger.info(f"Calling `{f.__name__}` with retries enabled")
240
- retries = 0
241
- errors = []
242
- while retries < DEFAULT_MAX_RETRIES:
243
- try:
244
- return await f(request)
245
- except (APIError, ProjectError) as e:
246
- logger.error(f"Error {e}\n{traceback.format_exc()}")
247
- error = {"traceback": traceback.format_exc()}
248
- if "has active endpoints" in e.explanation:
249
- error["error"] = "Service is already running"
250
- else:
251
- error["error"] = str(e)
252
- errors.append(error)
253
- return JSONResponse(content={"errors": errors}, status_code=500)
254
- except Exception as e: # pylint: disable=broad-except
255
- errors.append(
256
- {"error": str(e.args[0]), "traceback": traceback.format_exc()}
257
- )
258
- logger.error(f"Error {str(e.args[0])}\n{traceback.format_exc()}")
259
- retries += 1
260
- return JSONResponse(content={"errors": errors}, status_code=500)
261
-
262
- return _call
501
+ @app.middleware("http")
502
+ async def handle_internal_server_error(request: Request, call_next):
503
+ try:
504
+ response = await call_next(request)
505
+ except Exception as e: # pylint: disable=broad-except
506
+ logger.error(f"Error {str(e)}\n{traceback.format_exc()}")
507
+ return JSONResponse(
508
+ content={"error": str(e)},
509
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
510
+ )
511
+ return response
263
512
 
264
513
  @app.get(f"/{shutdown_endpoint}")
265
514
  async def _kill_server(request: Request) -> JSONResponse:
266
515
  """Kill backend server from inside."""
267
516
  os.kill(os.getpid(), signal.SIGINT)
517
+ return JSONResponse(content={})
518
+
519
+ @app.get("/shutdown")
520
+ async def _shutdown(request: Request) -> JSONResponse:
521
+ """Kill backend server from inside."""
522
+ logger.info("Stopping services on demand...")
523
+ await run_in_executor(pause_all_services)
524
+ logger.info("Stopping services on demand done.")
525
+ app._server.should_exit = True # pylint: disable=protected-access
526
+ await asyncio.sleep(0.3)
527
+ return JSONResponse(content={"stopped": True})
268
528
 
269
529
  @app.get("/api")
270
- @with_retries
271
530
  async def _get_api(request: Request) -> JSONResponse:
272
531
  """Get API info."""
273
532
  return JSONResponse(content=operate.json)
274
533
 
534
+ @app.get("/api/settings")
535
+ async def _get_settings(request: Request) -> JSONResponse:
536
+ """Get settings."""
537
+ return JSONResponse(content=operate.settings.json)
538
+
275
539
  @app.get("/api/account")
276
- @with_retries
277
540
  async def _get_account(request: Request) -> t.Dict:
278
541
  """Get account information."""
279
542
  return {"is_setup": operate.user_account is not None}
280
543
 
281
544
  @app.post("/api/account")
282
- @with_retries
283
545
  async def _setup_account(request: Request) -> t.Dict:
284
546
  """Setup account."""
285
547
  if operate.user_account is not None:
286
548
  return JSONResponse(
287
- content={"error": "Account already exists"},
288
- status_code=400,
549
+ content={"error": "Account already exists."},
550
+ status_code=HTTPStatus.CONFLICT,
289
551
  )
290
552
 
291
- data = await request.json()
292
- operate.create_user_account(
293
- password=data["password"],
294
- )
553
+ password = (await request.json()).get("password")
554
+ if not password or len(password) < MIN_PASSWORD_LENGTH:
555
+ return JSONResponse(
556
+ content={
557
+ "error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
558
+ },
559
+ status_code=HTTPStatus.BAD_REQUEST,
560
+ )
561
+
562
+ operate.create_user_account(password=password)
295
563
  return JSONResponse(content={"error": None})
296
564
 
297
565
  @app.put("/api/account")
298
- @with_retries
299
- async def _update_password(request: Request) -> t.Dict:
566
+ async def _update_password( # pylint: disable=too-many-return-statements
567
+ request: Request,
568
+ ) -> t.Dict:
300
569
  """Update password."""
301
570
  if operate.user_account is None:
571
+ return ACCOUNT_NOT_FOUND_ERROR
572
+
573
+ data = await request.json()
574
+ old_password = data.get("old_password")
575
+ new_password = data.get("new_password")
576
+ mnemonic = data.get("mnemonic")
577
+
578
+ if not old_password and not mnemonic:
579
+ return JSONResponse(
580
+ content={
581
+ "error": "Exactly one of 'old_password' or 'mnemonic' (seed phrase) is required.",
582
+ },
583
+ status_code=HTTPStatus.BAD_REQUEST,
584
+ )
585
+
586
+ if old_password and mnemonic:
302
587
  return JSONResponse(
303
- content={"error": "Account does not exist"},
304
- status_code=400,
588
+ content={
589
+ "error": "Exactly one of 'old_password' or 'mnemonic' (seed phrase) is required.",
590
+ },
591
+ status_code=HTTPStatus.BAD_REQUEST,
592
+ )
593
+
594
+ if not new_password or len(new_password) < MIN_PASSWORD_LENGTH:
595
+ return JSONResponse(
596
+ content={
597
+ "error": f"New password must be at least {MIN_PASSWORD_LENGTH} characters long."
598
+ },
599
+ status_code=HTTPStatus.BAD_REQUEST,
305
600
  )
306
601
 
307
- data = await request.json()
308
602
  try:
309
- operate.user_account.update(
310
- old_password=data["old_password"],
311
- new_password=data["new_password"],
603
+ if old_password:
604
+ operate.update_password(old_password, new_password)
605
+ return JSONResponse(
606
+ content={"error": None, "message": "Password updated successfully."}
607
+ )
608
+ if mnemonic:
609
+ operate.update_password_with_mnemonic(mnemonic, new_password)
610
+ return JSONResponse(
611
+ content={
612
+ "error": None,
613
+ "message": "Password updated successfully using seed phrase.",
614
+ }
615
+ )
616
+
617
+ return JSONResponse(
618
+ content={"error": "Password update failed."},
619
+ status_code=HTTPStatus.BAD_REQUEST,
312
620
  )
313
- return JSONResponse(content={"error": None})
314
621
  except ValueError as e:
622
+ logger.error(f"Password update error: {e}\n{traceback.format_exc()}")
623
+ return JSONResponse(
624
+ content={"error": f"Failed to update password: {str(e)}"},
625
+ status_code=HTTPStatus.BAD_REQUEST,
626
+ )
627
+ except Exception as e: # pylint: disable=broad-except
628
+ logger.error(f"Password update error: {e}\n{traceback.format_exc()}")
315
629
  return JSONResponse(
316
- content={"error": str(e), "traceback": traceback.format_exc()},
317
- status_code=400,
630
+ content={"error": "Failed to update password. Please check the logs."},
631
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
318
632
  )
319
633
 
320
634
  @app.post("/api/account/login")
321
- @with_retries
322
635
  async def _validate_password(request: Request) -> t.Dict:
323
636
  """Validate password."""
324
637
  if operate.user_account is None:
325
- return JSONResponse(
326
- content={"error": "Account does not exist"},
327
- status_code=400,
328
- )
638
+ return ACCOUNT_NOT_FOUND_ERROR
329
639
 
330
640
  data = await request.json()
331
641
  if not operate.user_account.is_valid(password=data["password"]):
332
642
  return JSONResponse(
333
- content={"error": "Password is not valid"},
334
- status_code=401,
643
+ content={"error": MSG_INVALID_PASSWORD},
644
+ status_code=HTTPStatus.UNAUTHORIZED,
335
645
  )
336
646
 
337
647
  operate.password = data["password"]
648
+ schedule_funding_job()
338
649
  return JSONResponse(
339
- content={"message": "Login successful"},
340
- status_code=200,
650
+ content={"message": "Login successful."},
651
+ status_code=HTTPStatus.OK,
341
652
  )
342
653
 
343
654
  @app.get("/api/wallet")
344
- @with_retries
345
655
  async def _get_wallets(request: Request) -> t.List[t.Dict]:
346
656
  """Get wallets."""
347
657
  wallets = []
@@ -349,42 +659,17 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
349
659
  wallets.append(wallet.json)
350
660
  return JSONResponse(content=wallets)
351
661
 
352
- @app.get("/api/wallet/{chain}")
353
- @with_retries
354
- async def _get_wallet_by_chain(request: Request) -> t.List[t.Dict]:
355
- """Create wallet safe"""
356
- ledger_type = get_ledger_type_from_chain_type(
357
- chain=ChainType.from_string(request.path_params["chain"])
358
- )
359
- manager = operate.wallet_manager
360
- if not manager.exists(ledger_type=ledger_type):
361
- return JSONResponse(
362
- content={"error": "Wallet does not exist"},
363
- status_code=404,
364
- )
365
- return JSONResponse(
366
- content=manager.load(ledger_type=ledger_type).json,
367
- )
368
-
369
662
  @app.post("/api/wallet")
370
- @with_retries
371
663
  async def _create_wallet(request: Request) -> t.List[t.Dict]:
372
664
  """Create wallet"""
373
665
  if operate.user_account is None:
374
- return JSONResponse(
375
- content={"error": "Cannot create wallet; User account does not exist!"},
376
- status_code=400,
377
- )
666
+ return ACCOUNT_NOT_FOUND_ERROR
378
667
 
379
668
  if operate.password is None:
380
- return JSONResponse(
381
- content={"error": "You need to login before creating a wallet"},
382
- status_code=401,
383
- )
669
+ return USER_NOT_LOGGED_IN_ERROR
384
670
 
385
671
  data = await request.json()
386
- chain_type = ChainType(data["chain_type"])
387
- ledger_type = get_ledger_type_from_chain_type(chain=chain_type)
672
+ ledger_type = LedgerType(data["ledger_type"])
388
673
  manager = operate.wallet_manager
389
674
  if manager.exists(ledger_type=ledger_type):
390
675
  return JSONResponse(
@@ -396,326 +681,970 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
396
681
  wallet, mnemonic = manager.create(ledger_type=ledger_type)
397
682
  return JSONResponse(content={"wallet": wallet.json, "mnemonic": mnemonic})
398
683
 
684
+ @app.post("/api/wallet/private_key")
685
+ async def _get_private_key(request: Request) -> t.List[t.Dict]:
686
+ """Get Master EOA private key."""
687
+ if operate.user_account is None:
688
+ return ACCOUNT_NOT_FOUND_ERROR
689
+
690
+ data = await request.json()
691
+ password = data.get("password")
692
+ if operate.password is None:
693
+ return USER_NOT_LOGGED_IN_ERROR
694
+ if operate.password != password:
695
+ return JSONResponse(
696
+ content={"error": MSG_INVALID_PASSWORD},
697
+ status_code=HTTPStatus.UNAUTHORIZED,
698
+ )
699
+
700
+ # TODO Should fail if not provided
701
+ ledger_type = data.get("ledger_type", LedgerType.ETHEREUM.value)
702
+ wallet = operate.wallet_manager.load(ledger_type=LedgerType(ledger_type))
703
+ return JSONResponse(content={"private_key": wallet.crypto.private_key})
704
+
705
+ @app.post("/api/wallet/mnemonic")
706
+ async def _get_mnemonic(request: Request) -> t.List[t.Dict]:
707
+ """Get Master EOA mnemonic."""
708
+ if operate.user_account is None:
709
+ return ACCOUNT_NOT_FOUND_ERROR
710
+
711
+ data = await request.json()
712
+ password = data.get("password")
713
+ if operate.password is None:
714
+ return USER_NOT_LOGGED_IN_ERROR
715
+ if operate.password != password:
716
+ return JSONResponse(
717
+ content={"error": "Password is not valid."},
718
+ status_code=HTTPStatus.UNAUTHORIZED,
719
+ )
720
+
721
+ try:
722
+ ledger_type = LedgerType(data.get("ledger_type"))
723
+ wallet = operate.wallet_manager.load(ledger_type=ledger_type)
724
+ mnemonic = wallet.decrypt_mnemonic(password=password)
725
+ if mnemonic is None:
726
+ return JSONResponse(
727
+ content={"error": "Mnemonic file does not exist."},
728
+ status_code=HTTPStatus.NOT_FOUND,
729
+ )
730
+ return JSONResponse(content={"mnemonic": mnemonic})
731
+ except Exception as e: # pylint: disable=broad-except
732
+ logger.error(f"Failed to retrieve mnemonic: {e}\n{traceback.format_exc()}")
733
+ return JSONResponse(
734
+ content={
735
+ "error": "Failed to retrieve mnemonic. Please check the logs."
736
+ },
737
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
738
+ )
739
+
740
+ @app.get("/api/wallet/extended")
741
+ async def _get_wallet_safe(request: Request) -> t.List[t.Dict]:
742
+ """Get wallets."""
743
+ wallets = []
744
+ for wallet in operate.wallet_manager:
745
+ wallets.append(wallet.extended_json)
746
+ return JSONResponse(content=wallets)
747
+
399
748
  @app.get("/api/wallet/safe")
400
- @with_retries
401
749
  async def _get_safes(request: Request) -> t.List[t.Dict]:
402
750
  """Create wallet safe"""
403
- safes = []
751
+ all_safes = []
404
752
  for wallet in operate.wallet_manager:
405
- safes.append({wallet.ledger_type: wallet.safe})
406
- return JSONResponse(content=safes)
753
+ safes = []
754
+ if wallet.safes is not None:
755
+ safes = list(wallet.safes.values())
756
+ all_safes.append({wallet.ledger_type: safes})
757
+ return JSONResponse(content=all_safes)
407
758
 
408
759
  @app.get("/api/wallet/safe/{chain}")
409
- @with_retries
410
760
  async def _get_safe(request: Request) -> t.List[t.Dict]:
411
- """Create wallet safe"""
412
- ledger_type = get_ledger_type_from_chain_type(
413
- chain=ChainType.from_string(request.path_params["chain"])
414
- )
761
+ """Get safe address"""
762
+ chain = Chain.from_string(request.path_params["chain"])
763
+ ledger_type = chain.ledger_type
415
764
  manager = operate.wallet_manager
416
765
  if not manager.exists(ledger_type=ledger_type):
417
766
  return JSONResponse(
418
- content={"error": "Wallet does not exist"},
419
- status_code=404,
767
+ content={"error": "No Master EOA found for this chain."},
768
+ status_code=HTTPStatus.NOT_FOUND,
769
+ )
770
+ safes = manager.load(ledger_type=ledger_type).safes
771
+ if safes is None or safes.get(chain) is None:
772
+ return JSONResponse(
773
+ content={"error": "No Master Safe found for this chain."},
774
+ status_code=HTTPStatus.NOT_FOUND,
420
775
  )
776
+
421
777
  return JSONResponse(
422
778
  content={
423
- "safe": manager.load(ledger_type=ledger_type).safe,
779
+ "safe": safes[chain],
424
780
  },
425
781
  )
426
782
 
427
783
  @app.post("/api/wallet/safe")
428
- @with_retries
429
- async def _create_safe(request: Request) -> t.List[t.Dict]:
784
+ async def _create_safe( # pylint: disable=too-many-return-statements
785
+ request: Request,
786
+ ) -> t.List[t.Dict]:
430
787
  """Create wallet safe"""
431
788
  if operate.user_account is None:
432
- return JSONResponse(
433
- content={"error": "Cannot create safe; User account does not exist!"},
434
- status_code=400,
435
- )
789
+ return ACCOUNT_NOT_FOUND_ERROR
436
790
 
437
791
  if operate.password is None:
792
+ return USER_NOT_LOGGED_IN_ERROR
793
+
794
+ data = await request.json()
795
+
796
+ if "initial_funds" in data and "transfer_excess_assets" in data:
438
797
  return JSONResponse(
439
- content={"error": "You need to login before creating a safe"},
440
- status_code=401,
798
+ content={
799
+ "error": "Only specify one of 'initial_funds' or 'transfer_excess_assets', but not both."
800
+ },
801
+ status_code=HTTPStatus.BAD_REQUEST,
441
802
  )
442
803
 
443
- data = await request.json()
444
- chain_type = ChainType(data["chain_type"])
445
- ledger_type = get_ledger_type_from_chain_type(chain=chain_type)
804
+ logger.info(f"POST /api/wallet/safe {data=}")
805
+
806
+ chain = Chain(data["chain"])
807
+ ledger_type = chain.ledger_type
446
808
  manager = operate.wallet_manager
447
809
  if not manager.exists(ledger_type=ledger_type):
448
- return JSONResponse(content={"error": "Wallet does not exist"})
810
+ return JSONResponse(
811
+ content={"error": "No Master EOA found for this chain."},
812
+ status_code=HTTPStatus.NOT_FOUND,
813
+ )
449
814
 
450
815
  wallet = manager.load(ledger_type=ledger_type)
451
- if wallet.safe is not None:
816
+ if wallet.safes is not None and wallet.safes.get(chain) is not None:
452
817
  return JSONResponse(
453
- content={"safe": wallet.safe, "message": "Safe already exists!"}
818
+ content={
819
+ "safe": wallet.safes.get(chain),
820
+ "message": "Safe already exists for this chain.",
821
+ }
454
822
  )
455
823
 
456
- wallet.create_safe( # pylint: disable=no-member
457
- chain_type=chain_type,
458
- owner=data.get("owner"),
459
- )
460
- wallet.transfer(
461
- to=t.cast(str, wallet.safe),
462
- amount=int(1e18),
463
- chain_type=chain_type,
464
- from_safe=False,
824
+ ledger_api = wallet.ledger_api(chain=chain)
825
+ safes = t.cast(t.Dict[Chain, str], wallet.safes)
826
+
827
+ backup_owner = data.get("backup_owner")
828
+ if backup_owner:
829
+ backup_owner = ledger_api.api.to_checksum_address(backup_owner)
830
+
831
+ # A default nonzero balance might be required on the Safe after creation.
832
+ # This is possibly required to estimate gas in protocol transactions.
833
+ initial_funds = data.get("initial_funds", DEFAULT_NEW_SAFE_FUNDS[chain])
834
+ transfer_excess_assets = (
835
+ str(data.get("transfer_excess_assets", "false")).lower() == "true"
465
836
  )
466
- return JSONResponse(content={"safe": wallet.safe, "message": "Safe created!"})
837
+
838
+ if transfer_excess_assets:
839
+ asset_addresses = {ZERO_ADDRESS} | {
840
+ token[chain] for token in ERC20_TOKENS.values()
841
+ }
842
+ balances = get_assets_balances(
843
+ ledger_api=ledger_api,
844
+ addresses={wallet.address},
845
+ asset_addresses=asset_addresses,
846
+ raise_on_invalid_address=False,
847
+ )[wallet.address]
848
+ initial_funds = subtract_dicts(balances, DEFAULT_EOA_TOPUPS[chain])
849
+
850
+ logger.info(f"_create_safe Computed {initial_funds=}")
851
+
852
+ try:
853
+ create_tx = wallet.create_safe( # pylint: disable=no-member
854
+ chain=chain,
855
+ backup_owner=backup_owner,
856
+ )
857
+
858
+ safe_address = t.cast(str, safes.get(chain))
859
+
860
+ transfer_txs = {}
861
+ for asset, amount in initial_funds.items():
862
+ if amount <= 0:
863
+ continue
864
+
865
+ logger.info(
866
+ f"_create_safe Transfer to={safe_address} {amount=} {chain} {asset=}"
867
+ )
868
+ tx_hash = wallet.transfer(
869
+ to=safe_address,
870
+ amount=int(amount),
871
+ chain=chain,
872
+ asset=asset,
873
+ from_safe=False,
874
+ )
875
+ transfer_txs[asset] = tx_hash
876
+
877
+ return JSONResponse(
878
+ content={
879
+ "create_tx": create_tx,
880
+ "transfer_txs": transfer_txs,
881
+ "safe": safes.get(chain),
882
+ "message": "Safe created successfully",
883
+ },
884
+ status_code=HTTPStatus.CREATED,
885
+ )
886
+ except Exception as e: # pylint: disable=broad-except
887
+ logger.error(f"Safe creation failed: {e}\n{traceback.format_exc()}")
888
+ return JSONResponse(
889
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
890
+ content={"error": "Failed to create safe. Please check the logs."},
891
+ )
467
892
 
468
893
  @app.put("/api/wallet/safe")
469
- @with_retries
470
894
  async def _update_safe(request: Request) -> t.List[t.Dict]:
471
- """Create wallet safe"""
895
+ """Update wallet safe"""
472
896
  # TODO: Extract login check as decorator
473
897
  if operate.user_account is None:
474
- return JSONResponse(
475
- content={"error": "Cannot create safe; User account does not exist!"},
476
- status_code=400,
477
- )
898
+ return ACCOUNT_NOT_FOUND_ERROR
478
899
 
479
900
  if operate.password is None:
901
+ return USER_NOT_LOGGED_IN_ERROR
902
+
903
+ data = await request.json()
904
+
905
+ if "chain" not in data:
480
906
  return JSONResponse(
481
- content={"error": "You need to login before creating a safe"},
482
- status_code=401,
907
+ content={"error": "'chain' is required."},
908
+ status_code=HTTPStatus.BAD_REQUEST,
483
909
  )
484
910
 
485
- data = await request.json()
486
- chain_type = ChainType(data["chain_type"])
487
- ledger_type = get_ledger_type_from_chain_type(chain=chain_type)
911
+ chain = Chain(data["chain"])
912
+ ledger_type = chain.ledger_type
488
913
  manager = operate.wallet_manager
489
914
  if not manager.exists(ledger_type=ledger_type):
490
- return JSONResponse(content={"error": "Wallet does not exist"})
915
+ return JSONResponse(
916
+ content={"error": "No Master EOA found for this chain."},
917
+ status_code=HTTPStatus.BAD_REQUEST,
918
+ )
491
919
 
492
920
  wallet = manager.load(ledger_type=ledger_type)
493
- wallet.add_or_swap_owner(
494
- chain_type=chain_type,
495
- owner=data.get("owner"),
921
+ ledger_api = wallet.ledger_api(chain=chain)
922
+
923
+ backup_owner = data.get("backup_owner")
924
+ if backup_owner:
925
+ backup_owner = ledger_api.api.to_checksum_address(backup_owner)
926
+
927
+ backup_owner_updated = wallet.update_backup_owner(
928
+ chain=chain,
929
+ backup_owner=backup_owner,
930
+ )
931
+ message = (
932
+ "Backup owner updated successfully"
933
+ if backup_owner_updated
934
+ else "Backup owner is already set to this address"
935
+ )
936
+ return JSONResponse(
937
+ content={
938
+ "wallet": wallet.json,
939
+ "chain": chain.value,
940
+ "backup_owner_updated": backup_owner_updated,
941
+ "message": message,
942
+ }
496
943
  )
497
- return JSONResponse(content=wallet.json)
498
944
 
499
- @app.get("/api/services")
500
- @with_retries
501
- async def _get_services(request: Request) -> JSONResponse:
502
- """Get available services."""
503
- return JSONResponse(content=operate.service_manager().json)
945
+ @app.post("/api/wallet/withdraw")
946
+ async def _wallet_withdraw(request: Request) -> JSONResponse:
947
+ """Withdraw from Master Safe / master eoa"""
504
948
 
505
- @app.post("/api/services")
506
- @with_retries
507
- async def _create_services(request: Request) -> JSONResponse:
508
- """Create a service."""
509
949
  if operate.password is None:
510
950
  return USER_NOT_LOGGED_IN_ERROR
511
- template = await request.json()
512
- manager = operate.service_manager()
513
- update = False
514
- if len(manager.json) > 0:
515
- old_hash = manager.json[0]["hash"]
516
- if old_hash == template["hash"]:
517
- logger.info(f'Loading service {template["hash"]}')
518
- service = manager.create_or_load(
519
- hash=template["hash"],
520
- rpc=template["configuration"]["rpc"],
521
- on_chain_user_params=services.manage.OnChainUserParams.from_json(
522
- template["configuration"]
523
- ),
524
- )
525
- else:
526
- logger.info(f"Updating service from {old_hash} to " + template["hash"])
527
- service = manager.update_service(
528
- old_hash=old_hash,
529
- new_hash=template["hash"],
530
- rpc=template["configuration"]["rpc"],
531
- on_chain_user_params=services.manage.OnChainUserParams.from_json(
532
- template["configuration"]
533
- ),
534
- from_safe=True,
535
- )
536
- update = True
537
- else:
538
- logger.info(f'Creating service {template["hash"]}')
539
- service = manager.create_or_load(
540
- hash=template["hash"],
541
- rpc=template["configuration"]["rpc"],
542
- on_chain_user_params=services.manage.OnChainUserParams.from_json(
543
- template["configuration"]
544
- ),
951
+
952
+ data = await request.json()
953
+ if not operate.user_account.is_valid(password=data["password"]):
954
+ return JSONResponse(
955
+ content={"error": MSG_INVALID_PASSWORD},
956
+ status_code=HTTPStatus.UNAUTHORIZED,
545
957
  )
546
958
 
547
- if template.get("deploy", False):
548
- manager.deploy_service_onchain_from_safe(hash=service.hash, update=update)
549
- manager.stake_service_on_chain_from_safe(hash=service.hash)
550
- manager.fund_service(hash=service.hash)
551
- manager.deploy_service_locally(hash=service.hash)
552
- schedule_funding_job(service=service.hash)
553
- schedule_healthcheck_job(service=service.hash)
959
+ try:
960
+ withdraw_assets = data.get("withdraw_assets", {})
961
+ to = data["to"]
962
+ wallet_manager = operate.wallet_manager
963
+ transfer_txs: t.Dict[str, t.Dict[str, t.List[str]]] = {}
964
+
965
+ # TODO: Ensure master wallet has enough funding.
966
+ for chain_str, tokens in withdraw_assets.items():
967
+ chain = Chain(chain_str)
968
+ wallet = wallet_manager.load(chain.ledger_type)
969
+ transfer_txs[chain_str] = {}
970
+
971
+ # Process ERC20 first
972
+ for asset, amount in tokens.items():
973
+ if asset != ZERO_ADDRESS:
974
+ txs = wallet.transfer_from_safe_then_eoa(
975
+ to=to,
976
+ amount=int(amount),
977
+ chain=chain,
978
+ asset=asset,
979
+ )
980
+ transfer_txs[chain_str][asset] = txs
981
+
982
+ # Process native last
983
+ if ZERO_ADDRESS in tokens:
984
+ asset = ZERO_ADDRESS
985
+ amount = tokens[asset]
986
+ txs = wallet.transfer_from_safe_then_eoa(
987
+ to=to,
988
+ amount=int(amount),
989
+ chain=chain,
990
+ asset=asset,
991
+ )
992
+ transfer_txs[chain_str][asset] = txs
993
+
994
+ except InsufficientFundsException as e:
995
+ logger.error(f"Insufficient funds: {e}\n{traceback.format_exc()}")
996
+ return JSONResponse(
997
+ content={
998
+ "error": f"Failed to withdraw funds. Insufficient funds: {e}",
999
+ "transfer_txs": transfer_txs,
1000
+ },
1001
+ status_code=HTTPStatus.BAD_REQUEST,
1002
+ )
1003
+ except Exception as e: # pylint: disable=broad-except
1004
+ logger.error(f"Failed to withdraw funds: {e}\n{traceback.format_exc()}")
1005
+ return JSONResponse(
1006
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1007
+ content={
1008
+ "error": "Failed to withdraw funds. Please check the logs.",
1009
+ "transfer_txs": transfer_txs,
1010
+ },
1011
+ )
554
1012
 
555
1013
  return JSONResponse(
556
- content=operate.service_manager().create_or_load(hash=service.hash).json
1014
+ content={
1015
+ "error": None,
1016
+ "message": "Funds withdrawn successfully.",
1017
+ "transfer_txs": transfer_txs,
1018
+ }
557
1019
  )
558
1020
 
559
- @app.put("/api/services")
560
- @with_retries
561
- async def _update_services(request: Request) -> JSONResponse:
562
- """Create a service."""
563
- if operate.password is None:
564
- return USER_NOT_LOGGED_IN_ERROR
565
- template = await request.json()
566
- service = operate.service_manager().update_service(
567
- old_hash=template["old_service_hash"],
568
- new_hash=template["new_service_hash"],
1021
+ @app.get("/api/v2/services")
1022
+ async def _get_services(request: Request) -> JSONResponse:
1023
+ """Get all services."""
1024
+ return JSONResponse(content=operate.service_manager().json)
1025
+
1026
+ @app.get("/api/v2/services/validate")
1027
+ async def _validate_services(request: Request) -> JSONResponse:
1028
+ """Validate all services."""
1029
+ service_manager = operate.service_manager()
1030
+ service_ids = service_manager.get_all_service_ids()
1031
+ _services = [
1032
+ service.service_config_id
1033
+ for service in service_manager.get_all_services()[0]
1034
+ ]
1035
+
1036
+ return JSONResponse(
1037
+ content={service_id: service_id in _services for service_id in service_ids}
569
1038
  )
570
- if template.get("deploy", False):
571
- manager = operate.service_manager()
572
- manager.deploy_service_onchain_from_safe(hash=service.hash, update=True)
573
- manager.stake_service_on_chain_from_safe(hash=service.hash)
574
- manager.fund_service(hash=service.hash)
575
- manager.deploy_service_locally(hash=service.hash)
576
- schedule_funding_job(service=service.hash)
577
- schedule_healthcheck_job(service=service.hash)
578
-
579
- return JSONResponse(content=service.json)
580
-
581
- @app.get("/api/services/{service}")
582
- @with_retries
1039
+
1040
+ @app.get("/api/v2/services/deployment")
1041
+ async def _get_services_deployment(request: Request) -> JSONResponse:
1042
+ """Get a service deployment."""
1043
+ service_manager = operate.service_manager()
1044
+ output = {}
1045
+ for service in service_manager.get_all_services()[0]:
1046
+ deployment_json = service.deployment.json
1047
+ deployment_json["healthcheck"] = service.get_latest_healthcheck()
1048
+ output[service.service_config_id] = deployment_json
1049
+
1050
+ return JSONResponse(content=output)
1051
+
1052
+ @app.get("/api/v2/service/{service_config_id}")
583
1053
  async def _get_service(request: Request) -> JSONResponse:
584
- """Create a service."""
585
- if not operate.service_manager().exists(service=request.path_params["service"]):
586
- return service_not_found_error(service=request.path_params["service"])
1054
+ """Get a service."""
1055
+ service_config_id = request.path_params["service_config_id"]
1056
+
1057
+ if not operate.service_manager().exists(service_config_id=service_config_id):
1058
+ return service_not_found_error(service_config_id=service_config_id)
587
1059
  return JSONResponse(
588
1060
  content=(
589
1061
  operate.service_manager()
590
- .create_or_load(
591
- hash=request.path_params["service"],
1062
+ .load(
1063
+ service_config_id=service_config_id,
592
1064
  )
593
1065
  .json
594
1066
  )
595
1067
  )
596
1068
 
597
- @app.post("/api/services/{service}/onchain/deploy")
598
- @with_retries
599
- async def _deploy_service_onchain(request: Request) -> JSONResponse:
600
- """Create a service."""
601
- if not operate.service_manager().exists(service=request.path_params["service"]):
602
- return service_not_found_error(service=request.path_params["service"])
603
- if operate.password is None:
604
- return USER_NOT_LOGGED_IN_ERROR
605
- operate.service_manager().deploy_service_onchain(
606
- hash=request.path_params["service"]
1069
+ @app.get("/api/v2/service/{service_config_id}/deployment")
1070
+ async def _get_service_deployment(request: Request) -> JSONResponse:
1071
+ """Get a service deployment."""
1072
+ service_config_id = request.path_params["service_config_id"]
1073
+
1074
+ if not operate.service_manager().exists(service_config_id=service_config_id):
1075
+ return service_not_found_error(service_config_id=service_config_id)
1076
+
1077
+ service = operate.service_manager().load(service_config_id=service_config_id)
1078
+ deployment_json = service.deployment.json
1079
+ deployment_json["healthcheck"] = service.get_latest_healthcheck()
1080
+ return JSONResponse(content=deployment_json)
1081
+
1082
+ @app.get("/api/v2/service/{service_config_id}/agent_performance")
1083
+ async def _get_agent_performance(request: Request) -> JSONResponse:
1084
+ """Get the service refill requirements."""
1085
+ service_config_id = request.path_params["service_config_id"]
1086
+
1087
+ if not operate.service_manager().exists(service_config_id=service_config_id):
1088
+ return service_not_found_error(service_config_id=service_config_id)
1089
+
1090
+ return JSONResponse(
1091
+ content=operate.service_manager()
1092
+ .load(service_config_id=service_config_id)
1093
+ .get_agent_performance()
607
1094
  )
608
- operate.service_manager().stake_service_on_chain(
609
- hash=request.path_params["service"]
1095
+
1096
+ @app.get("/api/v2/service/{service_config_id}/funding_requirements")
1097
+ async def _get_funding_requirements(request: Request) -> JSONResponse:
1098
+ """Get the service refill requirements."""
1099
+ service_config_id = request.path_params["service_config_id"]
1100
+
1101
+ if not operate.service_manager().exists(service_config_id=service_config_id):
1102
+ return service_not_found_error(service_config_id=service_config_id)
1103
+
1104
+ return JSONResponse(
1105
+ content=operate.service_manager().funding_requirements(
1106
+ service_config_id=service_config_id
1107
+ )
610
1108
  )
1109
+
1110
+ # TODO deprecate
1111
+ @app.get("/api/v2/service/{service_config_id}/refill_requirements")
1112
+ async def _get_refill_requirements(request: Request) -> JSONResponse:
1113
+ """Get the service refill requirements."""
1114
+ service_config_id = request.path_params["service_config_id"]
1115
+
1116
+ if not operate.service_manager().exists(service_config_id=service_config_id):
1117
+ return service_not_found_error(service_config_id=service_config_id)
1118
+
611
1119
  return JSONResponse(
612
- content=(
613
- operate.service_manager()
614
- .create_or_load(hash=request.path_params["service"])
615
- .json
1120
+ content=operate.service_manager().refill_requirements(
1121
+ service_config_id=service_config_id
616
1122
  )
617
1123
  )
618
1124
 
619
- @app.post("/api/services/{service}/onchain/stop")
620
- @with_retries
621
- async def _stop_service_onchain(request: Request) -> JSONResponse:
1125
+ @app.post("/api/v2/service")
1126
+ async def _create_services_v2(request: Request) -> JSONResponse:
622
1127
  """Create a service."""
623
- if not operate.service_manager().exists(service=request.path_params["service"]):
624
- return service_not_found_error(service=request.path_params["service"])
625
1128
  if operate.password is None:
626
1129
  return USER_NOT_LOGGED_IN_ERROR
627
- operate.service_manager().terminate_service_on_chain(
628
- hash=request.path_params["service"]
629
- )
630
- operate.service_manager().unbond_service_on_chain(
631
- hash=request.path_params["service"]
632
- )
633
- operate.service_manager().unstake_service_on_chain(
634
- hash=request.path_params["service"]
635
- )
1130
+ template = await request.json()
1131
+ manager = operate.service_manager()
1132
+ output = manager.create(service_template=template)
1133
+
1134
+ return JSONResponse(content=output.json)
1135
+
1136
+ @app.post("/api/v2/service/{service_config_id}")
1137
+ async def _deploy_and_run_service(request: Request) -> JSONResponse:
1138
+ """Deploy a service."""
1139
+ if operate.password is None:
1140
+ return USER_NOT_LOGGED_IN_ERROR
1141
+
1142
+ pause_all_services()
1143
+ service_config_id = request.path_params["service_config_id"]
1144
+ manager = operate.service_manager()
1145
+
1146
+ if not manager.exists(service_config_id=service_config_id):
1147
+ return service_not_found_error(service_config_id=service_config_id)
1148
+
1149
+ def _fn() -> None:
1150
+ # deploy_service_onchain_from_safe includes stake_service_on_chain_from_safe
1151
+ manager.deploy_service_onchain_from_safe(
1152
+ service_config_id=service_config_id
1153
+ )
1154
+ manager.deploy_service_locally(service_config_id=service_config_id)
1155
+
1156
+ await run_in_executor(_fn)
1157
+ schedule_healthcheck_job(service_config_id=service_config_id)
1158
+
636
1159
  return JSONResponse(
637
1160
  content=(
638
- operate.service_manager()
639
- .create_or_load(hash=request.path_params["service"])
640
- .json
1161
+ operate.service_manager().load(service_config_id=service_config_id).json
641
1162
  )
642
1163
  )
643
1164
 
644
- @app.get("/api/services/{service}/deployment")
645
- @with_retries
646
- async def _get_service_deployment(request: Request) -> JSONResponse:
647
- """Create a service."""
648
- if not operate.service_manager().exists(service=request.path_params["service"]):
649
- return service_not_found_error(service=request.path_params["service"])
650
- return JSONResponse(
651
- content=operate.service_manager()
652
- .create_or_load(
653
- request.path_params["service"],
654
- )
655
- .deployment.json
1165
+ @app.put("/api/v2/service/{service_config_id}")
1166
+ @app.patch("/api/v2/service/{service_config_id}")
1167
+ async def _update_service(request: Request) -> JSONResponse:
1168
+ """Update a service."""
1169
+ if operate.password is None:
1170
+ return USER_NOT_LOGGED_IN_ERROR
1171
+
1172
+ service_config_id = request.path_params["service_config_id"]
1173
+ manager = operate.service_manager()
1174
+
1175
+ if not manager.exists(service_config_id=service_config_id):
1176
+ return service_not_found_error(service_config_id=service_config_id)
1177
+
1178
+ template = await request.json()
1179
+ allow_different_service_public_id = template.get(
1180
+ "allow_different_service_public_id", False
656
1181
  )
657
1182
 
658
- @app.post("/api/services/{service}/deployment/build")
659
- @with_retries
660
- async def _build_service_locally(request: Request) -> JSONResponse:
661
- """Create a service."""
662
- if not operate.service_manager().exists(service=request.path_params["service"]):
663
- return service_not_found_error(service=request.path_params["service"])
664
- deployment = (
665
- operate.service_manager()
666
- .create_or_load(
667
- request.path_params["service"],
668
- )
669
- .deployment
1183
+ if request.method == "PUT":
1184
+ partial_update = False
1185
+ else:
1186
+ partial_update = True
1187
+
1188
+ logger.info(
1189
+ f"_update_service {partial_update=} {allow_different_service_public_id=}"
670
1190
  )
671
- deployment.build(force=True)
672
- return JSONResponse(content=deployment.json)
673
1191
 
674
- @app.post("/api/services/{service}/deployment/start")
675
- @with_retries
676
- async def _start_service_locally(request: Request) -> JSONResponse:
677
- """Create a service."""
678
- if not operate.service_manager().exists(service=request.path_params["service"]):
679
- return service_not_found_error(service=request.path_params["service"])
680
- service = request.path_params["service"]
681
- manager = operate.service_manager()
682
- manager.deploy_service_onchain(hash=service)
683
- manager.stake_service_on_chain(hash=service)
684
- manager.fund_service(hash=service)
685
- manager.deploy_service_locally(hash=service, force=True)
686
- schedule_funding_job(service=service)
687
- schedule_healthcheck_job(service=service.hash)
688
- return JSONResponse(content=manager.create_or_load(service).deployment)
689
-
690
- @app.post("/api/services/{service}/deployment/stop")
691
- @with_retries
1192
+ output = manager.update(
1193
+ service_config_id=service_config_id,
1194
+ service_template=template,
1195
+ allow_different_service_public_id=allow_different_service_public_id,
1196
+ partial_update=partial_update,
1197
+ )
1198
+
1199
+ return JSONResponse(content=output.json)
1200
+
1201
+ @app.post("/api/v2/service/{service_config_id}/deployment/stop")
692
1202
  async def _stop_service_locally(request: Request) -> JSONResponse:
693
- """Create a service."""
694
- if not operate.service_manager().exists(service=request.path_params["service"]):
695
- return service_not_found_error(service=request.path_params["service"])
696
- service = request.path_params["service"]
697
- deployment = operate.service_manager().create_or_load(service).deployment
698
- deployment.stop()
699
- logger.info(f"Cancelling funding job for {service}")
700
- cancel_funding_job(service=service)
1203
+ """Stop a service deployment."""
1204
+
1205
+ # No authentication required to stop services.
1206
+
1207
+ service_config_id = request.path_params["service_config_id"]
1208
+ manager = operate.service_manager()
1209
+
1210
+ if not manager.exists(service_config_id=service_config_id):
1211
+ return service_not_found_error(service_config_id=service_config_id)
1212
+
1213
+ service = operate.service_manager().load(service_config_id=service_config_id)
1214
+ service.remove_latest_healthcheck()
1215
+ deployment = service.deployment
1216
+ health_checker.stop_for_service(service_config_id=service_config_id)
1217
+
1218
+ await run_in_executor(deployment.stop)
1219
+ logger.info(f"Cancelling funding job for {service_config_id}")
701
1220
  return JSONResponse(content=deployment.json)
702
1221
 
703
- @app.post("/api/services/{service}/deployment/delete")
704
- @with_retries
705
- async def _delete_service_locally(request: Request) -> JSONResponse:
706
- """Create a service."""
707
- if not operate.service_manager().exists(service=request.path_params["service"]):
708
- return service_not_found_error(service=request.path_params["service"])
709
- # TODO: Drain safe before deleting service
710
- deployment = (
711
- operate.service_manager()
712
- .create_or_load(
713
- request.path_params["service"],
714
- )
715
- .deployment
1222
+ # TODO Deprecate
1223
+ @app.post("/api/v2/service/{service_config_id}/onchain/withdraw")
1224
+ async def _withdraw_onchain(request: Request) -> JSONResponse:
1225
+ """Withdraw all the funds from a service."""
1226
+
1227
+ if operate.password is None:
1228
+ return USER_NOT_LOGGED_IN_ERROR
1229
+
1230
+ service_config_id = request.path_params["service_config_id"]
1231
+ service_manager = operate.service_manager()
1232
+
1233
+ if not service_manager.exists(service_config_id=service_config_id):
1234
+ return service_not_found_error(service_config_id=service_config_id)
1235
+
1236
+ withdrawal_address = (await request.json()).get("withdrawal_address")
1237
+ if withdrawal_address is None:
1238
+ return JSONResponse(
1239
+ content={"error": "'withdrawal_address' is required"},
1240
+ status_code=HTTPStatus.BAD_REQUEST,
1241
+ )
1242
+
1243
+ try:
1244
+ pause_all_services()
1245
+ service = service_manager.load(service_config_id=service_config_id)
1246
+
1247
+ # terminate the service on chain
1248
+ for chain, chain_config in service.chain_configs.items():
1249
+ service_manager.terminate_service_on_chain_from_safe(
1250
+ service_config_id=service_config_id,
1251
+ chain=chain,
1252
+ )
1253
+ service_manager.drain(
1254
+ service_config_id=service_config_id,
1255
+ chain_str=chain,
1256
+ withdrawal_address=withdrawal_address,
1257
+ )
1258
+
1259
+ # drain the Master Safe and master signer for the home chain
1260
+ chain = Chain(service.home_chain)
1261
+ master_wallet = service_manager.wallet_manager.load(
1262
+ ledger_type=chain.ledger_type
1263
+ )
1264
+
1265
+ # drain the Master Safe
1266
+ logger.info(
1267
+ f"Draining the Master Safe {master_wallet.safes[chain]} on chain {chain.value} (withdrawal address {withdrawal_address})."
1268
+ )
1269
+ master_wallet.drain(
1270
+ withdrawal_address=withdrawal_address,
1271
+ chain=chain,
1272
+ from_safe=True,
1273
+ rpc=chain_config.ledger_config.rpc,
1274
+ )
1275
+
1276
+ # drain the master signer
1277
+ logger.info(
1278
+ f"Draining the Master Signer {master_wallet.address} on chain {chain.value} (withdrawal address {withdrawal_address})."
1279
+ )
1280
+ master_wallet.drain(
1281
+ withdrawal_address=withdrawal_address,
1282
+ chain=chain,
1283
+ from_safe=False,
1284
+ rpc=chain_config.ledger_config.rpc,
1285
+ )
1286
+ except Exception as e: # pylint: disable=broad-except
1287
+ logger.error(f"Withdrawal failed: {e}\n{traceback.format_exc()}")
1288
+ return JSONResponse(
1289
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1290
+ content={"error": "Failed to withdraw funds. Please check the logs."},
1291
+ )
1292
+
1293
+ return JSONResponse(content={"error": None, "message": "Withdrawal successful"})
1294
+
1295
+ @app.post("/api/v2/service/{service_config_id}/terminate_and_withdraw")
1296
+ async def _terminate_and_withdraw(request: Request) -> JSONResponse:
1297
+ """Terminate the service and withdraw all the funds to Master Safe"""
1298
+
1299
+ if operate.password is None:
1300
+ return USER_NOT_LOGGED_IN_ERROR
1301
+
1302
+ service_config_id = request.path_params["service_config_id"]
1303
+ service_manager = operate.service_manager()
1304
+ wallet_manager = operate.wallet_manager
1305
+
1306
+ if not service_manager.exists(service_config_id=service_config_id):
1307
+ return service_not_found_error(service_config_id=service_config_id)
1308
+
1309
+ try:
1310
+ pause_all_services()
1311
+ service = service_manager.load(service_config_id=service_config_id)
1312
+ for chain in service.chain_configs:
1313
+ wallet = wallet_manager.load(Chain(chain).ledger_type)
1314
+ master_safe = wallet.safes[Chain(chain)]
1315
+ service_manager.terminate_service_on_chain_from_safe(
1316
+ service_config_id=service_config_id,
1317
+ chain=chain,
1318
+ )
1319
+ service_manager.drain(
1320
+ service_config_id=service_config_id,
1321
+ chain_str=chain,
1322
+ withdrawal_address=master_safe,
1323
+ )
1324
+
1325
+ except InsufficientFundsException as e:
1326
+ logger.error(
1327
+ f"Failed to terminate service and withdraw funds. Insufficient funds: {e}\n{traceback.format_exc()}"
1328
+ )
1329
+ return JSONResponse(
1330
+ content={
1331
+ "error": f"Failed to terminate service and withdraw funds. Insufficient funds: {e}"
1332
+ },
1333
+ status_code=HTTPStatus.BAD_REQUEST,
1334
+ )
1335
+ except Exception as e: # pylint: disable=broad-except
1336
+ logger.error(
1337
+ f"Terminate service and withdraw funds failed: {e}\n{traceback.format_exc()}"
1338
+ )
1339
+ return JSONResponse(
1340
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1341
+ content={
1342
+ "error": "Failed to terminate service and withdraw funds. Please check the logs."
1343
+ },
1344
+ )
1345
+
1346
+ return JSONResponse(
1347
+ content={
1348
+ "error": None,
1349
+ "message": "Terminate service and withdraw funds successful",
1350
+ }
716
1351
  )
717
- deployment.delete()
718
- return JSONResponse(content=deployment.json)
1352
+
1353
+ @app.post("/api/v2/service/{service_config_id}/fund")
1354
+ async def fund_service( # pylint: disable=too-many-return-statements
1355
+ request: Request,
1356
+ ) -> JSONResponse:
1357
+ """Fund agent or service safe via Master Safe"""
1358
+
1359
+ if operate.password is None:
1360
+ return USER_NOT_LOGGED_IN_ERROR
1361
+
1362
+ service_config_id = request.path_params["service_config_id"]
1363
+ service_manager = operate.service_manager()
1364
+
1365
+ if not service_manager.exists(service_config_id=service_config_id):
1366
+ return service_not_found_error(service_config_id=service_config_id)
1367
+
1368
+ try:
1369
+ data = await request.json()
1370
+ service_manager.fund_service(
1371
+ service_config_id=service_config_id,
1372
+ amounts=ChainAmounts(
1373
+ {
1374
+ chain_str: {
1375
+ address: {
1376
+ asset: int(amount) for asset, amount in assets.items()
1377
+ }
1378
+ for address, assets in addresses.items()
1379
+ }
1380
+ for chain_str, addresses in data.items()
1381
+ }
1382
+ ),
1383
+ )
1384
+ except ValueError as e:
1385
+ logger.error(
1386
+ f"Failed to fund from Master Safe: {e}\n{traceback.format_exc()}"
1387
+ )
1388
+ return JSONResponse(
1389
+ content={"error": str(e)},
1390
+ status_code=HTTPStatus.BAD_REQUEST,
1391
+ )
1392
+ except InsufficientFundsException as e:
1393
+ logger.error(
1394
+ f"Failed to fund from Master Safe. Insufficient funds: {e}\n{traceback.format_exc()}"
1395
+ )
1396
+ return JSONResponse(
1397
+ content={
1398
+ "error": f"Failed to fund from Master Safe. Insufficient funds: {e}"
1399
+ },
1400
+ status_code=HTTPStatus.BAD_REQUEST,
1401
+ )
1402
+ except FundingInProgressError as e:
1403
+ logger.error(
1404
+ f"Failed to fund from Master Safe: {e}\n{traceback.format_exc()}"
1405
+ )
1406
+ return JSONResponse(
1407
+ content={"error": str(e)},
1408
+ status_code=HTTPStatus.CONFLICT,
1409
+ )
1410
+ except Exception as e: # pylint: disable=broad-except
1411
+ logger.error(
1412
+ f"Failed to fund from Master Safe: {e}\n{traceback.format_exc()}"
1413
+ )
1414
+ return JSONResponse(
1415
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1416
+ content={
1417
+ "error": "Failed to fund from Master Safe. Please check the logs."
1418
+ },
1419
+ )
1420
+
1421
+ return JSONResponse(
1422
+ content={"error": None, "message": "Funded from Master Safe successfully"}
1423
+ )
1424
+
1425
+ @app.post("/api/bridge/bridge_refill_requirements")
1426
+ async def _bridge_refill_requirements(request: Request) -> JSONResponse:
1427
+ """Get the bridge refill requirements."""
1428
+ if operate.password is None:
1429
+ return USER_NOT_LOGGED_IN_ERROR
1430
+
1431
+ try:
1432
+ data = await request.json()
1433
+ output = operate.bridge_manager.bridge_refill_requirements(
1434
+ requests_params=data["bridge_requests"],
1435
+ force_update=data.get("force_update", False),
1436
+ )
1437
+
1438
+ return JSONResponse(
1439
+ content=output,
1440
+ status_code=HTTPStatus.OK,
1441
+ )
1442
+ except ValueError as e:
1443
+ logger.error(f"Bridge refill requirements error: {e}")
1444
+ return JSONResponse(
1445
+ content={"error": "Invalid bridge request parameters."},
1446
+ status_code=HTTPStatus.BAD_REQUEST,
1447
+ )
1448
+ except Exception as e: # pylint: disable=broad-except
1449
+ logger.error(
1450
+ f"Bridge refill requirements error: {e}\n{traceback.format_exc()}"
1451
+ )
1452
+ return JSONResponse(
1453
+ content={
1454
+ "error": "Failed to get bridge requirements. Please check the logs."
1455
+ },
1456
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1457
+ )
1458
+
1459
+ @app.post("/api/bridge/execute")
1460
+ async def _bridge_execute(request: Request) -> JSONResponse:
1461
+ """Execute bridge transaction."""
1462
+ if operate.password is None:
1463
+ return USER_NOT_LOGGED_IN_ERROR
1464
+
1465
+ try:
1466
+ data = await request.json()
1467
+ output = operate.bridge_manager.execute_bundle(bundle_id=data["id"])
1468
+
1469
+ return JSONResponse(
1470
+ content=output,
1471
+ status_code=HTTPStatus.OK,
1472
+ )
1473
+ except ValueError as e:
1474
+ logger.error(f"Bridge execute error: {e}")
1475
+ return JSONResponse(
1476
+ content={"error": "Invalid bundle ID or transaction failed."},
1477
+ status_code=HTTPStatus.BAD_REQUEST,
1478
+ )
1479
+ except Exception as e: # pylint: disable=broad-except
1480
+ logger.error(f"Bridge execute error: {e}\n{traceback.format_exc()}")
1481
+ return JSONResponse(
1482
+ content={
1483
+ "error": "Failed to execute bridge transaction. Please check the logs."
1484
+ },
1485
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1486
+ )
1487
+
1488
+ @app.get("/api/bridge/last_executed_bundle_id")
1489
+ async def _bridge_last_executed_bundle_id(request: Request) -> t.List[t.Dict]:
1490
+ """Get last executed bundle id."""
1491
+ content = {"id": operate.bridge_manager.last_executed_bundle_id()}
1492
+ return JSONResponse(content=content, status_code=HTTPStatus.OK)
1493
+
1494
+ @app.get("/api/bridge/status/{id}")
1495
+ async def _bridge_status(request: Request) -> JSONResponse:
1496
+ """Get bridge transaction status."""
1497
+
1498
+ quote_bundle_id = request.path_params["id"]
1499
+
1500
+ try:
1501
+ output = operate.bridge_manager.get_status_json(bundle_id=quote_bundle_id)
1502
+
1503
+ return JSONResponse(
1504
+ content=output,
1505
+ status_code=HTTPStatus.OK,
1506
+ )
1507
+ except ValueError as e:
1508
+ logger.error(f"Bridge status error: {e}")
1509
+ return JSONResponse(
1510
+ content={"error": "Invalid bundle ID."},
1511
+ status_code=HTTPStatus.BAD_REQUEST,
1512
+ )
1513
+ except Exception as e: # pylint: disable=broad-except
1514
+ logger.error(f"Bridge status error: {e}\n{traceback.format_exc()}")
1515
+ return JSONResponse(
1516
+ content={
1517
+ "error": "Failed to get bridge status. Please check the logs."
1518
+ },
1519
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1520
+ )
1521
+
1522
+ @app.post("/api/wallet/recovery/prepare")
1523
+ async def _wallet_recovery_prepare(request: Request) -> JSONResponse:
1524
+ """Prepare wallet recovery."""
1525
+ if operate.user_account is None:
1526
+ return ACCOUNT_NOT_FOUND_ERROR
1527
+
1528
+ if operate.password:
1529
+ return USER_LOGGED_IN_ERROR
1530
+
1531
+ data = await request.json()
1532
+ new_password = data.get("new_password")
1533
+
1534
+ if not new_password or len(new_password) < MIN_PASSWORD_LENGTH:
1535
+ return JSONResponse(
1536
+ content={
1537
+ "error": f"New password must be at least {MIN_PASSWORD_LENGTH} characters long."
1538
+ },
1539
+ status_code=HTTPStatus.BAD_REQUEST,
1540
+ )
1541
+
1542
+ try:
1543
+ output = operate.wallet_recovery_manager.prepare_recovery(
1544
+ new_password=new_password
1545
+ )
1546
+ return JSONResponse(
1547
+ content=output,
1548
+ status_code=HTTPStatus.OK,
1549
+ )
1550
+ except (ValueError, WalletRecoveryError) as e:
1551
+ logger.error(f"_recovery_prepare error: {e}")
1552
+ return JSONResponse(
1553
+ content={"error": f"Failed to prepare recovery: {e}"},
1554
+ status_code=HTTPStatus.BAD_REQUEST,
1555
+ )
1556
+ except Exception as e: # pylint: disable=broad-except
1557
+ logger.error(f"_recovery_prepare error: {e}\n{traceback.format_exc()}")
1558
+ return JSONResponse(
1559
+ content={"error": "Failed to prepare recovery. Please check the logs."},
1560
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1561
+ )
1562
+
1563
+ @app.get("/api/wallet/recovery/funding_requirements")
1564
+ async def _get_recovery_funding_requirements(request: Request) -> JSONResponse:
1565
+ """Get recovery funding requirements."""
1566
+
1567
+ try:
1568
+ output = operate.wallet_recovery_manager.recovery_requirements()
1569
+ return JSONResponse(
1570
+ content=output,
1571
+ status_code=HTTPStatus.OK,
1572
+ )
1573
+ except Exception as e: # pylint: disable=broad-except
1574
+ logger.error(
1575
+ f"_recovery_funding_requirements error: {e}\n{traceback.format_exc()}"
1576
+ )
1577
+ return JSONResponse(
1578
+ content={
1579
+ "error": "Failed to retrieve recovery funding requirements. Please check the logs."
1580
+ },
1581
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1582
+ )
1583
+
1584
+ @app.get("/api/wallet/recovery/status")
1585
+ async def _get_recovery_status(request: Request) -> JSONResponse:
1586
+ """Get recovery status."""
1587
+
1588
+ try:
1589
+ output = operate.wallet_recovery_manager.status()
1590
+ return JSONResponse(
1591
+ content=output,
1592
+ status_code=HTTPStatus.OK,
1593
+ )
1594
+ except Exception as e: # pylint: disable=broad-except
1595
+ logger.error(f"_recovery_status error: {e}\n{traceback.format_exc()}")
1596
+ return JSONResponse(
1597
+ content={
1598
+ "error": "Failed to retrieve recovery status. Please check the logs."
1599
+ },
1600
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1601
+ )
1602
+
1603
+ @app.post("/api/wallet/recovery/complete")
1604
+ async def _wallet_recovery_complete(request: Request) -> JSONResponse:
1605
+ """Complete wallet recovery."""
1606
+ if operate.user_account is None:
1607
+ return ACCOUNT_NOT_FOUND_ERROR
1608
+
1609
+ if operate.password:
1610
+ return USER_LOGGED_IN_ERROR
1611
+
1612
+ data = {}
1613
+ if request.headers.get("content-type", "").startswith("application/json"):
1614
+ body = await request.body()
1615
+ if body:
1616
+ data = await request.json()
1617
+
1618
+ raise_if_inconsistent_owners = data.get("require_consistent_owners", True)
1619
+
1620
+ try:
1621
+ operate.wallet_recovery_manager.complete_recovery(
1622
+ raise_if_inconsistent_owners=raise_if_inconsistent_owners,
1623
+ )
1624
+ return JSONResponse(
1625
+ content=operate.wallet_manager.json,
1626
+ status_code=HTTPStatus.OK,
1627
+ )
1628
+ except KeyError as e:
1629
+ logger.error(f"_recovery_complete error: {e}")
1630
+ return JSONResponse(
1631
+ content={"error": f"Failed to complete recovery: {e}"},
1632
+ status_code=HTTPStatus.NOT_FOUND,
1633
+ )
1634
+ except (ValueError, WalletRecoveryError) as e:
1635
+ logger.error(f"_recovery_complete error: {e}")
1636
+ return JSONResponse(
1637
+ content={"error": f"Failed to complete recovery: {e}"},
1638
+ status_code=HTTPStatus.BAD_REQUEST,
1639
+ )
1640
+ except Exception as e: # pylint: disable=broad-except
1641
+ logger.error(f"_recovery_complete error: {e}\n{traceback.format_exc()}")
1642
+ return JSONResponse(
1643
+ content={
1644
+ "error": "Failed to complete recovery. Please check the logs."
1645
+ },
1646
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1647
+ )
719
1648
 
720
1649
  return app
721
1650
 
@@ -723,26 +1652,245 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
723
1652
  @group(name="operate")
724
1653
  def _operate() -> None:
725
1654
  """Operate - deploy autonomous services."""
1655
+ logger.info(f"Operate version: {__version__}")
726
1656
 
727
1657
 
728
1658
  @_operate.command(name="daemon")
729
1659
  def _daemon(
730
1660
  host: Annotated[str, params.String(help="HTTP server host string")] = "localhost",
731
1661
  port: Annotated[int, params.Integer(help="HTTP server port")] = 8000,
1662
+ ssl_keyfile: Annotated[str, params.String(help="Path to SSL key file")] = "",
1663
+ ssl_certfile: Annotated[
1664
+ str, params.String(help="Path to SSL certificate file")
1665
+ ] = "",
732
1666
  home: Annotated[
733
1667
  t.Optional[Path], params.Directory(long_flag="--home", help="Home directory")
734
1668
  ] = None,
735
1669
  ) -> None:
736
1670
  """Launch operate daemon."""
737
- uvicorn(
738
- app=create_app(home=home),
739
- host=host,
740
- port=port,
1671
+ # try automatically shutdown previous instance before creating the app
1672
+ if TRY_TO_SHUTDOWN_PREVIOUS_INSTANCE:
1673
+ app_single_instance = AppSingleInstance(port)
1674
+ app_single_instance.shutdown_previous_instance()
1675
+
1676
+ app = create_app(home=home)
1677
+
1678
+ config_kwargs = {
1679
+ "app": app,
1680
+ "host": host,
1681
+ "port": port,
1682
+ }
1683
+
1684
+ # Use SSL certificates if ssl_keyfile and ssl_certfile are provided
1685
+ if ssl_keyfile and ssl_certfile:
1686
+ logger.info(f"Using SSL certificates: {ssl_certfile}")
1687
+ config_kwargs.update(
1688
+ {
1689
+ "ssl_keyfile": ssl_keyfile,
1690
+ "ssl_certfile": ssl_certfile,
1691
+ "ssl_version": 2,
1692
+ }
1693
+ )
1694
+
1695
+ server = Server(Config(**config_kwargs))
1696
+ app._server = server # pylint: disable=protected-access
1697
+ server.run()
1698
+
1699
+
1700
+ @_operate.command(name="quickstart")
1701
+ def qs_start(
1702
+ config: Annotated[str, params.String(help="Quickstart config file path")],
1703
+ attended: Annotated[
1704
+ str, params.String(help="Run in attended/unattended mode (default: true")
1705
+ ] = "true",
1706
+ build_only: Annotated[
1707
+ bool, params.Boolean(help="Only build the service without running it")
1708
+ ] = False,
1709
+ skip_dependency_check: Annotated[
1710
+ bool,
1711
+ params.Boolean(help="Will skip the dependencies check for minting the service"),
1712
+ ] = False,
1713
+ use_binary: Annotated[
1714
+ bool,
1715
+ params.Boolean(help="Will use the released binary to run the service"),
1716
+ ] = False,
1717
+ ) -> None:
1718
+ """Quickstart."""
1719
+ os.environ["ATTENDED"] = attended.lower()
1720
+ operate = OperateApp()
1721
+ operate.setup()
1722
+ run_service(
1723
+ operate=operate,
1724
+ config_path=config,
1725
+ build_only=build_only,
1726
+ skip_dependency_check=skip_dependency_check,
1727
+ use_binary=use_binary,
1728
+ )
1729
+
1730
+
1731
+ @_operate.command(name="quickstop")
1732
+ def qs_stop(
1733
+ config: Annotated[str, params.String(help="Quickstart config file path")],
1734
+ use_binary: Annotated[
1735
+ bool,
1736
+ params.Boolean(help="Will use the released binary to run the service"),
1737
+ ] = False,
1738
+ attended: Annotated[
1739
+ str, params.String(help="Run in attended/unattended mode (default: true")
1740
+ ] = "true",
1741
+ ) -> None:
1742
+ """Quickstop."""
1743
+ os.environ["ATTENDED"] = attended.lower()
1744
+ operate = OperateApp()
1745
+ operate.setup()
1746
+ stop_service(operate=operate, config_path=config, use_binary=use_binary)
1747
+
1748
+
1749
+ @_operate.command(name="terminate")
1750
+ def qs_terminate(
1751
+ config: Annotated[str, params.String(help="Quickstart config file path")],
1752
+ attended: Annotated[
1753
+ str, params.String(help="Run in attended/unattended mode (default: true")
1754
+ ] = "true",
1755
+ ) -> None:
1756
+ """Terminate service."""
1757
+ os.environ["ATTENDED"] = attended.lower()
1758
+ operate = OperateApp()
1759
+ operate.setup()
1760
+ terminate_service(operate=operate, config_path=config)
1761
+
1762
+
1763
+ @_operate.command(name="claim")
1764
+ def qs_claim(
1765
+ config: Annotated[str, params.String(help="Quickstart config file path")],
1766
+ attended: Annotated[
1767
+ str, params.String(help="Run in attended/unattended mode (default: true")
1768
+ ] = "true",
1769
+ ) -> None:
1770
+ """Quickclaim staking rewards."""
1771
+ os.environ["ATTENDED"] = attended.lower()
1772
+ operate = OperateApp()
1773
+ operate.setup()
1774
+ claim_staking_rewards(operate=operate, config_path=config)
1775
+
1776
+
1777
+ @_operate.command(name="reset-configs")
1778
+ def qs_reset_configs(
1779
+ config: Annotated[str, params.String(help="Quickstart config file path")],
1780
+ attended: Annotated[
1781
+ str, params.String(help="Run in attended/unattended mode (default: true")
1782
+ ] = "true",
1783
+ ) -> None:
1784
+ """Reset configs."""
1785
+ os.environ["ATTENDED"] = attended.lower()
1786
+ operate = OperateApp()
1787
+ operate.setup()
1788
+ reset_configs(operate=operate, config_path=config)
1789
+
1790
+
1791
+ @_operate.command(name="reset-staking")
1792
+ def qs_reset_staking(
1793
+ config: Annotated[str, params.String(help="Quickstart config file path")],
1794
+ attended: Annotated[
1795
+ str, params.String(help="Run in attended/unattended mode (default: true")
1796
+ ] = "true",
1797
+ ) -> None:
1798
+ """Reset staking."""
1799
+ os.environ["ATTENDED"] = attended.lower()
1800
+ operate = OperateApp()
1801
+ operate.setup()
1802
+ reset_staking(operate=operate, config_path=config)
1803
+
1804
+
1805
+ @_operate.command(name="reset-password")
1806
+ def qs_reset_password(
1807
+ attended: Annotated[
1808
+ str, params.String(help="Run in attended/unattended mode (default: true")
1809
+ ] = "true",
1810
+ ) -> None:
1811
+ """Reset password."""
1812
+ os.environ["ATTENDED"] = attended.lower()
1813
+ operate = OperateApp()
1814
+ operate.setup()
1815
+ reset_password(operate=operate)
1816
+
1817
+
1818
+ @_operate.command(name="analyse-logs")
1819
+ def qs_analyse_logs( # pylint: disable=too-many-arguments
1820
+ config: Annotated[str, params.String(help="Quickstart config file path")],
1821
+ from_dir: Annotated[
1822
+ str,
1823
+ params.String(
1824
+ help="Path to the logs directory. If not provided, it is auto-detected.",
1825
+ default="",
1826
+ ),
1827
+ ],
1828
+ agent: Annotated[
1829
+ str,
1830
+ params.String(
1831
+ help="The agent name to analyze (default: 'aea_0').", default="aea_0"
1832
+ ),
1833
+ ],
1834
+ reset_db: Annotated[
1835
+ bool,
1836
+ params.Boolean(
1837
+ help="Use this flag to disable resetting the log database.", default=False
1838
+ ),
1839
+ ],
1840
+ start_time: Annotated[
1841
+ str,
1842
+ params.String(help="Start time in `YYYY-MM-DD H:M:S,MS` format.", default=""),
1843
+ ],
1844
+ end_time: Annotated[
1845
+ str, params.String(help="End time in `YYYY-MM-DD H:M:S,MS` format.", default="")
1846
+ ],
1847
+ log_level: Annotated[
1848
+ str,
1849
+ params.String(
1850
+ help="Logging level. (INFO, DEBUG, WARNING, ERROR, CRITICAL)",
1851
+ default="INFO",
1852
+ ),
1853
+ ],
1854
+ period: Annotated[int, params.Integer(help="Period ID.", default="")],
1855
+ round: Annotated[ # pylint: disable=redefined-builtin
1856
+ str, params.String(help="Round name.", default="")
1857
+ ],
1858
+ behaviour: Annotated[str, params.String(help="Behaviour name filter.", default="")],
1859
+ fsm: Annotated[
1860
+ bool, params.Boolean(help="Print only the FSM execution path.", default=False)
1861
+ ],
1862
+ include_regex: Annotated[
1863
+ str, params.String(help="Regex pattern to include in the result.", default="")
1864
+ ],
1865
+ exclude_regex: Annotated[
1866
+ str, params.String(help="Regex pattern to exclude from the result.", default="")
1867
+ ],
1868
+ ) -> None:
1869
+ """Analyse the logs of an agent."""
1870
+ operate = OperateApp()
1871
+ operate.setup()
1872
+ analyse_logs(
1873
+ operate=operate,
1874
+ config_path=config,
1875
+ from_dir=from_dir,
1876
+ agent=agent,
1877
+ reset_db=reset_db,
1878
+ start_time=start_time,
1879
+ end_time=end_time,
1880
+ log_level=log_level,
1881
+ period=period,
1882
+ round=round,
1883
+ behaviour=behaviour,
1884
+ fsm=fsm,
1885
+ include_regex=include_regex,
1886
+ exclude_regex=exclude_regex,
741
1887
  )
742
1888
 
743
1889
 
744
1890
  def main() -> None:
745
1891
  """CLI entry point."""
1892
+ if "freeze_support" in multiprocessing.__dict__:
1893
+ multiprocessing.freeze_support()
746
1894
  run(cli=_operate)
747
1895
 
748
1896