olas-operate-middleware 0.10.20__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.
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,10 +31,10 @@ 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
40
  from fastapi import FastAPI, Request
@@ -47,22 +48,35 @@ from operate import __version__, services
47
48
  from operate.account.user import UserAccount
48
49
  from operate.bridge.bridge_manager import BridgeManager
49
50
  from operate.constants import (
51
+ AGENT_RUNNER_PREFIX,
52
+ DEPLOYMENT_DIR,
50
53
  KEYS_DIR,
51
54
  MIN_PASSWORD_LENGTH,
55
+ MSG_INVALID_MNEMONIC,
56
+ MSG_INVALID_PASSWORD,
57
+ MSG_NEW_PASSWORD_MISSING,
58
+ OPERATE,
52
59
  OPERATE_HOME,
53
60
  SERVICES_DIR,
54
61
  USER_JSON,
62
+ VERSION_FILE,
55
63
  WALLETS_DIR,
56
64
  WALLET_RECOVERY_DIR,
57
65
  ZERO_ADDRESS,
58
66
  )
59
67
  from operate.ledger.profiles import (
60
- DEFAULT_MASTER_EOA_FUNDS,
68
+ DEFAULT_EOA_TOPUPS,
61
69
  DEFAULT_NEW_SAFE_FUNDS,
62
70
  ERC20_TOKENS,
63
71
  )
64
72
  from operate.migration import MigrationManager
65
- 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
+ )
66
80
  from operate.quickstart.analyse_logs import analyse_logs
67
81
  from operate.quickstart.claim_staking_rewards import claim_staking_rewards
68
82
  from operate.quickstart.reset_configs import reset_configs
@@ -72,16 +86,38 @@ from operate.quickstart.run_service import run_service
72
86
  from operate.quickstart.stop_service import stop_service
73
87
  from operate.quickstart.terminate_on_chain_service import terminate_service
74
88
  from operate.services.deployment_runner import stop_deployment_manager
89
+ from operate.services.funding_manager import FundingInProgressError, FundingManager
75
90
  from operate.services.health_checker import HealthChecker
91
+ from operate.settings import Settings
76
92
  from operate.utils import subtract_dicts
77
93
  from operate.utils.gnosis import get_assets_balances
78
- from operate.wallet.master import MasterWalletManager
94
+ from operate.utils.single_instance import AppSingleInstance, ParentWatchdog
95
+ from operate.wallet.master import InsufficientFundsException, MasterWalletManager
79
96
  from operate.wallet.wallet_recovery_manager import (
80
97
  WalletRecoveryError,
81
98
  WalletRecoveryManager,
82
99
  )
83
100
 
84
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
+
85
121
  DEFAULT_MAX_RETRIES = 3
86
122
  USER_NOT_LOGGED_IN_ERROR = JSONResponse(
87
123
  content={"error": "User not logged in."}, status_code=HTTPStatus.UNAUTHORIZED
@@ -115,17 +151,28 @@ class OperateApp:
115
151
  home: t.Optional[Path] = None,
116
152
  ) -> None:
117
153
  """Initialize object."""
118
- super().__init__()
119
154
  self._path = (home or OPERATE_HOME).resolve()
120
155
  self._services = self._path / SERVICES_DIR
121
156
  self._keys = self._path / KEYS_DIR
122
157
  self.setup()
158
+ self._backup_operate_if_new_version()
123
159
 
124
160
  services.manage.KeysManager(
125
161
  path=self._keys,
126
162
  logger=logger,
127
163
  )
128
- 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
+ )
129
176
 
130
177
  mm = MigrationManager(self._path, logger)
131
178
  mm.migrate_user_account()
@@ -133,6 +180,59 @@ class OperateApp:
133
180
  mm.migrate_wallets(self.wallet_manager)
134
181
  mm.migrate_qs_configs()
135
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
+
136
236
  def create_user_account(self, password: str) -> UserAccount:
