olas-operate-middleware 0.10.19__py3-none-any.whl → 0.11.0__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 (30) hide show
  1. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/METADATA +3 -1
  2. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/RECORD +30 -27
  3. operate/bridge/bridge_manager.py +10 -12
  4. operate/bridge/providers/lifi_provider.py +5 -4
  5. operate/bridge/providers/native_bridge_provider.py +6 -5
  6. operate/bridge/providers/provider.py +22 -87
  7. operate/bridge/providers/relay_provider.py +5 -4
  8. operate/cli.py +446 -168
  9. operate/constants.py +22 -2
  10. operate/keys.py +13 -0
  11. operate/ledger/__init__.py +107 -2
  12. operate/ledger/profiles.py +79 -11
  13. operate/operate_types.py +205 -2
  14. operate/quickstart/run_service.py +6 -10
  15. operate/services/agent_runner.py +5 -3
  16. operate/services/deployment_runner.py +3 -0
  17. operate/services/funding_manager.py +904 -0
  18. operate/services/health_checker.py +4 -4
  19. operate/services/manage.py +183 -310
  20. operate/services/protocol.py +392 -140
  21. operate/services/service.py +81 -5
  22. operate/settings.py +70 -0
  23. operate/utils/__init__.py +0 -29
  24. operate/utils/gnosis.py +79 -24
  25. operate/utils/single_instance.py +226 -0
  26. operate/wallet/master.py +221 -181
  27. operate/wallet/wallet_recovery_manager.py +5 -5
  28. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/WHEEL +0 -0
  29. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/entry_points.txt +0 -0
  30. {olas_operate_middleware-0.10.19.dist-info → olas_operate_middleware-0.11.0.dist-info}/licenses/LICENSE +0 -0
operate/cli.py CHANGED
@@ -22,6 +22,7 @@ import asyncio
22
22
  import atexit
23
23
  import multiprocessing
24
24
  import os
25
+ import shutil
25
26
  import signal
26
27
  import traceback
27
28
  import typing as t
@@ -30,14 +31,12 @@ from concurrent.futures import ThreadPoolExecutor
30
31
  from contextlib import asynccontextmanager, suppress
31
32
  from http import HTTPStatus
32
33
  from pathlib import Path
34
+ from time import time
33
35
  from types import FrameType
34
36
 
35
- import psutil
36
- import requests
37
+ import autonomy.chain.tx
37
38
  from aea.helpers.logging import setup_logger
38
39
  from clea import group, params, run
39
- from compose.project import ProjectError
40
- from docker.errors import APIError
41
40
  from fastapi import FastAPI, Request
42
41
  from fastapi.middleware.cors import CORSMiddleware
43
42
  from fastapi.responses import JSONResponse
@@ -49,22 +48,35 @@ from operate import __version__, services
49
48
  from operate.account.user import UserAccount
50
49
  from operate.bridge.bridge_manager import BridgeManager
51
50
  from operate.constants import (
51
+ AGENT_RUNNER_PREFIX,
52
+ DEPLOYMENT_DIR,
52
53
  KEYS_DIR,
53
54
  MIN_PASSWORD_LENGTH,
55
+ MSG_INVALID_MNEMONIC,
56
+ MSG_INVALID_PASSWORD,
57
+ MSG_NEW_PASSWORD_MISSING,
58
+ OPERATE,
54
59
  OPERATE_HOME,
55
60
  SERVICES_DIR,
56
61
  USER_JSON,
62
+ VERSION_FILE,
57
63
  WALLETS_DIR,
58
64
  WALLET_RECOVERY_DIR,
59
65
  ZERO_ADDRESS,
60
66
  )
61
67
  from operate.ledger.profiles import (
62
- DEFAULT_MASTER_EOA_FUNDS,
68
+ DEFAULT_EOA_TOPUPS,
63
69
  DEFAULT_NEW_SAFE_FUNDS,
64
70
  ERC20_TOKENS,
65
71
  )
66
72
  from operate.migration import MigrationManager
67
- from operate.operate_types import Chain, DeploymentStatus, LedgerType
73
+ from operate.operate_types import (
74
+ Chain,
75
+ ChainAmounts,
76
+ DeploymentStatus,
77
+ LedgerType,
78
+ Version,
79
+ )
68
80
  from operate.quickstart.analyse_logs import analyse_logs
69
81
  from operate.quickstart.claim_staking_rewards import claim_staking_rewards
70
82
  from operate.quickstart.reset_configs import reset_configs
@@ -74,16 +86,38 @@ from operate.quickstart.run_service import run_service
74
86
  from operate.quickstart.stop_service import stop_service
75
87
  from operate.quickstart.terminate_on_chain_service import terminate_service
76
88
  from operate.services.deployment_runner import stop_deployment_manager
89
+ from operate.services.funding_manager import FundingInProgressError, FundingManager
77
90
  from operate.services.health_checker import HealthChecker
91
+ from operate.settings import Settings
78
92
  from operate.utils import subtract_dicts
79
93
  from operate.utils.gnosis import get_assets_balances
80
- from operate.wallet.master import MasterWalletManager
94
+ from operate.utils.single_instance import AppSingleInstance, ParentWatchdog
95
+ from operate.wallet.master import InsufficientFundsException, MasterWalletManager
81
96
  from operate.wallet.wallet_recovery_manager import (
82
97
  WalletRecoveryError,
83
98
  WalletRecoveryManager,
84
99
  )
85
100
 
86
101
 
102
+ # TODO Backport to Open Autonomy
103
+ def should_rebuild(error: str) -> bool:
104
+ """Check if we should rebuild the transaction."""
105
+ for _error in (
106
+ "wrong transaction nonce",
107
+ "OldNonce",
108
+ "nonce too low",
109
+ "replacement transaction underpriced",
110
+ ):
111
+ if _error in error:
112
+ return True
113
+ return False
114
+
115
+
116
+ autonomy.chain.tx.ERRORS_TO_RETRY += ("replacement transaction underpriced",)
117
+ autonomy.chain.tx.should_rebuild = should_rebuild
118
+ # End backport to Open Autonomy
119
+
120
+
87
121
  DEFAULT_MAX_RETRIES = 3
