olas-operate-middleware 0.12.2__py3-none-any.whl → 0.13.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.
@@ -22,17 +22,30 @@
22
22
  import shutil
23
23
  import typing as t
24
24
  import uuid
25
+ from dataclasses import dataclass, field
25
26
  from logging import Logger
26
27
  from pathlib import Path
27
28
 
28
29
  from operate.account.user import UserAccount
29
- from operate.constants import MSG_INVALID_PASSWORD, USER_JSON, WALLETS_DIR
30
- from operate.utils.gnosis import get_owners
30
+ from operate.constants import (
31
+ KEYS_DIR,
32
+ MSG_INVALID_PASSWORD,
33
+ USER_JSON,
34
+ WALLETS_DIR,
35
+ ZERO_ADDRESS,
36
+ )
37
+ from operate.keys import KeysManager
38
+ from operate.ledger import get_default_ledger_api
39
+ from operate.ledger.profiles import DEFAULT_RECOVERY_TOPUPS
40
+ from operate.operate_types import ChainAmounts
41
+ from operate.resource import LocalResource
42
+ from operate.services.manage import ServiceManager
43
+ from operate.utils.gnosis import get_asset_balance, get_owners
31
44
  from operate.wallet.master import MasterWalletManager
32
45
 
33
46
 
34
47
  RECOVERY_BUNDLE_PREFIX = "eb-"
35
- RECOVERY_NEW_OBJECTS_DIR = "tmp"
48
+ RECOVERY_NEW_OBJECTS_DIR = "new"
36
49
  RECOVERY_OLD_OBJECTS_DIR = "old"
37
50
 
38
51
 
@@ -40,6 +53,18 @@ class WalletRecoveryError(Exception):
40
53
  """WalletRecoveryError"""
41
54
 
42
55
 
56
+ @dataclass
57
+ class WalletRecoveryManagerData(LocalResource):
58
+ """BridgeManagerData"""
59
+
60
+ path: Path
61
+ version: int = 1
62
+ last_prepared_bundle_id: t.Optional[str] = None
63
+ new_agent_keys: t.Dict[str, t.Dict[str, str]] = field(default_factory=dict)
64
+
65
+ _file = "wallet_recovery.json"
66
+
67
+
43
68
  class WalletRecoveryManager:
44
69
  """WalletRecoveryManager"""
45
70
 
@@ -48,15 +73,28 @@ class WalletRecoveryManager:
48
73
  path: Path,
49
74
  logger: Logger,
50
75
  wallet_manager: MasterWalletManager,
76
+ service_manager: ServiceManager,
51
77
  ) -> None:
52
78
  """Initialize wallet recovery manager."""
53
79
  self.path = path
54
80
  self.logger = logger
55
81
  self.wallet_manager = wallet_manager
82
+ self.service_manager = service_manager
83
+
84
+ path.mkdir(parents=True, exist_ok=True)
85
+ file = path / WalletRecoveryManagerData._file
86
+ if not file.exists():
87
+ WalletRecoveryManagerData(path=path).store()
88
+
89
+ self.data: WalletRecoveryManagerData = t.cast(
90
+ WalletRecoveryManagerData, WalletRecoveryManagerData.load(path)
91
+ )
56
92
 
57
- def initiate_recovery(self, new_password: str) -> t.Dict:
58
- """Recovery step 1"""
59
- self.logger.info("[WALLET RECOVERY MANAGER] Recovery step 1 start")
93
+ def prepare_recovery( # pylint: disable=too-many-locals
94
+ self, new_password: str
95
+ ) -> t.Dict:
96
+ """Prepare recovery"""
97
+ self.logger.info("[WALLET RECOVERY MANAGER] Prepare recovery started.")
60
98
 
61
99
  try:
62
100
  _ = self.wallet_manager.password
@@ -70,6 +108,39 @@ class WalletRecoveryManager:
70
108
  if not new_password:
71
109
  raise ValueError("'new_password' must be a non-empty string.")
72
110
 
111
+ for wallet in self.wallet_manager:
112
+ for chain, safe in wallet.safes.items():
113
+ ledger_api = get_default_ledger_api(chain)
114
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
115
+
116
+ if wallet.address not in owners:
117
+ self.logger.warning(
118
+ f"Wallet {wallet.address} is not an owner of Safe {safe} on {chain.value}. (Interrupted swapping of Safe owners?)"
119
+ )
120
+
121
+ backup_owners = set(owners) - {wallet.address}
122
+ if len(backup_owners) < 1:
123
+ raise WalletRecoveryError(
124
+ f"Safe {safe} on {chain.value} has less than 1 backup owner."
125
+ )
126
+
127
+ last_prepared_bundle_id = self.data.last_prepared_bundle_id
128
+ if last_prepared_bundle_id is not None:
129
+ (
130
+ _,
131
+ num_safes_with_new_wallet,
132
+ _,
133
+ _,
134
+ ) = self._get_swap_status(last_prepared_bundle_id)
135
+ if num_safes_with_new_wallet > 0:
136
+ self.logger.info(
137
+ f"[WALLET RECOVERY MANAGER] Uncompleted bundle {last_prepared_bundle_id} has Safes with new wallet."
138
+ )
139
+ return self._load_bundle(
140
+ bundle_id=last_prepared_bundle_id, new_password=new_password
141
+ )
142
+
143
+ # Create new recovery bundle
73
144
  bundle_id = f"{RECOVERY_BUNDLE_PREFIX}{str(uuid.uuid4())}"
