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.
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/METADATA +3 -1
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/RECORD +25 -22
- operate/bridge/bridge_manager.py +10 -12
- operate/bridge/providers/native_bridge_provider.py +1 -1
- operate/bridge/providers/provider.py +21 -34
- operate/cli.py +438 -108
- operate/constants.py +20 -0
- operate/ledger/__init__.py +55 -5
- operate/ledger/profiles.py +79 -11
- operate/operate_types.py +205 -2
- operate/quickstart/run_service.py +1 -1
- operate/services/agent_runner.py +5 -3
- operate/services/deployment_runner.py +3 -0
- operate/services/funding_manager.py +904 -0
- operate/services/manage.py +165 -306
- operate/services/protocol.py +392 -140
- operate/services/service.py +81 -5
- operate/settings.py +70 -0
- operate/utils/gnosis.py +79 -24
- operate/utils/single_instance.py +226 -0
- operate/wallet/master.py +214 -177
- operate/wallet/wallet_recovery_manager.py +5 -5
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/WHEEL +0 -0
- {olas_operate_middleware-0.10.20.dist-info → olas_operate_middleware-0.11.0.dist-info}/entry_points.txt +0 -0
- {olas_operate_middleware-0.10.20.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,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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
399
|
+
nonlocal funding_job
|
|
400
|
+
if funding_job is None:
|
|
305
401
|
return
|
|
306
|
-
|
|
402
|
+
|
|
403
|
+
status = funding_job.cancel()
|
|
307
404
|
if not status:
|
|
308
|
-
logger.info(
|
|
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
|
|
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("
|
|
467
|
+
logger.info("App stopped due to parent death.")
|
|
389
468
|
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
404
|
-
logger.exception("Parent alive check crashed!")
|
|
472
|
+
yield # --- app is running ---
|
|
405
473
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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":
|
|
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":
|
|
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} | {
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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()
|