olas-operate-middleware 0.1.0rc59__py3-none-any.whl → 0.13.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. olas_operate_middleware-0.13.2.dist-info/METADATA +75 -0
  2. olas_operate_middleware-0.13.2.dist-info/RECORD +101 -0
  3. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info}/WHEEL +1 -1
  4. operate/__init__.py +17 -0
  5. operate/account/user.py +35 -9
  6. operate/bridge/bridge_manager.py +470 -0
  7. operate/bridge/providers/lifi_provider.py +377 -0
  8. operate/bridge/providers/native_bridge_provider.py +677 -0
  9. operate/bridge/providers/provider.py +469 -0
  10. operate/bridge/providers/relay_provider.py +457 -0
  11. operate/cli.py +1565 -417
  12. operate/constants.py +60 -12
  13. operate/data/README.md +19 -0
  14. operate/data/contracts/{service_staking_token → dual_staking_token}/__init__.py +2 -2
  15. operate/data/contracts/dual_staking_token/build/DualStakingToken.json +443 -0
  16. operate/data/contracts/dual_staking_token/contract.py +132 -0
  17. operate/data/contracts/dual_staking_token/contract.yaml +23 -0
  18. operate/{ledger/base.py → data/contracts/foreign_omnibridge/__init__.py} +2 -19
  19. operate/data/contracts/foreign_omnibridge/build/ForeignOmnibridge.json +1372 -0
  20. operate/data/contracts/foreign_omnibridge/contract.py +130 -0
  21. operate/data/contracts/foreign_omnibridge/contract.yaml +23 -0
  22. operate/{ledger/solana.py → data/contracts/home_omnibridge/__init__.py} +2 -20
  23. operate/data/contracts/home_omnibridge/build/HomeOmnibridge.json +1421 -0
  24. operate/data/contracts/home_omnibridge/contract.py +80 -0
  25. operate/data/contracts/home_omnibridge/contract.yaml +23 -0
  26. operate/data/contracts/l1_standard_bridge/__init__.py +20 -0
  27. operate/data/contracts/l1_standard_bridge/build/L1StandardBridge.json +831 -0
  28. operate/data/contracts/l1_standard_bridge/contract.py +158 -0
  29. operate/data/contracts/l1_standard_bridge/contract.yaml +23 -0
  30. operate/data/contracts/l2_standard_bridge/__init__.py +20 -0
  31. operate/data/contracts/l2_standard_bridge/build/L2StandardBridge.json +626 -0
  32. operate/data/contracts/l2_standard_bridge/contract.py +130 -0
  33. operate/data/contracts/l2_standard_bridge/contract.yaml +23 -0
  34. operate/data/contracts/mech_activity/__init__.py +20 -0
  35. operate/data/contracts/mech_activity/build/MechActivity.json +111 -0
  36. operate/data/contracts/mech_activity/contract.py +44 -0
  37. operate/data/contracts/mech_activity/contract.yaml +23 -0
  38. operate/data/contracts/optimism_mintable_erc20/__init__.py +20 -0
  39. operate/data/contracts/optimism_mintable_erc20/build/OptimismMintableERC20.json +491 -0
  40. operate/data/contracts/optimism_mintable_erc20/contract.py +45 -0
  41. operate/data/contracts/optimism_mintable_erc20/contract.yaml +23 -0
  42. operate/data/contracts/recovery_module/__init__.py +20 -0
  43. operate/data/contracts/recovery_module/build/RecoveryModule.json +811 -0
  44. operate/data/contracts/recovery_module/contract.py +61 -0
  45. operate/data/contracts/recovery_module/contract.yaml +23 -0
  46. operate/data/contracts/requester_activity_checker/__init__.py +20 -0
  47. operate/data/contracts/requester_activity_checker/build/RequesterActivityChecker.json +111 -0
  48. operate/data/contracts/requester_activity_checker/contract.py +33 -0
  49. operate/data/contracts/requester_activity_checker/contract.yaml +23 -0
  50. operate/data/contracts/staking_token/__init__.py +20 -0
  51. operate/data/contracts/staking_token/build/StakingToken.json +1336 -0
  52. operate/data/contracts/{service_staking_token → staking_token}/contract.py +27 -13
  53. operate/data/contracts/staking_token/contract.yaml +23 -0
  54. operate/data/contracts/uniswap_v2_erc20/contract.yaml +3 -1
  55. operate/data/contracts/uniswap_v2_erc20/tests/__init__.py +20 -0
  56. operate/data/contracts/uniswap_v2_erc20/tests/test_contract.py +363 -0
  57. operate/keys.py +118 -33
  58. operate/ledger/__init__.py +159 -56
  59. operate/ledger/profiles.py +321 -18
  60. operate/migration.py +555 -0
  61. operate/{http → operate_http}/__init__.py +3 -2
  62. operate/{http → operate_http}/exceptions.py +6 -4
  63. operate/operate_types.py +544 -0
  64. operate/pearl.py +13 -1
  65. operate/quickstart/analyse_logs.py +118 -0
  66. operate/quickstart/claim_staking_rewards.py +104 -0
  67. operate/quickstart/reset_configs.py +106 -0
  68. operate/quickstart/reset_password.py +70 -0
  69. operate/quickstart/reset_staking.py +145 -0
  70. operate/quickstart/run_service.py +726 -0
  71. operate/quickstart/stop_service.py +72 -0
  72. operate/quickstart/terminate_on_chain_service.py +83 -0
  73. operate/quickstart/utils.py +298 -0
  74. operate/resource.py +62 -3
  75. operate/services/agent_runner.py +202 -0
  76. operate/services/deployment_runner.py +868 -0
  77. operate/services/funding_manager.py +929 -0
  78. operate/services/health_checker.py +280 -0
  79. operate/services/manage.py +2356 -620
  80. operate/services/protocol.py +1246 -340
  81. operate/services/service.py +756 -391
  82. operate/services/utils/mech.py +103 -0
  83. operate/services/utils/tendermint.py +86 -12
  84. operate/settings.py +70 -0
  85. operate/utils/__init__.py +135 -0
  86. operate/utils/gnosis.py +407 -80
  87. operate/utils/single_instance.py +226 -0
  88. operate/utils/ssl.py +133 -0
  89. operate/wallet/master.py +708 -123
  90. operate/wallet/wallet_recovery_manager.py +507 -0
  91. olas_operate_middleware-0.1.0rc59.dist-info/METADATA +0 -304
  92. olas_operate_middleware-0.1.0rc59.dist-info/RECORD +0 -41
  93. operate/data/contracts/service_staking_token/build/ServiceStakingToken.json +0 -1273
  94. operate/data/contracts/service_staking_token/contract.yaml +0 -23
  95. operate/ledger/ethereum.py +0 -48
  96. operate/types.py +0 -260
  97. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info}/entry_points.txt +0 -0
  98. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,507 @@