74
145
  new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
75
146
  new_root.mkdir(parents=True, exist_ok=False)
@@ -81,35 +152,198 @@ class WalletRecoveryManager:
81
152
  )
82
153
  new_wallet_manager.setup()
83
154
 
84
- output = []
85
155
  for wallet in self.wallet_manager:
86
156
  ledger_type = wallet.ledger_type
87
- new_wallet, new_mnemonic = new_wallet_manager.create(
88
- ledger_type=ledger_type
89
- )
157
+ new_wallet, _ = new_wallet_manager.create(ledger_type=ledger_type)
90
158
  self.logger.info(
91
159
  f"[WALLET RECOVERY MANAGER] Created new wallet {ledger_type=} {new_wallet.address=}"
92
160
  )
93
- output.append(
161
+
162
+ new_keys_manager = KeysManager(
163
+ path=new_root / KEYS_DIR, password=new_password, logger=self.logger
164
+ )
165
+
166
+ new_agent_keys = self.data.new_agent_keys
167
+ for service in self.service_manager.get_all_services()[0]:
168
+ service_config_id = service.service_config_id
169
+ new_agent_keys.setdefault(service_config_id, {})
170
+ for agent_address in service.agent_addresses:
171
+ new_agent_address = new_keys_manager.create()
172
+ new_agent_keys[service_config_id][agent_address] = new_agent_address
173
+
174
+ self.data.last_prepared_bundle_id = bundle_id
175
+ self.data.store()
176
+ self.logger.info(
177
+ "[WALLET RECOVERY MANAGER] Prepare recovery finished with new bundle."
178
+ )
179
+ return self._load_bundle(bundle_id=bundle_id, new_password=new_password)
180
+
181
+ def _get_swap_status(self, bundle_id: str) -> t.Tuple[int, int, int, int]:
182
+ new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
183
+ new_wallets_path = new_root / WALLETS_DIR
184
+ new_wallet_manager = MasterWalletManager(path=new_wallets_path, password=None)
185
+
186
+ num_safes = 0
187
+ num_safes_with_new_wallet = 0
188
+ num_safes_with_old_wallet = 0
189
+ num_safes_with_both_wallets = 0
190
+
191
+ for wallet in self.wallet_manager:
192
+ new_wallet = next(
193
+ (w for w in new_wallet_manager if w.ledger_type == wallet.ledger_type)
194
+ )
195
+ for chain, safe in wallet.safes.items():
196
+ ledger_api = get_default_ledger_api(chain)
197
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
198
+
199
+ num_safes += 1
200
+ if new_wallet.address in owners and wallet.address in owners:
201
+ num_safes_with_both_wallets += 1
202
+ if new_wallet.address in owners:
203
+ num_safes_with_new_wallet += 1
204
+ if wallet.address in owners:
205
+ num_safes_with_old_wallet += 1
206
+
207
+ return (
208
+ num_safes,
209
+ num_safes_with_new_wallet,
210
+ num_safes_with_old_wallet,
211
+ num_safes_with_both_wallets,
212
+ )
213
+
214
+ def _load_bundle(self, bundle_id: str, new_password: str) -> t.Dict:
215
+ new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
216
+
217
+ new_user_account = UserAccount.load(new_root / USER_JSON)
218
+ if not new_user_account.is_valid(password=new_password):
219
+ raise ValueError(MSG_INVALID_PASSWORD)
220
+
221
+ new_wallets_path = new_root / WALLETS_DIR
222
+ new_wallet_manager = MasterWalletManager(
223
+ path=new_wallets_path, password=new_password
224
+ )
225
+
226
+ wallets = []
227
+ for wallet in self.wallet_manager:
228
+ ledger_type = wallet.ledger_type
229
+ new_wallet = new_wallet_manager.load(ledger_type=ledger_type)
230
+ new_mnemonic = None
231
+ if new_password:
232
+ new_mnemonic = new_wallet.decrypt_mnemonic(password=new_password)
233
+ wallets.append(
94
234
  {
95
235
  "current_wallet": wallet.json,
96
236
  "new_wallet": new_wallet.json,
97
237
  "new_mnemonic": new_mnemonic,
98
238
  }
99
239
  )
240
+ return {
241
+ "id": bundle_id,
242
+ "wallets": wallets,
243
+ }
100
244
 
101
- self.logger.info("[WALLET RECOVERY MANAGER] Recovery step 1 finish")
245
+ def recovery_requirements( # pylint: disable=too-many-locals
246
+ self,
247
+ ) -> t.Dict[str, t.Any]:
248
+ """Get recovery funding requirements for backup owners."""
249
+
250
+ bundle_id = self.data.last_prepared_bundle_id
251
+ if not bundle_id:
252
+ return {}
253
+
254
+ balances = ChainAmounts()
255
+ requirements = ChainAmounts()
256
+ pending_backup_owner_swaps: t.Dict = {}
257
+
258
+ new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
259
+ new_wallets_path = new_root / WALLETS_DIR
260
+ new_wallet_manager = MasterWalletManager(path=new_wallets_path, password=None)
261
+
262
+ for wallet in self.wallet_manager:
263
+ new_wallet = next(
264
+ (w for w in new_wallet_manager if w.ledger_type == wallet.ledger_type)
265
+ )
266
+ for chain, safe in wallet.safes.items():
267
+ chain_str = chain.value
268
+ balances.setdefault(chain_str, {})
269
+ requirements.setdefault(chain_str, {})
270
+
271
+ ledger_api = get_default_ledger_api(chain)
272
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
273
+ backup_owners = set(owners) - {wallet.address} - {new_wallet.address}
274
+
275
+ if len(backup_owners) != 1:
276
+ self.logger.warning(
277
+ f"[WALLET RECOVERY MANAGER] Safe {safe} on {chain.value} has unexpected number of backup owners: {len(backup_owners)}."
278
+ )
279
+
280
+ for backup_owner in backup_owners:
281
+ balances[chain_str].setdefault(backup_owner, {})
282
+ balances[chain_str][backup_owner][ZERO_ADDRESS] = get_asset_balance(
283
+ ledger_api=ledger_api,
284
+ asset_address=ZERO_ADDRESS,
285
+ address=backup_owner,
286
+ raise_on_invalid_address=False,
287
+ )
288
+ requirements[chain_str].setdefault(backup_owner, {}).setdefault(
289
+ ZERO_ADDRESS, 0
290
+ )
291
+ if new_wallet.address not in owners:
292
+ requirements[chain_str][backup_owner][
293
+ ZERO_ADDRESS
294
+ ] += DEFAULT_RECOVERY_TOPUPS[chain][ZERO_ADDRESS]
295
+ pending_backup_owner_swaps.setdefault(chain_str, [])
296
+ if safe not in pending_backup_owner_swaps[chain_str]:
297
+ pending_backup_owner_swaps[chain_str].append(safe)
298
+
299
+ refill_requirements = ChainAmounts.shortfalls(
300
+ requirements=requirements, balances=balances
301
+ )
302
+ is_refill_required = any(
303
+ amount > 0
304
+ for address in refill_requirements.values()
305
+ for assets in address.values()
306
+ for amount in assets.values()
307
+ )
102
308
 
103
309
  return {
104
- "id": bundle_id,
105
- "wallets": output,
310
+ "balances": balances,
311
+ "total_requirements": requirements,
312
+ "refill_requirements": refill_requirements,
313
+ "is_refill_required": is_refill_required,
314
+ "pending_backup_owner_swaps": pending_backup_owner_swaps,
315
+ }
316
+
317
+ def status(self) -> t.Dict[str, t.Any]:
318
+ """Get recovery status."""
319
+ bundle_id = self.data.last_prepared_bundle_id
320
+ if not bundle_id:
321
+ return {
322
+ "prepared": False,
323
+ "bundle_id": bundle_id,
324
+ "has_swaps": False,
325
+ "has_pending_swaps": False,
326
+ }
327
+
328
+ (
329
+ num_safes,
330
+ num_safes_with_new_wallet,
331
+ _,
332
+ _,
333
+ ) = self._get_swap_status(bundle_id)
334
+
335
+ return {
336
+ "prepared": bundle_id is not None,
337
+ "bundle_id": bundle_id,
338
+ "has_swaps": num_safes_with_new_wallet > 0,
339
+ "has_pending_swaps": num_safes_with_new_wallet < num_safes,
106
340
  }
107
341
 
108
342
  def complete_recovery( # pylint: disable=too-many-locals,too-many-statements
109
- self, bundle_id: str, password: str, raise_if_inconsistent_owners: bool = True
343
+ self, raise_if_inconsistent_owners: bool = True
110
344
  ) -> None:
111
- """Recovery step 2"""
112
- self.logger.info("[WALLET RECOVERY MANAGER] Recovery step 2 start")
345
+ """Complete recovery"""
346
+ self.logger.info("[WALLET RECOVERY MANAGER] Complete recovery started.")
113
347
 
114
348
  def _report_issue(msg: str) -> None:
115
349
  self.logger.warning(f"[WALLET RECOVERY MANAGER] {msg}")
@@ -125,31 +359,27 @@ class WalletRecoveryManager:
125
359
  "Wallet recovery cannot be executed while logged in."
126
360
  )