137
237
  """Create a user account."""
138
238
  self.password = password
@@ -145,13 +245,13 @@ class OperateApp:
145
245
  """Updates current password"""
146
246
 
147
247
  if not new_password:
148
- raise ValueError("'new_password' is required.")
248
+ raise ValueError(MSG_NEW_PASSWORD_MISSING)
149
249
 
150
250
  if not (
151
251
  self.user_account.is_valid(old_password)
152
252
  and self.wallet_manager.is_password_valid(old_password)
153
253
  ):
154
- raise ValueError("Password is not valid.")
254
+ raise ValueError(MSG_INVALID_PASSWORD)
155
255
 
156
256
  wallet_manager = self.wallet_manager
157
257
  wallet_manager.password = old_password
@@ -162,11 +262,11 @@ class OperateApp:
162
262
  """Updates current password using the mnemonic"""
163
263
 
164
264
  if not new_password:
165
- raise ValueError("'new_password' is required.")
265
+ raise ValueError(MSG_NEW_PASSWORD_MISSING)
166
266
 
167
267
  mnemonic = mnemonic.strip().lower()
168
268
  if not self.wallet_manager.is_mnemonic_valid(mnemonic):
169
- raise ValueError("Seed phrase is not valid.")
269
+ raise ValueError(MSG_INVALID_MNEMONIC)
170
270
 
171
271
  wallet_manager = self.wallet_manager
172
272
  wallet_manager.update_password_with_mnemonic(mnemonic, new_password)
@@ -179,10 +279,16 @@ class OperateApp:
179
279
  return services.manage.ServiceManager(
180
280
  path=self._services,
181
281
  wallet_manager=self.wallet_manager,
282
+ funding_manager=self.funding_manager,
182
283
  logger=logger,
183
284
  skip_dependency_check=skip_dependency_check,
184
285
  )
185
286
 
287
+ @property
288
+ def funding_manager(self) -> FundingManager:
289
+ """Load funding manager."""
290
+ return self._funding_manager
291
+
186
292
  @property
187
293
  def user_account(self) -> t.Optional[UserAccount]:
188
294
  """Load user account."""
@@ -193,13 +299,7 @@ class OperateApp:
193
299
  @property
194
300
  def wallet_manager(self) -> MasterWalletManager:
195
301
  """Load wallet manager."""
196
- manager = MasterWalletManager(
197
- path=self._path / WALLETS_DIR,
198
- password=self.password,
199
- logger=logger,
200
- )
201
- manager.setup()
202
- return manager
302
+ return self._wallet_manager
203
303
 
204
304
  @property
205
305
  def wallet_recoverey_manager(self) -> WalletRecoveryManager:
@@ -232,7 +332,7 @@ class OperateApp:
232
332
  """Json representation of the app."""
233
333
  return {
234
334
  "name": "Operate HTTP server",
235
- "version": "0.1.0.rc0",
335
+ "version": (__version__),
236
336
  "home": str(self._path),
237
337
  }
238
338
 
@@ -252,7 +352,7 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
252
352
  logger.warning("Healthchecker is off!!!")
253
353
  operate = OperateApp(home=home)
254
354
 
255
- funding_jobs: t.Dict[str, asyncio.Task] = {}
355
+ funding_job: t.Optional[asyncio.Task] = None
256
356
  health_checker = HealthChecker(
257
357
  operate.service_manager(), number_of_fails=number_of_fails, logger=logger
258
358
  )
@@ -272,25 +372,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
272
372
  raise exception
273
373
  return res
274
374
 