88
122
  USER_NOT_LOGGED_IN_ERROR = JSONResponse(
89
123
  content={"error": "User not logged in."}, status_code=HTTPStatus.UNAUTHORIZED
@@ -117,17 +151,28 @@ class OperateApp:
117
151
  home: t.Optional[Path] = None,
118
152
  ) -> None:
119
153
  """Initialize object."""
120
- super().__init__()
121
154
  self._path = (home or OPERATE_HOME).resolve()
122
155
  self._services = self._path / SERVICES_DIR
123
156
  self._keys = self._path / KEYS_DIR
124
157
  self.setup()
158
+ self._backup_operate_if_new_version()
125
159
 
126
160
  services.manage.KeysManager(
127
161
  path=self._keys,
128
162
  logger=logger,
129
163
  )
130
- self.password: t.Optional[str] = os.environ.get("OPERATE_USER_PASSWORD")
164
+ self._password: t.Optional[str] = os.environ.get("OPERATE_USER_PASSWORD")
165
+ self.settings = Settings(path=self._path)
166
+
167
+ self._wallet_manager = MasterWalletManager(
168
+ path=self._path / WALLETS_DIR,
169
+ password=self.password,
170
+ )
171
+ self._wallet_manager.setup()
172
+ self._funding_manager = FundingManager(
173
+ wallet_manager=self._wallet_manager,
174
+ logger=logger,
175
+ )
131
176
 
132
177
  mm = MigrationManager(self._path, logger)
133
178
  mm.migrate_user_account()
@@ -135,6 +180,59 @@ class OperateApp:
135
180
  mm.migrate_wallets(self.wallet_manager)
136
181
  mm.migrate_qs_configs()
137
182
 
183
+ @property
184
+ def password(self) -> t.Optional[str]:
185
+ """Get the password."""
186
+ return self._password
187
+
188
+ @password.setter
189
+ def password(self, value: t.Optional[str]) -> None:
190
+ """Set the password."""
191
+ self._password = value
192
+ self._wallet_manager.password = value
193
+
194
+ def _backup_operate_if_new_version(self) -> None:
195
+ """Backup .operate directory if this is a new version."""
196
+ current_version = Version(__version__)
197
+ backup_required = False
198
+ version_file = self._path / VERSION_FILE
199
+ if not version_file.exists():
200
+ backup_required = True
201
+ found_version = "0.10.21" # first version with version file
202
+ else:
203
+ found_version = Version(version_file.read_text())
204
+ if current_version.major > found_version.major or (
205
+ current_version.major == found_version.major
206
+ and current_version.minor > found_version.minor
207
+ ):
208
+ backup_required = True
209
+
210
+ if not backup_required:
211
+ return
212
+
213
+ backup_path = self._path.parent / f"{OPERATE}_v{found_version}_bak"
214
+ if backup_path.exists():
215
+ logger.info(f"Backup directory {backup_path} already exists.")
216
+ backup_path = (
217
+ self._path.parent / f"{OPERATE}_v{found_version}_bak_{int(time())}"
218
+ )
219
+
220
+ logger.info(f"Backing up existing {OPERATE} directory to {backup_path}")
221
+ shutil.copytree(self._path, backup_path)
222
+ version_file.write_text(str(current_version))
223
+
224
+ # remove recoverable files from the backup to save space
225
+ service_dir = backup_path / SERVICES_DIR
226
+ for service_path in service_dir.iterdir():
227
+ deployment_dir = service_path / DEPLOYMENT_DIR
228
+ if deployment_dir.exists():
229
+ shutil.rmtree(deployment_dir)
230
+
231
+ for agent_runner_path in service_path.glob(f"{AGENT_RUNNER_PREFIX}*"):
232
+ agent_runner_path.unlink()
233
+
234
+ logger.info("Backup completed.")
235
+
138
236
  def create_user_account(self, password: str) -> UserAccount:
139
237
  """Create a user account."""
140
238
  self.password = password
@@ -147,13 +245,13 @@ class OperateApp:
147
245
  """Updates current password"""
148
246
 
149
247
  if not new_password:
150
- raise ValueError("'new_password' is required.")
248
+ raise ValueError(MSG_NEW_PASSWORD_MISSING)
151
249
 
152
250
  if not (
153
251
  self.user_account.is_valid(old_password)
154
252
  and self.wallet_manager.is_password_valid(old_password)
155
253
  ):
156
- raise ValueError("Password is not valid.")
254
+ raise ValueError(MSG_INVALID_PASSWORD)
157
255
 
158
256
  wallet_manager = self.wallet_manager
159
257
  wallet_manager.password = old_password
@@ -164,11 +262,11 @@ class OperateApp:
164
262
  """Updates current password using the mnemonic"""
165
263
 
166
264
  if not new_password:
167
- raise ValueError("'new_password' is required.")
265
+ raise ValueError(MSG_NEW_PASSWORD_MISSING)
168
266
 
169
267
  mnemonic = mnemonic.strip().lower()
170
268
  if not self.wallet_manager.is_mnemonic_valid(mnemonic):
171
- raise ValueError("Seed phrase is not valid.")
269
+ raise ValueError(MSG_INVALID_MNEMONIC)
172
270
 
173
271
  wallet_manager = self.wallet_manager
174
272
  wallet_manager.update_password_with_mnemonic(mnemonic, new_password)
@@ -181,10 +279,16 @@ class OperateApp:
181
279
  return services.manage.ServiceManager(
182
280
  path=self._services,
183
281
  wallet_manager=self.wallet_manager,
282
+ funding_manager=self.funding_manager,
184
283
  logger=logger,
185
284
  skip_dependency_check=skip_dependency_check,
186
285
  )