127
361
 
128
- if not password:
129
- raise ValueError("'password' must be a non-empty string.")
362
+ bundle_id = self.data.last_prepared_bundle_id
130
363
 
131
364
  if not bundle_id:
132
- raise ValueError("'bundle_id' must be a non-empty string.")
365
+ raise WalletRecoveryError("No prepared bundle found.")
133
366
 
134
367
  root = self.path.parent # .operate root
135
368
  wallets_path = root / WALLETS_DIR
136
369
  new_root = self.path / bundle_id / RECOVERY_NEW_OBJECTS_DIR
137
370
  new_wallets_path = new_root / WALLETS_DIR
371
+ new_keys_path = new_root / KEYS_DIR
138
372
  old_root = self.path / bundle_id / RECOVERY_OLD_OBJECTS_DIR
139
373
 
140
374
  if not new_root.exists() or not new_root.is_dir():
141
- raise KeyError(f"Recovery bundle {bundle_id} does not exist.")
375
+ raise RuntimeError(f"Recovery bundle {bundle_id} does not exist.")
142
376
 
143
377
  if old_root.exists() and old_root.is_dir():
144
- raise ValueError(f"Recovery bundle {bundle_id} has been executed already.")
145
-
146
- new_user_account = UserAccount.load(new_root / USER_JSON)
147
- if not new_user_account.is_valid(password=password):
148
- raise ValueError(MSG_INVALID_PASSWORD)
378
+ raise RuntimeError(
379
+ f"Recovery bundle {bundle_id} has been executed already."
380
+ )
149
381
 