1
+ # -*- coding: utf-8 -*-
2
+ # ------------------------------------------------------------------------------
3
+ #
4
+ # Copyright 2025 Valory AG
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # ------------------------------------------------------------------------------
19
+
20
+ """Wallet recovery manager"""
21
+
22
+ import enum
23
+ import shutil
24
+ import typing as t
25
+ import uuid
26
+ from dataclasses import dataclass, field
27
+ from logging import Logger
28
+ from pathlib import Path
29
+
30
+ from operate.account.user import UserAccount
31
+ from operate.constants import (
32
+ KEYS_DIR,
33
+ MSG_INVALID_PASSWORD,
34
+ USER_JSON,
35
+ WALLETS_DIR,
36
+ ZERO_ADDRESS,
37
+ )
38
+ from operate.keys import KeysManager
39
+ from operate.ledger import get_default_ledger_api
40
+ from operate.ledger.profiles import DEFAULT_RECOVERY_TOPUPS
41
+ from operate.operate_types import ChainAmounts
42
+ from operate.resource import LocalResource
43
+ from operate.services.manage import ServiceManager
44
+ from operate.utils.gnosis import get_asset_balance, get_owners
45
+ from operate.wallet.master import MasterWalletManager
46
+
47
+
48
+ RECOVERY_BUNDLE_PREFIX = "eb-"
49
+ RECOVERY_NEW_OBJECTS_DIR = "new"
50
+ RECOVERY_OLD_OBJECTS_DIR = "old"
51
+
52
+
53
+ class WalletRecoveryStatus(str, enum.Enum):
54
+ """ProviderRequestStatus"""
55
+
56
+ NOT_PREPARED = "NOT_PREPARED"
57
+ PREPARED = "PREPARED"
58
+ IN_PROGRESS = "IN_PROGRESS"
59
+ COMPLETED = "COMPLETED"
60
+
61
+ def __str__(self) -> str:
62
+ """__str__"""
63
+ return self.value
64
+
65
+
66
+ class WalletRecoveryError(Exception):
67
+ """WalletRecoveryError"""
68
+
69
+
70
+ @dataclass
71
+ class WalletRecoveryManagerData(LocalResource):
72
+ """BridgeManagerData"""
73
+
74
+ path: Path
75
+ version: int = 1
76
+ last_prepared_bundle_id: t.Optional[str] = None
77
+ new_agent_keys: t.Dict[str, t.Dict[str, str]] = field(default_factory=dict)
78
+
79
+ _file = "wallet_recovery.json"
80
+
81
+
82
+ class WalletRecoveryManager:
83
+ """WalletRecoveryManager"""
84
+
85
+ def __init__(
86
+ self,
87
+ path: Path,
88
+ logger: Logger,
89
+ wallet_manager: MasterWalletManager,
90
+ service_manager: ServiceManager,
91
+ ) -> None:
92
+ """Initialize wallet recovery manager."""
93
+ self.path = path
94
+ self.logger = logger
95
+ self.wallet_manager = wallet_manager
96
+ self.service_manager = service_manager
97
+
98
+ path.mkdir(parents=True, exist_ok=True)
99
+ file = path / WalletRecoveryManagerData._file
100
+ if not file.exists():
101
+ WalletRecoveryManagerData(path=path).store()
102
+
103
+ self.data: WalletRecoveryManagerData = t.cast(
104
+ WalletRecoveryManagerData, WalletRecoveryManagerData.load(path)
105
+ )
106
+
107
+ def prepare_recovery( # pylint: disable=too-many-locals
108
+ self, new_password: str
109
+ ) -> t.Dict:
110
+ """Prepare recovery"""
111
+ self.logger.info("[WALLET RECOVERY MANAGER] Prepare recovery started.")
112
+
113
+ try:
114
+ _ = self.wallet_manager.password
115
+ except ValueError:
116
+ pass
117
+ else:
118
+ raise WalletRecoveryError(
119
+ "Wallet recovery cannot be executed while logged in."
120
+ )
121
+
122
+ if not new_password:
123
+ raise ValueError("'new_password' must be a non-empty string.")
124
+
125
+ for wallet in self.wallet_manager:
126
+ for chain, safe in wallet.safes.items():
127
+ ledger_api = get_default_ledger_api(chain)
128
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
129
+
130
+ if wallet.address not in owners:
131
+ self.logger.warning(
132
+ f"Wallet {wallet.address} is not an owner of Safe {safe} on {chain.value}. (Interrupted swapping of Safe owners?)"
133
+ )
134
+
135
+ backup_owners = set(owners) - {wallet.address}
136
+ if len(backup_owners) < 1:
137
+ raise WalletRecoveryError(
138
+ f"Safe {safe} on {chain.value} has less than 1 backup owner."
139
+ )
140
+
141
+ last_prepared_bundle_id = self.data.last_prepared_bundle_id
142
+ if (
143
+ last_prepared_bundle_id is not None
144
+ and self.status()["num_safes_with_new_wallet"] > 0
145
+ ):
146
+ self.logger.info(
147
+ f"[WALLET RECOVERY MANAGER] Uncompleted bundle {last_prepared_bundle_id} has Safes with new wallet."
148
+ )
149
+ return self._load_bundle(
150
+ bundle_id=last_prepared_bundle_id, new_password=new_password
151
+ )
152
+
153
+ # Create new recovery bundle
154
+ bundle_id = f"{RECOVERY_BUNDLE_PREFIX}{str(uuid.uuid4())}"
155
+ new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
156
+ new_root.mkdir(parents=True, exist_ok=False)
157
+ UserAccount.new(new_password, new_root / USER_JSON)
158
+
159
+ new_wallets_path = new_root / WALLETS_DIR
160
+ new_wallet_manager = MasterWalletManager(
161
+ path=new_wallets_path, password=new_password
162
+ )
163
+ new_wallet_manager.setup()
164
+
165
+ for wallet in self.wallet_manager:
166
+ ledger_type = wallet.ledger_type
167
+ new_wallet, _ = new_wallet_manager.create(ledger_type=ledger_type)
168
+ self.logger.info(
169
+ f"[WALLET RECOVERY MANAGER] Created new wallet {ledger_type=} {new_wallet.address=}"
170
+ )
171
+
172
+ new_keys_manager = KeysManager(
173
+ path=new_root / KEYS_DIR, password=new_password, logger=self.logger
174
+ )
175
+
176
+ new_agent_keys = self.data.new_agent_keys
177
+ for service in self.service_manager.get_all_services()[0]:
178
+ service_config_id = service.service_config_id
179
+ new_agent_keys.setdefault(service_config_id, {})
180
+ for agent_address in service.agent_addresses:
181
+ new_agent_address = new_keys_manager.create()
182
+ new_agent_keys[service_config_id][agent_address] = new_agent_address
183
+
184
+ self.data.last_prepared_bundle_id = bundle_id
185
+ self.data.store()
186
+ self.logger.info(
187
+ "[WALLET RECOVERY MANAGER] Prepare recovery finished with new bundle."
188
+ )
189
+ return self._load_bundle(bundle_id=bundle_id, new_password=new_password)
190
+
191
+ def _load_bundle( # pylint: disable=too-many-locals
192
+ self, bundle_id: str, new_password: t.Optional[str] = None
193
+ ) -> t.Dict:
194
+ new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
195
+
196
+ new_user_account = UserAccount.load(new_root / USER_JSON)
197
+ if new_password is not None and not new_user_account.is_valid(
198
+ password=new_password
199
+ ):
200
+ raise ValueError(MSG_INVALID_PASSWORD)
201
+
202
+ new_wallets_path = new_root / WALLETS_DIR
203
+ new_wallet_manager = MasterWalletManager(
204
+ path=new_wallets_path, password=new_password
205
+ )
206
+
207
+ num_safes = 0
208
+ num_safes_with_new_wallet = 0
209
+ num_safes_with_old_wallet = 0
210
+ num_safes_with_both_wallets = 0
211
+ backup_owner_sets = set()
212
+
213
+ wallets = []
214
+ for wallet in self.wallet_manager:
215
+ new_wallet = next(
216
+ (w for w in new_wallet_manager if w.ledger_type == wallet.ledger_type)
217
+ )
218
+ new_mnemonic = None
219
+ if new_password:
220
+ new_mnemonic = new_wallet.decrypt_mnemonic(password=new_password)
221
+
222
+ wallet_json = wallet.json
223
+
224
+ for chain, safe in wallet.safes.items():
225
+ chain_str = chain.value
226
+ ledger_api = get_default_ledger_api(chain)
227
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
228
+ backup_owners = list(set(owners) - {wallet.address, new_wallet.address})
229
+ backup_owner_sets.add(frozenset(backup_owners))
230
+
231
+ num_safes += 1
232
+ if new_wallet.address in owners and wallet.address in owners:
233
+ num_safes_with_both_wallets += 1
234
+ if new_wallet.address in owners:
235
+ num_safes_with_new_wallet += 1
236
+ if wallet.address in owners:
237
+ num_safes_with_old_wallet += 1
238
+
239
+ wallet_json["safes"][chain_str] = {
240
+ safe: {
241
+ "owners": owners,
242
+ "backup_owners": backup_owners,
243
+ "owner_to_remove": wallet.address
244
+ if wallet.address in owners
245
+ else None,
246
+ "owner_to_add": new_wallet.address
247
+ if new_wallet.address not in owners
248
+ else None,
249
+ }
250
+ }
251
+
252
+ wallets.append(
253
+ {
254
+ "current_wallet": wallet_json,
255
+ "new_wallet": new_wallet.json,
256
+ "new_mnemonic": new_mnemonic,
257
+ }
258
+ )
259
+
260
+ if num_safes_with_new_wallet == 0:
261
+ status = WalletRecoveryStatus.PREPARED
262
+ elif num_safes_with_new_wallet < num_safes:
263
+ status = WalletRecoveryStatus.IN_PROGRESS
264
+ else:
265
+ status = WalletRecoveryStatus.COMPLETED
266
+
267
+ return {
268
+ "id": bundle_id,
269
+ "wallets": wallets,
270
+ "status": status,
271
+ "all_safes_have_backup_owner": all(
272
+ len(owners) >= 1 for owners in backup_owner_sets
273
+ ),
274
+ "consistent_backup_owner": len(backup_owner_sets) == 1,
275
+ "consistent_backup_owner_count": all(
276
+ len(owners) == 1 for owners in backup_owner_sets
277
+ ),
278
+ "prepared": bundle_id is not None,
279
+ "has_swaps": num_safes_with_new_wallet > 0,
280
+ "has_pending_swaps": num_safes_with_new_wallet < num_safes,
281
+ "num_safes": num_safes,
282
+ "num_safes_with_new_wallet": num_safes_with_new_wallet,
283
+ "num_safes_with_old_wallet": num_safes_with_old_wallet,
284
+ "num_safes_with_both_wallets": num_safes_with_both_wallets,
285
+ }
286
+
287
+ def recovery_requirements( # pylint: disable=too-many-locals
288
+ self,
289
+ ) -> t.Dict[str, t.Any]:
290
+ """Get recovery funding requirements for backup owners."""
291
+
292
+ bundle_id = self.data.last_prepared_bundle_id
293
+ if not bundle_id:
294
+ return {}
295
+
296
+ balances = ChainAmounts()
297
+ requirements = ChainAmounts()
298
+ pending_backup_owner_swaps: t.Dict = {}
299
+
300
+ new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
301
+ new_wallets_path = new_root / WALLETS_DIR
302
+ new_wallet_manager = MasterWalletManager(path=new_wallets_path, password=None)
303
+
304
+ for wallet in self.wallet_manager:
305
+ new_wallet = next(
306
+ (w for w in new_wallet_manager if w.ledger_type == wallet.ledger_type)
307
+ )
308
+ for chain, safe in wallet.safes.items():
309
+ chain_str = chain.value
310
+ balances.setdefault(chain_str, {})
311
+ requirements.setdefault(chain_str, {})
312
+
313
+ ledger_api = get_default_ledger_api(chain)
314
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
315
+ backup_owners = set(owners) - {wallet.address} - {new_wallet.address}
316
+
317
+ if len(backup_owners) != 1:
318
+ self.logger.warning(
319
+ f"[WALLET RECOVERY MANAGER] Safe {safe} on {chain.value} has unexpected number of backup owners: {len(backup_owners)}."
320
+ )
321
+
322
+ for backup_owner in backup_owners:
323
+ balances[chain_str].setdefault(backup_owner, {})
324
+ balances[chain_str][backup_owner][ZERO_ADDRESS] = get_asset_balance(
325
+ ledger_api=ledger_api,
326
+ asset_address=ZERO_ADDRESS,
327
+ address=backup_owner,
328
+ raise_on_invalid_address=False,
329
+ )
330
+ requirements[chain_str].setdefault(backup_owner, {}).setdefault(
331
+ ZERO_ADDRESS, 0
332
+ )
333
+ if new_wallet.address not in owners:
334
+ requirements[chain_str][backup_owner][
335
+ ZERO_ADDRESS
336
+ ] += DEFAULT_RECOVERY_TOPUPS[chain][ZERO_ADDRESS]
337
+ pending_backup_owner_swaps.setdefault(chain_str, [])
338
+ if safe not in pending_backup_owner_swaps[chain_str]:
339
+ pending_backup_owner_swaps[chain_str].append(safe)
340
+
341
+ refill_requirements = ChainAmounts.shortfalls(
342
+ requirements=requirements, balances=balances
343
+ )
344
+ is_refill_required = any(
345
+ amount > 0
346
+ for address in refill_requirements.values()
347
+ for assets in address.values()
348
+ for amount in assets.values()
349
+ )
350
+
351
+ return {
352
+ "balances": balances,
353
+ "total_requirements": requirements,
354
+ "refill_requirements": refill_requirements,
355
+ "is_refill_required": is_refill_required,
356
+ "pending_backup_owner_swaps": pending_backup_owner_swaps,
357
+ }
358
+
359
+ def status(self) -> t.Dict[str, t.Any]:
360
+ """Get recovery status."""
361
+ bundle_id = self.data.last_prepared_bundle_id
362
+ if bundle_id is None:
363
+ backup_owner_sets = set()
364
+ for wallet in self.wallet_manager:
365
+ for chain, safe in wallet.safes.items():
366
+ ledger_api = get_default_ledger_api(chain)
367
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
368
+ backup_owners = list(set(owners) - {wallet.address})
369
+ backup_owner_sets.add(frozenset(backup_owners))
370
+
371
+ return {
372
+ "id": None,
373
+ "wallets": [],
374
+ "status": WalletRecoveryStatus.NOT_PREPARED,
375
+ "all_safes_have_backup_owner": all(
376
+ len(owners) >= 1 for owners in backup_owner_sets
377
+ ),
378
+ "consistent_backup_owner": len(backup_owner_sets) == 1,
379
+ "consistent_backup_owner_count": all(
380
+ len(owners) == 1 for owners in backup_owner_sets
381
+ ),
382
+ "prepared": False,
383
+ "bundle_id": bundle_id,
384
+ "has_swaps": False,
385
+ "has_pending_swaps": False,
386
+ "num_safes": 0,
387
+ "num_safes_with_new_wallet": 0,
388
+ "num_safes_with_old_wallet": 0,
389
+ "num_safes_with_both_wallets": 0,
390
+ }
391
+
392
+ return self._load_bundle(bundle_id=bundle_id)
393
+
394
+ def complete_recovery( # pylint: disable=too-many-locals,too-many-statements
395
+ self, raise_if_inconsistent_owners: bool = True
396
+ ) -> None:
397
+ """Complete recovery"""
398
+ self.logger.info("[WALLET RECOVERY MANAGER] Complete recovery started.")
399
+
400
+ def _report_issue(msg: str) -> None:
401
+ self.logger.warning(f"[WALLET RECOVERY MANAGER] {msg}")
402
+ if raise_if_inconsistent_owners:
403
+ raise WalletRecoveryError(f"{msg}")
404
+
405
+ try:
406
+ _ = self.wallet_manager.password
407
+ except ValueError:
408
+ pass
409
+ else:
410
+ raise WalletRecoveryError(
411
+ "Wallet recovery cannot be executed while logged in."
412
+ )
413
+
414
+ bundle_id = self.data.last_prepared_bundle_id
415
+
416
+ if not bundle_id:
417
+ raise WalletRecoveryError("No prepared bundle found.")
418
+
419
+ root = self.path.parent # .operate root
420
+ wallets_path = root / WALLETS_DIR
421
+ new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
422
+ new_wallets_path = new_root / WALLETS_DIR
423
+ new_keys_path = new_root / KEYS_DIR
424
+ old_root = self.path / bundle_id / RECOVERY_OLD_OBJECTS_DIR
425
+
426
+ if not new_root.exists() or not new_root.is_dir():
427
+ raise RuntimeError(f"Recovery bundle {bundle_id} does not exist.")
428
+
429
+ if old_root.exists() and old_root.is_dir():
430
+ raise RuntimeError(
431
+ f"Recovery bundle {bundle_id} has been executed already."
432
+ )
433
+
434
+ new_wallet_manager = MasterWalletManager(path=new_wallets_path, password=None)
435
+
436
+ ledger_types = {item.ledger_type for item in self.wallet_manager}
437
+ new_ledger_types = {item.ledger_type for item in new_wallet_manager}
438
+
439
+ if ledger_types != new_ledger_types:
440
+ raise WalletRecoveryError(
441
+ f"Ledger type mismatch: {ledger_types=}, {new_ledger_types=}."
442
+ )
443
+
444
+ for wallet in self.wallet_manager:
445
+ new_wallet = next(
446
+ (w for w in new_wallet_manager if w.ledger_type == wallet.ledger_type)
447
+ )
448
+
449
+ all_backup_owners = set()
450
+ for chain, safe in wallet.safes.items():
451
+ ledger_api = get_default_ledger_api(chain)
452
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
453
+ if new_wallet.address not in owners:
454
+ raise WalletRecoveryError(
455
+ f"Incorrect owners. Wallet {new_wallet.address} is not an owner of Safe {safe} on {chain}."
456
+ )
457
+ if wallet.address in owners:
458
+ _report_issue(
459
+ f"Inconsistent owners. Current wallet {wallet.address} is still an owner of Safe {safe} on {chain}."
460
+ )
461
+ if len(owners) != 2:
462
+ _report_issue(
463
+ f"Inconsistent owners. Safe {safe} on {chain} has {len(owners)} != 2 owners."
464
+ )
465
+ all_backup_owners.update(set(owners) - {new_wallet.address})
466
+
467
+ if len(all_backup_owners) != 1:
468
+ _report_issue(
469
+ f"Inconsistent owners. Backup owners differ across Safes on chains {', '.join(chain.value for chain in wallet.safes.keys())}. "
470
+ f"Found backup owners: {', '.join(map(str, all_backup_owners))}."
471
+ )
472
+
473
+ new_wallet.safes = wallet.safes.copy()
474
+ new_wallet.safe_chains = wallet.safe_chains.copy()
475
+ new_wallet.safe_nonce = wallet.safe_nonce
476
+ new_wallet.store()
477
+
478
+ # Update configuration recovery
479
+ try:
480
+ old_root.mkdir(parents=True, exist_ok=False)
481
+ shutil.move(wallets_path, old_root)
482
+ for file in root.glob(f"{USER_JSON}*"):
483
+ shutil.move(file, old_root / file.name)
484
+
485
+ shutil.copytree(
486
+ new_wallets_path, root / new_wallets_path.name, dirs_exist_ok=True
487
+ )
488
+ for file in new_root.glob(f"{USER_JSON}*"):
489
+ shutil.copy2(file, root / file.name)
490
+ for file in new_keys_path.iterdir():
491
+ shutil.copy2(file, root / KEYS_DIR / file.name)
492
+
493
+ new_agent_keys = self.data.new_agent_keys
494
+ for service in self.service_manager.get_all_services()[0]:
495
+ service_config_id = service.service_config_id
496
+ service.agent_addresses = [
497
+ new_agent_keys[service_config_id][addr]
498
+ for addr in service.agent_addresses
499
+ ]
500
+ service.store()
501
+
502
+ self.data.last_prepared_bundle_id = None
503
+ self.data.store()
504
+ except Exception as e:
505
+ raise RuntimeError from e
506
+
507
+ self.logger.info("[WALLET RECOVERY MANAGER] Complete recovery finished.")