187
286
 
287
+ @property
288
+ def funding_manager(self) -> FundingManager:
289
+ """Load funding manager."""
290
+ return self._funding_manager
291
+
188
292
  @property
189
293
  def user_account(self) -> t.Optional[UserAccount]:
190
294
  """Load user account."""
@@ -195,13 +299,7 @@ class OperateApp:
195
299
  @property
196
300
  def wallet_manager(self) -> MasterWalletManager:
197
301
  """Load wallet manager."""
198
- manager = MasterWalletManager(
199
- path=self._path / WALLETS_DIR,
200
- password=self.password,
201
- logger=logger,
202
- )
203
- manager.setup()
204
- return manager
302
+ return self._wallet_manager
205
303
 
206
304
  @property
207
305
  def wallet_recoverey_manager(self) -> WalletRecoveryManager:
@@ -234,7 +332,7 @@ class OperateApp:
234
332
  """Json representation of the app."""
235
333
  return {
236
334
  "name": "Operate HTTP server",
237
- "version": "0.1.0.rc0",
335
+ "version": (__version__),
238
336
  "home": str(self._path),
239
337
  }
240
338
 
@@ -254,7 +352,7 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
254
352
  logger.warning("Healthchecker is off!!!")
255
353
  operate = OperateApp(home=home)
256
354
 
257
- funding_jobs: t.Dict[str, asyncio.Task] = {}
355
+ funding_job: t.Optional[asyncio.Task] = None
258
356
  health_checker = HealthChecker(
259
357
  operate.service_manager(), number_of_fails=number_of_fails, logger=logger
260
358
  )
@@ -274,25 +372,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
274
372
  raise exception
275
373
  return res
276
374
 
277
- def schedule_funding_job(
278
- service_config_id: str,
279
- from_safe: bool = True,
280
- ) -> None:
281
- """Schedule a funding job."""
282
- logger.info(f"Starting funding job for {service_config_id}")
283
- if service_config_id in funding_jobs:
284
- logger.info(f"Cancelling existing funding job for {service_config_id}")
285
- cancel_funding_job(service_config_id=service_config_id)
286
-
287
- loop = asyncio.get_running_loop()
288
- funding_jobs[service_config_id] = loop.create_task(
289
- operate.service_manager().funding_job(
290
- service_config_id=service_config_id,
291
- loop=loop,
292
- from_safe=from_safe,
293
- )
294
- )
295
-
296
375
  def schedule_healthcheck_job(
297
376
  service_config_id: str,
298
377
  ) -> None:
@@ -301,13 +380,29 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
301
380
  # dont start health checker if it's switched off
302
381
  health_checker.start_for_service(service_config_id)
303
382
 
304
- def cancel_funding_job(service_config_id: str) -> None:
383
+ def schedule_funding_job() -> None:
384
+ """Schedule the funding job."""
385
+ cancel_funding_job() # cancel previous job if any
386
+ logger.info("Starting the funding job")
387
+
388
+ loop = asyncio.get_event_loop()
389
+ nonlocal funding_job
390
+ funding_job = loop.create_task(
391
+ operate.funding_manager.funding_job(
392
+ service_manager=operate.service_manager(),
393
+ loop=loop,
394
+ )
395
+ )
396
+
397
+ def cancel_funding_job() -> None:
305
398
  """Cancel funding job."""
306
- if service_config_id not in funding_jobs:
399
+ nonlocal funding_job
400
+ if funding_job is None:
307
401
  return
308
- status = funding_jobs[service_config_id].cancel()
402
+
403
+ status = funding_job.cancel()
309
404
  if not status:
310
- logger.info(f"Funding job cancellation for {service_config_id} failed")
405
+ logger.info("Funding job cancellation failed")
311
406
 
312
407
  def pause_all_services_on_startup() -> None:
313
408
  logger.info("Stopping services on startup...")
@@ -346,7 +441,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
346
441
  f"Deployment {service_config_id} stopping failed. but continue"
347
442
  )
348
443
  logger.info(f"Cancelling funding job for {service_config_id}")
349
- cancel_funding_job(service_config_id=service_config_id)
350
444
  health_checker.stop_for_service(service_config_id=service_config_id)
351
445
 
352
446
  def pause_all_services_on_exit(signum: int, frame: t.Optional[FrameType]) -> None:
@@ -365,49 +459,25 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
365
459
 
366
460
  @asynccontextmanager
367
461
  async def lifespan(app: FastAPI):
368
- # Load the ML model
369
- watchdog_task = set_parent_watchdog(app)
370
- yield
371
- # Clean up the ML models and release the resources
372
-
373
- with suppress(Exception):
374
- watchdog_task.cancel()
375
-
376
- with suppress(Exception):
377
- await watchdog_task
378
-
379
- app = FastAPI(lifespan=lifespan)
380
-
381
- def set_parent_watchdog(app):
382
462
  async def stop_app():
383
- logger.info("Stopping services on demand...")
384
-
385
- stop_deployment_manager() # TODO: make it async?
463
+ logger.info("Stopping services due to parent death...")
464
+ stop_deployment_manager()
386
465
  await run_in_executor(pause_all_services)
387
-
388
- logger.info("Stopping services on demand done.")
389
466
  app._server.should_exit = True # pylint: disable=protected-access
390
- logger.info("Stopping app.")
467
+ logger.info("App stopped due to parent death.")
391
468
 
392
- async def check_parent_alive():
393
- try:
394
- logger.info(
395
- f"Parent alive check task started: ppid is {os.getppid()} and own pid is {os.getpid()}"
396
- )
397
- while True:
398
- parent = psutil.Process(os.getpid()).parent()
399
- if not parent:
400
- logger.info("Parent is not alive, going to stop")
401
- await stop_app()
402
- return
403
- await asyncio.sleep(3)
469
+ watchdog = ParentWatchdog(on_parent_exit=stop_app)
470
+ watchdog.start()
404
471
 