150
- new_wallet_manager = MasterWalletManager(
151
- path=new_wallets_path, password=password
152
- )
382
+ new_wallet_manager = MasterWalletManager(path=new_wallets_path, password=None)
153
383
 
154
384
  ledger_types = {item.ledger_type for item in self.wallet_manager}
155
385
  new_ledger_types = {item.ledger_type for item in new_wallet_manager}
@@ -166,7 +396,7 @@ class WalletRecoveryManager:
166
396
 
167
397
  all_backup_owners = set()
168
398
  for chain, safe in wallet.safes.items():
169
- ledger_api = wallet.ledger_api(chain=chain)
399
+ ledger_api = get_default_ledger_api(chain)
170
400
  owners = get_owners(ledger_api=ledger_api, safe=safe)
171
401
  if new_wallet.address not in owners:
172
402
  raise WalletRecoveryError(
@@ -196,15 +426,30 @@ class WalletRecoveryManager:
196
426
  # Update configuration recovery
197
427
  try:
198
428
  old_root.mkdir(parents=True, exist_ok=False)
199
- shutil.move(str(wallets_path), str(old_root))
429
+ shutil.move(wallets_path, old_root)
200
430
  for file in root.glob(f"{USER_JSON}*"):
201
- shutil.move(str(file), str(old_root / file.name))
431
+ shutil.move(file, old_root / file.name)
202
432
 
203
- shutil.move(str(new_wallets_path), str(root))
433
+ shutil.copytree(
434
+ new_wallets_path, root / new_wallets_path.name, dirs_exist_ok=True
435
+ )
204
436
  for file in new_root.glob(f"{USER_JSON}*"):
205
- shutil.move(str(file), str(root / file.name))
206
-
437
+ shutil.copy2(file, root / file.name)
438
+ for file in new_keys_path.iterdir():
439
+ shutil.copy2(file, root / KEYS_DIR / file.name)
440
+
441
+ new_agent_keys = self.data.new_agent_keys
442
+ for service in self.service_manager.get_all_services()[0]:
443
+ service_config_id = service.service_config_id
444
+ service.agent_addresses = [
445
+ new_agent_keys[service_config_id][addr]
446
+ for addr in service.agent_addresses
447
+ ]
448
+ service.store()
449
+
450
+ self.data.last_prepared_bundle_id = None
451
+ self.data.store()
207
452
  except Exception as e:
208
453
  raise RuntimeError from e
209
454
 
210
- self.logger.info("[WALLET RECOVERY MANAGER] Recovery step 2 finish")
455
+ self.logger.info("[WALLET RECOVERY MANAGER] Complete recovery finished.")