275
- def schedule_funding_job(
276
- service_config_id: str,
277
- from_safe: bool = True,
278
- ) -> None:
279
- """Schedule a funding job."""
280
- logger.info(f"Starting funding job for {service_config_id}")
281
- if service_config_id in funding_jobs:
282
- logger.info(f"Cancelling existing funding job for {service_config_id}")
283
- cancel_funding_job(service_config_id=service_config_id)
284
-
285
- loop = asyncio.get_running_loop()
286
- funding_jobs[service_config_id] = loop.create_task(
287
- operate.service_manager().funding_job(
288
- service_config_id=service_config_id,
289
- loop=loop,
290
- from_safe=from_safe,
291
- )
292
- )
293
-
294
375
  def schedule_healthcheck_job(
295
376
  service_config_id: str,
296
377
  ) -> None:
@@ -299,13 +380,29 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
299
380
  # dont start health checker if it's switched off
300
381
  health_checker.start_for_service(service_config_id)
301
382
 
302
- 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:
303
398
  """Cancel funding job."""
304
- if service_config_id not in funding_jobs:
399
+ nonlocal funding_job
400
+ if funding_job is None:
305
401
  return
306
- status = funding_jobs[service_config_id].cancel()
402
+
403
+ status = funding_job.cancel()
307
404
  if not status:
308
- logger.info(f"Funding job cancellation for {service_config_id} failed")
405
+ logger.info("Funding job cancellation failed")
309
406
 
310
407
  def pause_all_services_on_startup() -> None:
311
408
  logger.info("Stopping services on startup...")
@@ -344,7 +441,6 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
344
441
  f"Deployment {service_config_id} stopping failed. but continue"
345
442
  )
346
443
  logger.info(f"Cancelling funding job for {service_config_id}")
347
- cancel_funding_job(service_config_id=service_config_id)
348
444
  health_checker.stop_for_service(service_config_id=service_config_id)
349
445
 
350
446
  def pause_all_services_on_exit(signum: int, frame: t.Optional[FrameType]) -> None:
@@ -363,49 +459,25 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
363
459
 
364
460
  @asynccontextmanager
365
461
  async def lifespan(app: FastAPI):
366
- # Load the ML model
367
- watchdog_task = set_parent_watchdog(app)
368
- yield
369
- # Clean up the ML models and release the resources
370
-
371
- with suppress(Exception):
372
- watchdog_task.cancel()
373
-
374
- with suppress(Exception):
375
- await watchdog_task
376
-
377
- app = FastAPI(lifespan=lifespan)
378
-
379
- def set_parent_watchdog(app):
380
462
  async def stop_app():
381
- logger.info("Stopping services on demand...")
382
-
383
- stop_deployment_manager() # TODO: make it async?
463
+ logger.info("Stopping services due to parent death...")
464
+ stop_deployment_manager()
384
465
  await run_in_executor(pause_all_services)
385
-
386
- logger.info("Stopping services on demand done.")
387
466
  app._server.should_exit = True # pylint: disable=protected-access
388
- logger.info("Stopping app.")
467
+ logger.info("App stopped due to parent death.")
389
468
 
390
- async def check_parent_alive():
391
- try:
392
- logger.info(
393
- f"Parent alive check task started: ppid is {os.getppid()} and own pid is {os.getpid()}"
394
- )
395
- while True:
396
- parent = psutil.Process(os.getpid()).parent()
397
- if not parent:
398
- logger.info("Parent is not alive, going to stop")
399
- await stop_app()
400
- return
401
- await asyncio.sleep(3)
469
+ watchdog = ParentWatchdog(on_parent_exit=stop_app)
470
+ watchdog.start()
402
471
 
403
- except Exception: # pylint: disable=broad-except
404
- logger.exception("Parent alive check crashed!")
472
+ yield # --- app is running ---
405
473
 
406
- loop = asyncio.get_running_loop()
407
- task = loop.create_task(check_parent_alive())
408
- return task
474
+ with suppress(Exception):
475
+ cancel_funding_job()
476
+
477
+ with suppress(Exception):
478
+ await watchdog.stop()
479
+
480
+ app = FastAPI(lifespan=lifespan)
409
481
 