405
- except Exception: # pylint: disable=broad-except
406
- logger.exception("Parent alive check crashed!")
472
+ yield # --- app is running ---
473
+
474
+ with suppress(Exception):
475
+ cancel_funding_job()
407
476
 
408
- loop = asyncio.get_running_loop()
409
- task = loop.create_task(check_parent_alive())
410
- return task
477
+ with suppress(Exception):
478
+ await watchdog.stop()
479
+
480
+ app = FastAPI(lifespan=lifespan)
411
481
 
412
482
  app.add_middleware(
413
483
  CORSMiddleware,
@@ -415,37 +485,17 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
415
485
  allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
416
486
  )
417
487
 
418
- def with_retries(f: t.Callable) -> t.Callable:
419
- """Retries decorator."""
420
-
421
- async def _call(request: Request) -> JSONResponse:
422
- """Call the endpoint."""
423
- logger.info(f"Calling `{f.__name__}` with retries enabled")
424
- retries = 0
425
- while retries < DEFAULT_MAX_RETRIES:
426
- try:
427
- return await f(request)
428
- except (APIError, ProjectError) as e:
429
- logger.error(f"Error {e}\n{traceback.format_exc()}")
430
- if "has active endpoints" in str(e):
431
- error_msg = "Service is already running."
432
- else:
433
- error_msg = "Service deployment failed. Please check the logs."
434
- return JSONResponse(
435
- content={"error": error_msg},
436
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
437
- )
438
- except Exception as e: # pylint: disable=broad-except
439
- logger.error(f"Error {str(e)}\n{traceback.format_exc()}")
440
- retries += 1
488
+ @app.middleware("http")
489
+ async def handle_internal_server_error(request: Request, call_next):
490
+ try:
491
+ response = await call_next(request)
492
+ except Exception as e: # pylint: disable=broad-except
493
+ logger.error(f"Error {str(e)}\n{traceback.format_exc()}")
441
494
  return JSONResponse(
442
- content={
443
- "error": "Operation failed after multiple attempts. Please try again later."
444
- },
495
+ content={"error": str(e)},
445
496
  status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
446
497
  )
447
-
448
- return _call
498
+ return response
449
499
 
450
500
  @app.get(f"/{shutdown_endpoint}")
451
501
  async def _kill_server(request: Request) -> JSONResponse:
@@ -464,19 +514,21 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
464
514
  return JSONResponse(content={"stopped": True})
465
515
 
466
516
  @app.get("/api")
467
- @with_retries
468
517
  async def _get_api(request: Request) -> JSONResponse:
469
518
  """Get API info."""
470
519
  return JSONResponse(content=operate.json)
471
520
 
521
+ @app.get("/api/settings")
522
+ async def _get_settings(request: Request) -> JSONResponse:
523
+ """Get settings."""
524
+ return JSONResponse(content=operate.settings.json)
525
+
472
526
  @app.get("/api/account")
473
- @with_retries
474
527
  async def _get_account(request: Request) -> t.Dict:
475
528
  """Get account information."""
476
529
  return {"is_setup": operate.user_account is not None}
477
530
 
478
531
  @app.post("/api/account")
479
- @with_retries
480
532
  async def _setup_account(request: Request) -> t.Dict:
481
533
  """Setup account."""
482
534
  if operate.user_account is not None:
@@ -498,7 +550,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
498
550
  return JSONResponse(content={"error": None})
499
551
 
500
552
  @app.put("/api/account")
501
- @with_retries
502
553
  async def _update_password( # pylint: disable=too-many-return-statements
503
554
  request: Request,
504
555
  ) -> t.Dict:
@@ -568,7 +619,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
568
619
  )
569
620
 
570
621
  @app.post("/api/account/login")
571
- @with_retries
572
622
  async def _validate_password(request: Request) -> t.Dict:
573
623
  """Validate password."""
574
624
  if operate.user_account is None:
@@ -577,18 +627,18 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
577
627
  data = await request.json()
578
628
  if not operate.user_account.is_valid(password=data["password"]):
579
629
  return JSONResponse(
580
- content={"error": "Password is not valid."},
630
+ content={"error": MSG_INVALID_PASSWORD},
581
631
  status_code=HTTPStatus.UNAUTHORIZED,
582
632
  )
583
633
 
584
634
  operate.password = data["password"]
635
+ schedule_funding_job()
585
636
  return JSONResponse(
586
637
  content={"message": "Login successful."},
587
638
  status_code=HTTPStatus.OK,
588
639
  )
589
640
 
590
641
  @app.get("/api/wallet")
591
- @with_retries
592
642
  async def _get_wallets(request: Request) -> t.List[t.Dict]:
593
643
  """Get wallets."""
594
644
  wallets = []
@@ -597,7 +647,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
597
647
  return JSONResponse(content=wallets)
598
648
 
599
649
  @app.post("/api/wallet")
600
- @with_retries
601
650
  async def _create_wallet(request: Request) -> t.List[t.Dict]:
602
651
  """Create wallet"""
603
652
  if operate.user_account is None:
@@ -620,7 +669,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
620
669
  return JSONResponse(content={"wallet": wallet.json, "mnemonic": mnemonic})
621
670
 
622
671
  @app.post("/api/wallet/private_key")
623
- @with_retries
624
672
  async def _get_private_key(request: Request) -> t.List[t.Dict]:
625
673
  """Get Master EOA private key."""
626
674
  if operate.user_account is None:
@@ -632,16 +680,51 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
632
680
  return USER_NOT_LOGGED_IN_ERROR
633
681
  if operate.password != password:
634
682
  return JSONResponse(
635
- content={"error": "Password is not valid."},
683
+ content={"error": MSG_INVALID_PASSWORD},
636
684
  status_code=HTTPStatus.UNAUTHORIZED,
637
685
  )
638
686
 
687
+ # TODO Should fail if not provided
639
688
  ledger_type = data.get("ledger_type", LedgerType.ETHEREUM.value)
640
689
  wallet = operate.wallet_manager.load(ledger_type=LedgerType(ledger_type))
641
690
  return JSONResponse(content={"private_key": wallet.crypto.private_key})
642
691
 
692
+ @app.post("/api/wallet/mnemonic")
693
+ async def _get_mnemonic(request: Request) -> t.List[t.Dict]:
694
+ """Get Master EOA mnemonic."""
695
+ if operate.user_account is None:
696
+ return ACCOUNT_NOT_FOUND_ERROR
697
+
698
+ data = await request.json()
699
+ password = data.get("password")
700
+ if operate.password is None:
701
+ return USER_NOT_LOGGED_IN_ERROR
702
+ if operate.password != password:
703
+ return JSONResponse(
704
+ content={"error": "Password is not valid."},
705
+ status_code=HTTPStatus.UNAUTHORIZED,
706
+ )
707
+
708
+ try:
709
+ ledger_type = LedgerType(data.get("ledger_type"))
710
+ wallet = operate.wallet_manager.load(ledger_type=ledger_type)
711
+ mnemonic = wallet.decrypt_mnemonic(password=password)
712
+ if mnemonic is None:
713
+ return JSONResponse(
714
+ content={"error": "Mnemonic file does not exist."},
715
+ status_code=HTTPStatus.NOT_FOUND,
716
+ )
717
+ return JSONResponse(content={"mnemonic": mnemonic})
718
+ except Exception as e: # pylint: disable=broad-except
719
+ logger.error(f"Failed to retrieve mnemonic: {e}\n{traceback.format_exc()}")
720
+ return JSONResponse(
721
+ content={
722
+ "error": "Failed to retrieve mnemonic. Please check the logs."
723
+ },
724
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
725
+ )
726
+
643
727
  @app.get("/api/extended/wallet")
644
- @with_retries
645
728
  async def _get_wallet_safe(request: Request) -> t.List[t.Dict]:
646
729
  """Get wallets."""
647
730
  wallets = []
@@ -650,7 +733,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
650
733
  return JSONResponse(content=wallets)
651
734
 
652
735
  @app.get("/api/wallet/safe")
653
- @with_retries
654
736
  async def _get_safes(request: Request) -> t.List[t.Dict]:
655
737
  """Create wallet safe"""
656
738
  all_safes = []
@@ -662,7 +744,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
662
744
  return JSONResponse(content=all_safes)
663
745
 
664
746
  @app.get("/api/wallet/safe/{chain}")
665
- @with_retries
666
747
  async def _get_safe(request: Request) -> t.List[t.Dict]:
667
748
  """Get safe address"""
668
749
  chain = Chain.from_string(request.path_params["chain"])
@@ -742,14 +823,16 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
742
823
  )
743
824
 
744
825
  if transfer_excess_assets:
745
- asset_addresses = {ZERO_ADDRESS} | {token[chain] for token in ERC20_TOKENS}
826
+ asset_addresses = {ZERO_ADDRESS} | {
827
+ token[chain] for token in ERC20_TOKENS.values()
828
+ }
746
829
  balances = get_assets_balances(
747
830
  ledger_api=ledger_api,
748
831
  addresses={wallet.address},
749
832
  asset_addresses=asset_addresses,
750
833
  raise_on_invalid_address=False,
751
834
  )[wallet.address]
752
- initial_funds = subtract_dicts(balances, DEFAULT_MASTER_EOA_FUNDS[chain])
835
+ initial_funds = subtract_dicts(balances, DEFAULT_EOA_TOPUPS[chain])
753
836
 
754
837
  logger.info(f"_create_safe Computed {initial_funds=}")
755
838
 
@@ -763,10 +846,13 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
763
846
 
764
847
  transfer_txs = {}
765
848
  for asset, amount in initial_funds.items():
849
+ if amount <= 0:
850
+ continue
851
+
766
852
  logger.info(
767
853
  f"_create_safe Transfer to={safe_address} {amount=} {chain} {asset=}"
768
854
  )
769
- tx_hash = wallet.transfer_asset(
855
+ tx_hash = wallet.transfer(
770
856
  to=safe_address,
771
857
  amount=int(amount),
772
858
  chain=chain,
@@ -792,7 +878,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
792
878
  )
793
879
 
794
880
  @app.put("/api/wallet/safe")
795
- @with_retries
796
881
  async def _update_safe(request: Request) -> t.List[t.Dict]:
797
882
  """Update wallet safe"""
798
883
  # TODO: Extract login check as decorator
@@ -844,14 +929,88 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
844
929
  }
845
930
  )
846
931
 