410
482
  app.add_middleware(
411
483
  CORSMiddleware,
@@ -446,6 +518,11 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
446
518
  """Get API info."""
447
519
  return JSONResponse(content=operate.json)
448
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
+
449
526
  @app.get("/api/account")
450
527
  async def _get_account(request: Request) -> t.Dict:
451
528
  """Get account information."""
@@ -550,11 +627,12 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
550
627
  data = await request.json()
551
628
  if not operate.user_account.is_valid(password=data["password"]):
552
629
  return JSONResponse(
553
- content={"error": "Password is not valid."},
630
+ content={"error": MSG_INVALID_PASSWORD},
554
631
  status_code=HTTPStatus.UNAUTHORIZED,
555
632
  )
556
633
 
557
634
  operate.password = data["password"]
635
+ schedule_funding_job()
558
636
  return JSONResponse(
559
637
  content={"message": "Login successful."},
560
638
  status_code=HTTPStatus.OK,
@@ -602,14 +680,50 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
602
680
  return USER_NOT_LOGGED_IN_ERROR
603
681
  if operate.password != password:
604
682
  return JSONResponse(
605
- content={"error": "Password is not valid."},
683
+ content={"error": MSG_INVALID_PASSWORD},
606
684
  status_code=HTTPStatus.UNAUTHORIZED,
607
685
  )
608
686
 
687
+ # TODO Should fail if not provided
609
688
  ledger_type = data.get("ledger_type", LedgerType.ETHEREUM.value)
610
689
  wallet = operate.wallet_manager.load(ledger_type=LedgerType(ledger_type))
611
690
  return JSONResponse(content={"private_key": wallet.crypto.private_key})
612
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
+
613
727
  @app.get("/api/extended/wallet")
614
728
  async def _get_wallet_safe(request: Request) -> t.List[t.Dict]:
615
729
  """Get wallets."""
@@ -709,14 +823,16 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
709
823
  )
710
824
 
711
825
  if transfer_excess_assets:
712
- 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
+ }
713
829
  balances = get_assets_balances(
714
830
  ledger_api=ledger_api,
715
831
  addresses={wallet.address},
716
832
  asset_addresses=asset_addresses,
717
833
  raise_on_invalid_address=False,
718
834
  )[wallet.address]
719
- initial_funds = subtract_dicts(balances, DEFAULT_MASTER_EOA_FUNDS[chain])
835
+ initial_funds = subtract_dicts(balances, DEFAULT_EOA_TOPUPS[chain])
720
836
 
721
837
  logger.info(f"_create_safe Computed {initial_funds=}")
722
838
 
@@ -730,10 +846,13 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
730
846
 
731
847
  transfer_txs = {}
732
848
  for asset, amount in initial_funds.items():
849
+ if amount <= 0:
850
+ continue
851
+
733
852
  logger.info(
734
853
  f"_create_safe Transfer to={safe_address} {amount=} {chain} {asset=}"
735
854
  )
736
- tx_hash = wallet.transfer_asset(
855
+ tx_hash = wallet.transfer(
737
856
  to=safe_address,
738
857
  amount=int(amount),
739
858
  chain=chain,
@@ -810,6 +929,82 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
810
929
  }
811
930
  )
812
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
+
813
1008
  @app.get("/api/v2/services")
814
1009
  async def _get_services(request: Request) -> JSONResponse:
815
1010
  """Get all services."""
@@ -885,6 +1080,21 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
885
1080
  .get_agent_performance()
886
1081
  )
887
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
888
1098
  @app.get("/api/v2/service/{service_config_id}/refill_requirements")
889
1099
  async def _get_refill_requirements(request: Request) -> JSONResponse:
890
1100
  """Get the service refill requirements."""
@@ -928,11 +1138,9 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
928
1138
  manager.deploy_service_onchain_from_safe(
929
1139
  service_config_id=service_config_id
930
1140
  )
931
- manager.fund_service(service_config_id=service_config_id)
932
1141
  manager.deploy_service_locally(service_config_id=service_config_id)