932
+ @app.post("/api/wallet/withdraw")
933
+ async def _wallet_withdraw(request: Request) -> JSONResponse:
934
+ """Withdraw from Master Safe / master eoa"""
935
+
936
+ if operate.password is None:
937
+ return USER_NOT_LOGGED_IN_ERROR
938
+
939
+ data = await request.json()
940
+ if not operate.user_account.is_valid(password=data["password"]):
941
+ return JSONResponse(
942
+ content={"error": MSG_INVALID_PASSWORD},
943
+ status_code=HTTPStatus.UNAUTHORIZED,
944
+ )
945
+
946
+ try:
947
+ withdraw_assets = data.get("withdraw_assets", {})
948
+ to = data["to"]
949
+ wallet_manager = operate.wallet_manager
950
+ transfer_txs: t.Dict[str, t.Dict[str, t.List[str]]] = {}
951
+
952
+ # TODO: Ensure master wallet has enough funding.
953
+ for chain_str, tokens in withdraw_assets.items():
954
+ chain = Chain(chain_str)
955
+ wallet = wallet_manager.load(chain.ledger_type)
956
+ transfer_txs[chain_str] = {}
957
+
958
+ # Process ERC20 first
959
+ for asset, amount in tokens.items():
960
+ if asset != ZERO_ADDRESS:
961
+ txs = wallet.transfer_from_safe_then_eoa(
962
+ to=to,
963
+ amount=int(amount),
964
+ chain=chain,
965
+ asset=asset,
966
+ )
967
+ transfer_txs[chain_str][asset] = txs
968
+
969
+ # Process native last
970
+ if ZERO_ADDRESS in tokens:
971
+ asset = ZERO_ADDRESS
972
+ amount = tokens[asset]
973
+ txs = wallet.transfer_from_safe_then_eoa(
974
+ to=to,
975
+ amount=int(amount),
976
+ chain=chain,
977
+ asset=asset,
978
+ )
979
+ transfer_txs[chain_str][asset] = txs
980
+
981
+ except InsufficientFundsException as e:
982
+ logger.error(f"Insufficient funds: {e}\n{traceback.format_exc()}")
983
+ return JSONResponse(
984
+ content={
985
+ "error": f"Failed to withdraw funds. Insufficient funds: {e}",
986
+ "transfer_txs": transfer_txs,
987
+ },
988
+ status_code=HTTPStatus.BAD_REQUEST,
989
+ )
990
+ except Exception as e: # pylint: disable=broad-except
991
+ logger.error(f"Failed to withdraw funds: {e}\n{traceback.format_exc()}")
992
+ return JSONResponse(
993
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
994
+ content={
995
+ "error": "Failed to withdraw funds. Please check the logs.",
996
+ "transfer_txs": transfer_txs,
997
+ },
998
+ )
999
+
1000
+ return JSONResponse(
1001
+ content={
1002
+ "error": None,
1003
+ "message": "Funds withdrawn successfully.",
1004
+ "transfer_txs": transfer_txs,
1005
+ }
1006
+ )
1007
+
847
1008
  @app.get("/api/v2/services")
848
- @with_retries
849
1009
  async def _get_services(request: Request) -> JSONResponse:
850
1010
  """Get all services."""
851
1011
  return JSONResponse(content=operate.service_manager().json)
852
1012
 
853
1013
  @app.get("/api/v2/services/validate")
854
- @with_retries
855
1014
  async def _validate_services(request: Request) -> JSONResponse:
856
1015
  """Validate all services."""
857
1016
  service_manager = operate.service_manager()
@@ -866,7 +1025,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
866
1025
  )
867
1026
 
868
1027
  @app.get("/api/v2/services/deployment")
869
- @with_retries
870
1028
  async def _get_services_deployment(request: Request) -> JSONResponse:
871
1029
  """Get a service deployment."""
872
1030
  service_manager = operate.service_manager()
@@ -879,7 +1037,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
879
1037
  return JSONResponse(content=output)
880
1038
 
881
1039
  @app.get("/api/v2/service/{service_config_id}")
882
- @with_retries
883
1040
  async def _get_service(request: Request) -> JSONResponse:
884
1041
  """Get a service."""
885
1042
  service_config_id = request.path_params["service_config_id"]
@@ -897,7 +1054,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
897
1054
  )
898
1055
 
899
1056
  @app.get("/api/v2/service/{service_config_id}/deployment")
900
- @with_retries
901
1057
  async def _get_service_deployment(request: Request) -> JSONResponse:
902
1058
  """Get a service deployment."""
903
1059
  service_config_id = request.path_params["service_config_id"]
@@ -911,7 +1067,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
911
1067
  return JSONResponse(content=deployment_json)
912
1068
 
913
1069
  @app.get("/api/v2/service/{service_config_id}/agent_performance")
914
- @with_retries
915
1070
  async def _get_agent_performance(request: Request) -> JSONResponse:
916
1071
  """Get the service refill requirements."""
917
1072
  service_config_id = request.path_params["service_config_id"]
@@ -925,8 +1080,22 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
925
1080
  .get_agent_performance()
926
1081
  )
927
1082
 
1083
+ @app.get("/api/v2/service/{service_config_id}/funding_requirements")
1084
+ async def _get_funding_requirements(request: Request) -> JSONResponse:
1085
+ """Get the service refill requirements."""
1086
+ service_config_id = request.path_params["service_config_id"]
1087
+
1088
+ if not operate.service_manager().exists(service_config_id=service_config_id):
1089
+ return service_not_found_error(service_config_id=service_config_id)
1090
+
1091
+ return JSONResponse(
1092
+ content=operate.service_manager().funding_requirements(
1093
+ service_config_id=service_config_id
1094
+ )
1095
+ )
1096
+
1097
+ # TODO deprecate
928
1098
  @app.get("/api/v2/service/{service_config_id}/refill_requirements")
929
- @with_retries
930
1099
  async def _get_refill_requirements(request: Request) -> JSONResponse:
931
1100
  """Get the service refill requirements."""
932
1101
  service_config_id = request.path_params["service_config_id"]
@@ -941,7 +1110,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
941
1110
  )
942
1111
 
943
1112
  @app.post("/api/v2/service")
944
- @with_retries
945
1113
  async def _create_services_v2(request: Request) -> JSONResponse:
946
1114
  """Create a service."""
947
1115
  if operate.password is None:
@@ -953,7 +1121,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
953
1121
  return JSONResponse(content=output.json)
954
1122
 
955
1123
  @app.post("/api/v2/service/{service_config_id}")
956
- @with_retries
957
1124
  async def _deploy_and_run_service(request: Request) -> JSONResponse:
958
1125
  """Deploy a service."""
959
1126
  if operate.password is None:
@@ -971,11 +1138,9 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
971
1138
  manager.deploy_service_onchain_from_safe(
972
1139
  service_config_id=service_config_id
973
1140
  )
974
- manager.fund_service(service_config_id=service_config_id)
975
1141
  manager.deploy_service_locally(service_config_id=service_config_id)
976
1142
 
977
1143
  await run_in_executor(_fn)
978
- schedule_funding_job(service_config_id=service_config_id)
979
1144
  schedule_healthcheck_job(service_config_id=service_config_id)