933
1142
 
934
1143
  await run_in_executor(_fn)
935
- schedule_funding_job(service_config_id=service_config_id)
936
1144
  schedule_healthcheck_job(service_config_id=service_config_id)
937
1145
 
938
1146
  return JSONResponse(
@@ -996,9 +1204,9 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
996
1204
 
997
1205
  await run_in_executor(deployment.stop)
998
1206
  logger.info(f"Cancelling funding job for {service_config_id}")
999
- cancel_funding_job(service_config_id=service_config_id)
1000
1207
  return JSONResponse(content=deployment.json)
1001
1208
 
1209
+ # TODO Deprecate
1002
1210
  @app.post("/api/v2/service/{service_config_id}/onchain/withdraw")
1003
1211
  async def _withdraw_onchain(request: Request) -> JSONResponse:
1004
1212
  """Withdraw all the funds from a service."""
@@ -1028,16 +1236,20 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1028
1236
  service_manager.terminate_service_on_chain_from_safe(
1029
1237
  service_config_id=service_config_id,
1030
1238
  chain=chain,
1239
+ )
1240
+ service_manager.drain(
1241
+ service_config_id=service_config_id,
1242
+ chain_str=chain,
1031
1243
  withdrawal_address=withdrawal_address,
1032
1244
  )
1033
1245
 
1034
- # drain the master safe and master signer for the home chain
1246
+ # drain the Master Safe and master signer for the home chain
1035
1247
  chain = Chain(service.home_chain)
1036
1248
  master_wallet = service_manager.wallet_manager.load(
1037
1249
  ledger_type=chain.ledger_type
1038
1250
  )
1039
1251
 
1040
- # drain the master safe
1252
+ # drain the Master Safe
1041
1253
  logger.info(
1042
1254
  f"Draining the Master Safe {master_wallet.safes[chain]} on chain {chain.value} (withdrawal address {withdrawal_address})."
1043
1255
  )
@@ -1067,6 +1279,136 @@ def create_app( # pylint: disable=too-many-locals, unused-argument, too-many-st
1067
1279
 
1068
1280
  return JSONResponse(content={"error": None, "message": "Withdrawal successful"})
1069
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
+
1070
1412
  @app.post("/api/bridge/bridge_refill_requirements")
1071
1413
  async def _bridge_refill_requirements(request: Request) -> JSONResponse:
1072
1414
  """Get the bridge refill requirements."""
@@ -1274,6 +1616,11 @@ def _daemon(
1274
1616
  ] = None,
1275
1617
  ) -> None:
1276
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
+
1277
1624
  app = create_app(home=home)
1278
1625
 
1279
1626
  config_kwargs = {
@@ -1293,23 +1640,6 @@ def _daemon(
1293
1640
  }
1294
1641
  )
1295
1642
 
1296
- # try automatically shutdown previous instance
1297
- if TRY_TO_SHUTDOWN_PREVIOUS_INSTANCE:
1298
- url = f"http{'s' if ssl_keyfile and ssl_certfile else ''}://{host}:{port}/shutdown"
1299
- logger.info(f"trying to stop previous instance with {url}")
1300
- try:
1301
- requests.get(
1302
- f"https://{host}:{port}/shutdown", timeout=3, verify=False # nosec
1303
- )
1304
- except requests.exceptions.SSLError:
1305
- logger.warning("SSL failed, trying HTTP fallback...")
1306
- try:
1307
- requests.get(f"http://{host}:{port}/shutdown", timeout=3)
1308
- except Exception: # pylint: disable=broad-except
1309
- logger.exception("Failed to stop previous instance")
1310
- except Exception: # pylint: disable=broad-except
1311
- logger.exception("Failed to stop previous instance")
1312
-
1313
1643
  server = Server(Config(**config_kwargs))
1314
1644
  app._server = server # pylint: disable=protected-access
1315
1645
  server.run()