980
1145
 
981
1146
  return JSONResponse(
@@ -986,7 +1151,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
986
1151
 
987
1152
  @app.put("/api/v2/service/{service_config_id}")
988
1153
  @app.patch("/api/v2/service/{service_config_id}")
989
- @with_retries
990
1154
  async def _update_service(request: Request) -> JSONResponse:
991
1155
  """Update a service."""
992
1156
  if operate.password is None:
@@ -1022,7 +1186,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1022
1186
  return JSONResponse(content=output.json)
1023
1187
 
1024
1188
  @app.post("/api/v2/service/{service_config_id}/deployment/stop")
1025
- @with_retries
1026
1189
  async def _stop_service_locally(request: Request) -> JSONResponse:
1027
1190
  """Stop a service deployment."""
1028
1191
 
@@ -1041,11 +1204,10 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1041
1204
 
1042
1205
  await run_in_executor(deployment.stop)
1043
1206
  logger.info(f"Cancelling funding job for {service_config_id}")
1044
- cancel_funding_job(service_config_id=service_config_id)
1045
1207
  return JSONResponse(content=deployment.json)
1046
1208
 
1209
+ # TODO Deprecate
1047
1210
  @app.post("/api/v2/service/{service_config_id}/onchain/withdraw")
1048
- @with_retries
1049
1211
  async def _withdraw_onchain(request: Request) -> JSONResponse:
1050
1212
  """Withdraw all the funds from a service."""
1051
1213
 
@@ -1074,16 +1236,20 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1074
1236
  service_manager.terminate_service_on_chain_from_safe(
1075
1237
  service_config_id=service_config_id,
1076
1238
  chain=chain,
1239
+ )
1240
+ service_manager.drain(
1241
+ service_config_id=service_config_id,
1242
+ chain_str=chain,
1077
1243
  withdrawal_address=withdrawal_address,
1078
1244
  )
1079
1245
 
1080
- # drain the master safe and master signer for the home chain
1246
+ # drain the Master Safe and master signer for the home chain
1081
1247
  chain = Chain(service.home_chain)
1082
1248
  master_wallet = service_manager.wallet_manager.load(
1083
1249
  ledger_type=chain.ledger_type
1084
1250
  )
1085
1251
 
1086
- # drain the master safe
1252
+ # drain the Master Safe
1087
1253
  logger.info(
1088
1254
  f"Draining the Master Safe {master_wallet.safes[chain]} on chain {chain.value} (withdrawal address {withdrawal_address})."
1089
1255
  )
@@ -1113,8 +1279,137 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1113
1279
 
1114
1280
  return JSONResponse(content={"error": None, "message": "Withdrawal successful"})
1115
1281
 
1282
+ @app.post("/api/v2/service/{service_config_id}/terminate_and_withdraw")
1283
+ async def _terminate_and_withdraw(request: Request) -> JSONResponse:
1284
+ """Terminate the service and withdraw all the funds to Master Safe"""
1285
+
1286
+ if operate.password is None:
1287
+ return USER_NOT_LOGGED_IN_ERROR
1288
+
1289
+ service_config_id = request.path_params["service_config_id"]
1290
+ service_manager = operate.service_manager()
1291
+ wallet_manager = operate.wallet_manager
1292
+
1293
+ if not service_manager.exists(service_config_id=service_config_id):
1294
+ return service_not_found_error(service_config_id=service_config_id)
1295
+
1296
+ try:
1297
+ pause_all_services()
1298
+ service = service_manager.load(service_config_id=service_config_id)
1299
+ for chain in service.chain_configs:
1300
+ wallet = wallet_manager.load(Chain(chain).ledger_type)
1301
+ master_safe = wallet.safes[Chain(chain)]
1302
+ service_manager.terminate_service_on_chain_from_safe(
1303
+ service_config_id=service_config_id,
1304
+ chain=chain,
1305
+ )
1306
+ service_manager.drain(
1307
+ service_config_id=service_config_id,
1308
+ chain_str=chain,
1309
+ withdrawal_address=master_safe,
1310
+ )
1311
+
1312
+ except InsufficientFundsException as e:
1313
+ logger.error(
1314
+ f"Failed to terminate service and withdraw funds. Insufficient funds: {e}\n{traceback.format_exc()}"
1315
+ )
1316
+ return JSONResponse(
1317
+ content={
1318
+ "error": f"Failed to terminate service and withdraw funds. Insufficient funds: {e}"
1319
+ },
1320
+ status_code=HTTPStatus.BAD_REQUEST,
1321
+ )
1322
+ except Exception as e: # pylint: disable=broad-except
1323
+ logger.error(
1324
+ f"Terminate service and withdraw funds failed: {e}\n{traceback.format_exc()}"
1325
+ )
1326
+ return JSONResponse(
1327
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1328
+ content={
1329
+ "error": "Failed to terminate service and withdraw funds. Please check the logs."
1330
+ },
1331
+ )
1332
+
1333
+ return JSONResponse(
1334
+ content={
1335
+ "error": None,
1336
+ "message": "Terminate service and withdraw funds successful",
1337
+ }
1338
+ )
1339
+
1340
+ @app.post("/api/v2/service/{service_config_id}/fund")
1341
+ async def fund_service( # pylint: disable=too-many-return-statements
1342
+ request: Request,
1343
+ ) -> JSONResponse:
1344
+ """Fund agent or service safe via Master Safe"""
1345
+
1346
+ if operate.password is None:
1347
+ return USER_NOT_LOGGED_IN_ERROR
1348
+
1349
+ service_config_id = request.path_params["service_config_id"]
1350
+ service_manager = operate.service_manager()
1351
+
1352
+ if not service_manager.exists(service_config_id=service_config_id):
1353
+ return service_not_found_error(service_config_id=service_config_id)
1354
+
1355
+ try:
1356
+ data = await request.json()
1357
+ service_manager.fund_service(
1358
+ service_config_id=service_config_id,
1359
+ amounts=ChainAmounts(
1360
+ {
1361
+ chain_str: {
1362
+ address: {
1363
+ asset: int(amount) for asset, amount in assets.items()
1364
+ }
1365
+ for address, assets in addresses.items()
1366
+ }
1367
+ for chain_str, addresses in data.items()
1368
+ }
1369
+ ),
1370
+ )
1371
+ except ValueError as e:
1372
+ logger.error(
1373
+ f"Failed to fund from Master Safe: {e}\n{traceback.format_exc()}"
1374
+ )
1375
+ return JSONResponse(
1376
+ content={"error": str(e)},
1377
+ status_code=HTTPStatus.BAD_REQUEST,
1378
+ )
1379
+ except InsufficientFundsException as e:
1380
+ logger.error(
1381
+ f"Failed to fund from Master Safe. Insufficient funds: {e}\n{traceback.format_exc()}"
1382
+ )
1383
+ return JSONResponse(
1384
+ content={
1385
+ "error": f"Failed to fund from Master Safe. Insufficient funds: {e}"
1386
+ },
1387
+ status_code=HTTPStatus.BAD_REQUEST,
1388
+ )
1389
+ except FundingInProgressError as e:
1390
+ logger.error(
1391
+ f"Failed to fund from Master Safe: {e}\n{traceback.format_exc()}"
1392
+ )
1393
+ return JSONResponse(
1394
+ content={"error": str(e)},
1395
+ status_code=HTTPStatus.CONFLICT,
1396
+ )
1397
+ except Exception as e: # pylint: disable=broad-except
1398
+ logger.error(
1399
+ f"Failed to fund from Master Safe: {e}\n{traceback.format_exc()}"
1400
+ )
1401
+ return JSONResponse(
1402
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
1403
+ content={
1404
+ "error": "Failed to fund from Master Safe. Please check the logs."
1405
+ },
1406
+ )
1407
+
1408
+ return JSONResponse(
1409
+ content={"error": None, "message": "Funded from Master Safe successfully"}
1410
+ )
1411
+
1116
1412
  @app.post("/api/bridge/bridge_refill_requirements")
1117
- @with_retries
1118
1413
  async def _bridge_refill_requirements(request: Request) -> JSONResponse:
1119
1414
  """Get the bridge refill requirements."""
1120
1415
  if operate.password is None:
@@ -1149,7 +1444,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1149
1444
  )
1150
1445
 
1151
1446
  @app.post("/api/bridge/execute")
1152
- @with_retries
1153
1447
  async def _bridge_execute(request: Request) -> JSONResponse:
1154
1448
  """Execute bridge transaction."""
1155
1449
  if operate.password is None:
@@ -1179,14 +1473,12 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1179
1473
  )
1180
1474
 
1181
1475
  @app.get("/api/bridge/last_executed_bundle_id")
1182
- @with_retries
1183
1476
  async def _bridge_last_executed_bundle_id(request: Request) -> t.List[t.Dict]:
1184
1477
  """Get last executed bundle id."""
1185
1478
  content = {"id": operate.bridge_manager.last_executed_bundle_id()}
1186
1479
  return JSONResponse(content=content, status_code=HTTPStatus.OK)
1187
1480
 
1188
1481
  @app.get("/api/bridge/status/{id}")
1189
- @with_retries
1190
1482
  async def _bridge_status(request: Request) -> JSONResponse:
1191
1483
  """Get bridge transaction status."""
1192
1484
 
@@ -1215,7 +1507,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1215
1507
  )
1216
1508
 
1217
1509
  @app.post("/api/wallet/recovery/initiate")
1218
- @with_retries
1219
1510
  async def _wallet_recovery_initiate(request: Request) -> JSONResponse:
1220
1511
  """Initiate wallet recovery."""
1221
1512
  if operate.user_account is None:
@@ -1259,7 +1550,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1259
1550
  )
1260
1551
 
1261
1552
  @app.post("/api/wallet/recovery/complete")
1262
- @with_retries
1263
1553
  async def _wallet_recovery_complete(request: Request) -> JSONResponse:
1264
1554
  """Complete wallet recovery."""
1265
1555
  if operate.user_account is None:
@@ -1326,6 +1616,11 @@ def _daemon(
1326
1616
  ] = None,
1327
1617
  ) -> None:
1328
1618
  """Launch operate daemon."""
1619
+ # try automatically shutdown previous instance before creating the app
1620
+ if TRY_TO_SHUTDOWN_PREVIOUS_INSTANCE:
1621
+ app_single_instance = AppSingleInstance(port)
1622
+ app_single_instance.shutdown_previous_instance()
1623
+
1329
1624
  app = create_app(home=home)
1330
1625
 
1331
1626
  config_kwargs = {
@@ -1345,23 +1640,6 @@ def _daemon(
1345
1640
  }
1346
1641
  )
1347
1642
 
1348
- # try automatically shutdown previous instance
1349
- if TRY_TO_SHUTDOWN_PREVIOUS_INSTANCE:
1350
- url = f"http{'s' if ssl_keyfile and ssl_certfile else ''}://{host}:{port}/shutdown"
1351
- logger.info(f"trying to stop previous instance with {url}")
1352
- try:
1353
- requests.get(
1354
- f"https://{host}:{port}/shutdown", timeout=3, verify=False # nosec
1355
- )
1356
- except requests.exceptions.SSLError:
1357
- logger.warning("SSL failed, trying HTTP fallback...")
1358
- try:
1359
- requests.get(f"http://{host}:{port}/shutdown", timeout=3)
1360
- except Exception: # pylint: disable=broad-except
1361
- logger.exception("Failed to stop previous instance")
1362
- except Exception: # pylint: disable=broad-except
1363
- logger.exception("Failed to stop previous instance")
1364
-
1365
1643
  server = Server(Config(**config_kwargs))
1366
1644
  app._server = server # pylint: disable=protected-access
1367
1645
  server.run()