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
operate/wallet/master.py CHANGED
@@ -20,39 +20,69 @@
20
20
  """Master key implementation"""
21
21
 
22
22
  import json
23
+ import os
23
24
  import typing as t
24
- from dataclasses import dataclass
25
+ from dataclasses import dataclass, field
25
26
  from pathlib import Path
26
27
 
27
28
  from aea.crypto.base import Crypto, LedgerApi
28
- from aea.crypto.registries import make_ledger_api
29
+ from aea.helpers.logging import setup_logger
29
30
  from aea_ledger_ethereum.ethereum import EthereumApi, EthereumCrypto
31
+ from autonomy.chain.base import registry_contracts
30
32
  from autonomy.chain.config import ChainType as ChainProfile
31
33
  from autonomy.chain.tx import TxSettler
32
- from web3 import Account
34
+ from web3 import Account, Web3
33
35
 
34
36
  from operate.constants import (
35
37
  ON_CHAIN_INTERACT_RETRIES,
36
38
  ON_CHAIN_INTERACT_SLEEP,
37
39
  ON_CHAIN_INTERACT_TIMEOUT,
40
+ ZERO_ADDRESS,
38
41
  )
39
- from operate.ledger import get_default_rpc
42
+ from operate.ledger import (
43
+ get_default_ledger_api,
44
+ make_chain_ledger_api,
45
+ update_tx_with_gas_estimate,
46
+ update_tx_with_gas_pricing,
47
+ )
48
+ from operate.ledger.profiles import DUST, ERC20_TOKENS, format_asset_amount
49
+ from operate.operate_types import Chain, EncryptedData, LedgerType
40
50
  from operate.resource import LocalResource
41
- from operate.types import ChainType, LedgerType
51
+ from operate.utils import create_backup
42
52
  from operate.utils.gnosis import add_owner
43
53
  from operate.utils.gnosis import create_safe as create_gnosis_safe
44
- from operate.utils.gnosis import get_owners, swap_owner
54
+ from operate.utils.gnosis import (
55
+ estimate_transfer_tx_fee,
56
+ get_asset_balance,
57
+ get_owners,
58
+ remove_owner,
59
+ swap_owner,
60
+ )
45
61
  from operate.utils.gnosis import transfer as transfer_from_safe
62
+ from operate.utils.gnosis import transfer_erc20_from_safe
63
+
64
+
65
+ logger = setup_logger(name="master_wallet")
66
+
67
+
68
+ # TODO Organize exceptions definition
69
+ class InsufficientFundsException(Exception):
70
+ """Insufficient funds exception."""
46
71
 
47
72
 
48
73
  class MasterWallet(LocalResource):
49
74
  """Master wallet."""
50
75
 
51
76
  path: Path
52
- safe: t.Optional[str] = None
77
+ address: str
78
+
79
+ safes: t.Dict[Chain, str] = field(default_factory=dict)
80
+ safe_chains: t.List[Chain] = field(default_factory=list)
53
81
  ledger_type: LedgerType
82
+ safe_nonce: t.Optional[int] = None
54
83
 
55
84
  _key: str
85
+ _mnemonic: str
56
86
  _crypto: t.Optional[Crypto] = None
57
87
  _password: t.Optional[str] = None
58
88
  _crypto_cls: t.Type[Crypto]
@@ -81,70 +111,130 @@ class MasterWallet(LocalResource):
81
111
  """Key path."""
82
112
  return self.path / self._key
83
113
 
114
+ @property
115
+ def mnemonic_path(self) -> Path:
116
+ """Mnemonic path."""
117
+ return self.path / self._mnemonic
118
+
119
+ @staticmethod
84
120
  def ledger_api(
85
- self,
86
- chain_type: ChainType,
121
+ chain: Chain,
87
122
  rpc: t.Optional[str] = None,
88
123
  ) -> LedgerApi:
89
124
  """Get ledger api object."""
90
- return make_ledger_api(
91
- self.ledger_type.name.lower(),
92
- address=(rpc or get_default_rpc(chain=chain_type)),
93
- chain_id=chain_type.id,
94
- )
125
+ if not rpc:
126
+ return get_default_ledger_api(chain=chain)
127
+ return make_chain_ledger_api(chain=chain, rpc=rpc)
95
128
 
96
- def transfer(
129
+ def transfer( # pylint: disable=too-many-arguments
97
130
  self,
98
131
  to: str,
99
132
  amount: int,
100
- chain_type: ChainType,
133
+ chain: Chain,
134
+ asset: str = ZERO_ADDRESS,
101
135
  from_safe: bool = True,
102
- ) -> None:
136
+ rpc: t.Optional[str] = None,
137
+ ) -> t.Optional[str]:
103
138
  """Transfer funds to the given account."""
104
139
  raise NotImplementedError()
105
140
 
106
- @staticmethod
107
- def new(password: str, path: Path) -> t.Tuple["MasterWallet", t.List[str]]:
108
- """Create a new master wallet."""
109
- raise NotImplementedError()
110
-
111
- def create_safe(
141
+ def transfer_from_safe_then_eoa(
112
142
  self,
113
- chain_type: ChainType,
114
- owner: t.Optional[str] = None,
143
+ to: str,
144
+ amount: int,
145
+ chain: Chain,
146
+ asset: str = ZERO_ADDRESS,
115
147
  rpc: t.Optional[str] = None,
116
- ) -> None:
117
- """Create safe."""
148
+ ) -> t.List[str]:
149
+ """Transfer assets to the given account using Safe balance first, and EOA balance for leftover."""
118
150
  raise NotImplementedError()
119
151
 
120
- def add_backup_owner(
152
+ def drain(
121
153
  self,
122
- chain_type: ChainType,
123
- owner: str,
154
+ withdrawal_address: str,
155
+ chain: Chain,
156
+ from_safe: bool = True,
124
157
  rpc: t.Optional[str] = None,
125
158
  ) -> None:
126
- """Create safe."""
159
+ """Drain all erc20/native assets to the given account."""
160
+ raise NotImplementedError()
161
+
162
+ @classmethod
163
+ def new(cls, password: str, path: Path) -> t.Tuple["MasterWallet", t.List[str]]:
164
+ """Create a new master wallet."""
165
+ raise NotImplementedError()
166
+
167
+ def decrypt_mnemonic(self, password: str) -> t.Optional[t.List[str]]:
168
+ """Retrieve the mnemonic"""
127
169
  raise NotImplementedError()
128
170
 
129
- def swap_backup_owner(
171
+ def create_safe(
130
172
  self,
131
- chain_type: ChainType,
132
- old_owner: str,
133
- new_owner: str,
173
+ chain: Chain,
174
+ backup_owner: t.Optional[str] = None,
134
175
  rpc: t.Optional[str] = None,
135
- ) -> None:
176
+ ) -> t.Optional[str]:
136
177
  """Create safe."""
137
178
  raise NotImplementedError()
138
179
 
139
- def add_or_swap_owner(
180
+ def update_backup_owner(
140
181
  self,
141
- chain_type: ChainType,
142
- owner: str,
182
+ chain: Chain,
183
+ backup_owner: t.Optional[str] = None,
143
184
  rpc: t.Optional[str] = None,
144
- ) -> None:
145
- """Add or swap backup owner."""
185
+ ) -> bool:
186
+ """Update backup owner."""
146
187
  raise NotImplementedError()
147
188
 
189
+ def is_password_valid(self, password: str) -> bool:
190
+ """Verifies if the provided password is valid."""
191
+ try:
192
+ self._crypto_cls(self.path / self._key, password)
193
+ return True
194
+ except Exception: # pylint: disable=broad-except
195
+ return False
196
+
197
+ def update_password(self, new_password: str) -> None:
198
+ """Update password."""
199
+ raise NotImplementedError()
200
+
201
+ def is_mnemonic_valid(self, mnemonic: str) -> bool:
202
+ """Is mnemonic valid."""
203
+ raise NotImplementedError()
204
+
205
+ def update_password_with_mnemonic(self, mnemonic: str, new_password: str) -> None:
206
+ """Updates password using the mnemonic."""
207
+ raise NotImplementedError()
208
+
209
+ def get_balance(
210
+ self, chain: Chain, asset: str = ZERO_ADDRESS, from_safe: bool = True
211
+ ) -> int:
212
+ """Get wallet balance on a given chain."""
213
+ if from_safe:
214
+ if chain not in self.safes:
215
+ raise ValueError(f"Wallet does not have a Safe on chain {chain}.")
216
+
217
+ address = self.safes[chain]
218
+ else:
219
+ address = self.address
220
+
221
+ return get_asset_balance(
222
+ ledger_api=get_default_ledger_api(chain),
223
+ asset_address=asset,
224
+ address=address,
225
+ )
226
+
227
+ # TODO move to resource.py if used in more resources similarly
228
+ @property
229
+ def extended_json(self) -> t.Dict:
230
+ """Get JSON representation with extended information (e.g., safe owners)."""
231
+ raise NotImplementedError
232
+
233
+ @classmethod
234
+ def migrate_format(cls, path: Path) -> bool:
235
+ """Migrate the JSON file format if needed."""
236
+ raise NotImplementedError
237
+
148
238
 
149
239
  @dataclass
150
240
  class EthereumMasterWallet(MasterWallet):
@@ -152,19 +242,70 @@ class EthereumMasterWallet(MasterWallet):
152
242
 
153
243
  path: Path
154
244
  address: str
155
- safe_chains: t.List[ChainType] # For cross-chain support
156
245
 
246
+ safes: t.Dict[Chain, str] = field(default_factory=dict)
247
+ safe_chains: t.List[Chain] = field(default_factory=list)
157
248
  ledger_type: LedgerType = LedgerType.ETHEREUM
158
- safe: t.Optional[str] = None
159
249
  safe_nonce: t.Optional[int] = None # For cross-chain reusability
160
250
 
161
251
  _file = ledger_type.config_file
162
252
  _key = ledger_type.key_file
253
+ _mnemonic = ledger_type.mnemonic_file
163
254
  _crypto_cls = EthereumCrypto
164
255
 
165
- def _transfer_from_eoa(self, to: str, amount: int, chain_type: ChainType) -> None:
256
+ def _pre_transfer_checks(
257
+ self,
258
+ to: str,
259
+ amount: int,
260
+ chain: Chain,
261
+ from_safe: bool,
262
+ asset: str = ZERO_ADDRESS,
263
+ ) -> str:
264
+ """Checks conditions before transfer. Returns the to address checksummed."""
265
+ if amount <= 0:
266
+ raise ValueError(
267
+ "Transfer amount must be greater than zero, not transferring."
268
+ )
269
+
270
+ to = Web3().to_checksum_address(to)
271
+ if from_safe and chain not in self.safes:
272
+ raise ValueError(f"Wallet does not have a Safe on chain {chain}.")
273
+
274
+ balance = self.get_balance(chain=chain, asset=asset, from_safe=from_safe)
275
+ if balance < amount:
276
+ source = "Master Safe" if from_safe else " Master EOA"
277
+ source_address = self.safes[chain] if from_safe else self.address
278
+ raise InsufficientFundsException(
279
+ f"Cannot transfer {format_asset_amount(chain, asset, amount)} from {source} {source_address} to {to} on chain {chain.name}. "
280
+ f"Balance: {format_asset_amount(chain, asset, balance)}. Missing: {format_asset_amount(chain, asset, amount - balance)}."
281
+ )
282
+
283
+ return to
284
+
285
+ def _transfer_from_eoa(
286
+ self, to: str, amount: int, chain: Chain, rpc: t.Optional[str] = None
287
+ ) -> t.Optional[str]:
166
288
  """Transfer funds from EOA wallet."""
167
- ledger_api = t.cast(EthereumApi, self.ledger_api(chain_type=chain_type))
289
+ balance = self.get_balance(chain=chain, from_safe=False)
290
+ tx_fee = estimate_transfer_tx_fee(
291
+ chain=chain, sender_address=self.address, to=to
292
+ )
293
+ if balance - tx_fee < amount <= balance:
294
+ # we assume that the user wants to drain the EOA
295
+ # we also account for dust here because withdraw call use some EOA balance to drain the safes first
296
+ amount = balance - tx_fee
297
+ if amount <= 0:
298
+ logger.warning(
299
+ f"Not enough balance to cover gas fees for transfer of {amount} on chain {chain} from EOA {self.address}. "
300
+ f"Balance is {balance}, estimated fee is {tx_fee}. Not transferring."
301
+ )
302
+ return None
303
+
304
+ to = self._pre_transfer_checks(
305
+ to=to, amount=amount, chain=chain, from_safe=False
306
+ )
307
+
308
+ ledger_api = t.cast(EthereumApi, self.ledger_api(chain=chain, rpc=rpc))
168
309
  tx_helper = TxSettler(
169
310
  ledger_api=ledger_api,
170
311
  crypto=self.crypto,
@@ -178,14 +319,20 @@ class EthereumMasterWallet(MasterWallet):
178
319
  *args: t.Any, **kwargs: t.Any
179
320
  ) -> t.Dict:
180
321
  """Build transaction"""
322
+ max_priority_fee_per_gas = os.getenv("MAX_PRIORITY_FEE_PER_GAS", None)
323
+ max_fee_per_gas = os.getenv("MAX_FEE_PER_GAS", None)
181
324
  tx = ledger_api.get_transfer_transaction(
182
325
  sender_address=self.crypto.address,
183
326
  destination_address=to,
184
327
  amount=amount,
185
328
  tx_fee=50000,
186
329
  tx_nonce="0x",
187
- chain_id=chain_type.id,
330
+ chain_id=chain.id,
188
331
  raise_on_try=True,
332
+ max_fee_per_gas=int(max_fee_per_gas) if max_fee_per_gas else None,
333
+ max_priority_fee_per_gas=(
334
+ int(max_priority_fee_per_gas) if max_priority_fee_per_gas else None
335
+ ),
189
336
  )
190
337
  return ledger_api.update_with_gas_estimate(
191
338
  transaction=tx,
@@ -193,48 +340,259 @@ class EthereumMasterWallet(MasterWallet):
193
340
  )
194
341
 
195
342
  setattr(tx_helper, "build", _build_tx) # noqa: B010
196
- tx_helper.transact(lambda x: x, "", kwargs={})
343
+ tx_receipt = tx_helper.transact(lambda x: x, "", kwargs={})
344
+ tx_hash = tx_receipt.get("transactionHash", "").hex()
345
+ return tx_hash
197
346
 
198
- def _transfer_from_safe(self, to: str, amount: int, chain_type: ChainType) -> None:
347
+ def _transfer_from_safe(
348
+ self, to: str, amount: int, chain: Chain, rpc: t.Optional[str] = None
349
+ ) -> t.Optional[str]:
199
350
  """Transfer funds from safe wallet."""
200
- transfer_from_safe(
201
- ledger_api=self.ledger_api(chain_type=chain_type),
351
+ to = self._pre_transfer_checks(
352
+ to=to, amount=amount, chain=chain, from_safe=True
353
+ )
354
+
355
+ return transfer_from_safe(
356
+ ledger_api=self.ledger_api(chain=chain, rpc=rpc),
202
357
  crypto=self.crypto,
203
- safe=t.cast(str, self.safe),
358
+ safe=self.safes[chain],
204
359
  to=to,
205
360
  amount=amount,
206
361
  )
207
362
 
208
- def transfer(
363
+ def _transfer_erc20_from_safe(
209
364
  self,
365
+ token: str,
210
366
  to: str,
211
367
  amount: int,
212
- chain_type: ChainType,
368
+ chain: Chain,
369
+ rpc: t.Optional[str] = None,
370
+ ) -> t.Optional[str]:
371
+ """Transfer erc20 from safe wallet."""
372
+ to = self._pre_transfer_checks(
373
+ to=to, amount=amount, chain=chain, from_safe=True, asset=token
374
+ )
375
+
376
+ return transfer_erc20_from_safe(
377
+ ledger_api=self.ledger_api(chain=chain, rpc=rpc),
378
+ crypto=self.crypto,
379
+ token=token,
380
+ safe=self.safes[chain],
381
+ to=to,
382
+ amount=amount,
383
+ )
384
+
385
+ def _transfer_erc20_from_eoa(
386
+ self,
387
+ token: str,
388
+ to: str,
389
+ amount: int,
390
+ chain: Chain,
391
+ rpc: t.Optional[str] = None,
392
+ ) -> t.Optional[str]:
393
+ """Transfer erc20 from EOA wallet."""
394
+ to = self._pre_transfer_checks(
395
+ to=to, amount=amount, chain=chain, from_safe=False, asset=token
396
+ )
397
+
398
+ wallet_address = self.address
399
+ ledger_api = t.cast(EthereumApi, self.ledger_api(chain=chain, rpc=rpc))
400
+ tx_settler = TxSettler(
401
+ ledger_api=ledger_api,
402
+ crypto=self.crypto,
403
+ chain_type=ChainProfile.CUSTOM,
404
+ timeout=ON_CHAIN_INTERACT_TIMEOUT,
405
+ retries=ON_CHAIN_INTERACT_RETRIES,
406
+ sleep=ON_CHAIN_INTERACT_SLEEP,
407
+ )
408
+
409
+ def _build_transfer_tx( # pylint: disable=unused-argument
410
+ *args: t.Any, **kargs: t.Any
411
+ ) -> t.Dict:
412
+ # TODO Backport to OpenAEA
413
+ instance = registry_contracts.erc20.get_instance(
414
+ ledger_api=ledger_api,
415
+ contract_address=token,
416
+ )
417
+ tx = instance.functions.transfer(to, amount).build_transaction(
418
+ {
419
+ "from": wallet_address,
420
+ "gas": 1,
421
+ "maxFeePerGas": 1,
422
+ "maxPriorityFeePerGas": 1,
423
+ "nonce": ledger_api.api.eth.get_transaction_count(wallet_address),
424
+ }
425
+ )
426
+ update_tx_with_gas_pricing(tx, ledger_api)
427
+ update_tx_with_gas_estimate(tx, ledger_api)
428
+ return tx
429
+
430
+ setattr(tx_settler, "build", _build_transfer_tx) # noqa: B010
431
+ tx_receipt = tx_settler.transact(
432
+ method=lambda: {},
433
+ contract="",
434
+ kwargs={},
435
+ dry_run=False,
436
+ )
437
+ tx_hash = tx_receipt.get("transactionHash", "").hex()
438
+ return tx_hash
439
+
440
+ def transfer( # pylint: disable=too-many-arguments
441
+ self,
442
+ to: str,
443
+ amount: int,
444
+ chain: Chain,
445
+ asset: str = ZERO_ADDRESS,
213
446
  from_safe: bool = True,
214
- ) -> None:
447
+ rpc: t.Optional[str] = None,
448
+ ) -> t.Optional[str]:
215
449
  """Transfer funds to the given account."""
216
450
  if from_safe:
217
- return self._transfer_from_safe(
451
+ if asset == ZERO_ADDRESS:
452
+ return self._transfer_from_safe(
453
+ to=to,
454
+ amount=amount,
455
+ chain=chain,
456
+ rpc=rpc,
457
+ )
458
+
459
+ return self._transfer_erc20_from_safe(
460
+ token=asset,
218
461
  to=to,
219
462
  amount=amount,
220
- chain_type=chain_type,
463
+ chain=chain,
464
+ rpc=rpc,
221
465
  )
222
- return self._transfer_from_eoa(
466
+
467
+ if asset == ZERO_ADDRESS:
468
+ return self._transfer_from_eoa(
469
+ to=to,
470
+ amount=amount,
471
+ chain=chain,
472
+ rpc=rpc,
473
+ )
474
+
475
+ return self._transfer_erc20_from_eoa(
476
+ token=asset,
223
477
  to=to,
224
478
  amount=amount,
225
- chain_type=chain_type,
479
+ chain=chain,
480
+ rpc=rpc,
226
481
  )
227
482
 
483
+ def transfer_from_safe_then_eoa(
484
+ self,
485
+ to: str,
486
+ amount: int,
487
+ chain: Chain,
488
+ asset: str = ZERO_ADDRESS,
489
+ rpc: t.Optional[str] = None,
490
+ ) -> t.List[str]:
491
+ """
492
+ Transfer assets to the given account using Safe balance first, and EOA balance for leftover.
493
+
494
+ If asset is a zero address, transfer native currency.
495
+ """
496
+ safe_balance = self.get_balance(chain=chain, asset=asset, from_safe=True)
497
+ eoa_balance = self.get_balance(chain=chain, asset=asset, from_safe=False)
498
+ balance = safe_balance + eoa_balance
499
+ if asset == ZERO_ADDRESS:
500
+ # to account for gas fees burned in previous txs
501
+ # in this case we will set the amount = eoa_balance below
502
+ balance += DUST[chain]
503
+
504
+ if balance < amount:
505
+ raise InsufficientFundsException(
506
+ f"Cannot transfer {format_asset_amount(chain, asset, amount)} to {to} on chain {chain.name}. "
507
+ f"Balance of Master Safe {self.safes[chain]}: {format_asset_amount(chain, asset, safe_balance)}. "
508
+ f"Balance of Master EOA {self.address}: {format_asset_amount(chain, asset, eoa_balance)}. "
509
+ f"Missing: {format_asset_amount(chain, asset, amount - balance)}."
510
+ )
511
+
512
+ tx_hashes = []
513
+ from_safe_amount = min(safe_balance, amount)
514
+ if from_safe_amount > 0:
515
+ tx_hash = self.transfer(
516
+ to=to,
517
+ amount=from_safe_amount,
518
+ chain=chain,
519
+ asset=asset,
520
+ from_safe=True,
521
+ rpc=rpc,
522
+ )
523
+ if tx_hash:
524
+ tx_hashes.append(tx_hash)
525
+ amount -= from_safe_amount
526
+
527
+ if amount > 0:
528
+ eoa_balance = self.get_balance(chain=chain, asset=asset, from_safe=False)
529
+ if (
530
+ asset == ZERO_ADDRESS
531
+ and eoa_balance <= amount <= eoa_balance + DUST[chain]
532
+ ):
533
+ # to make the internal function drain the EOA
534
+ amount = eoa_balance
535
+
536
+ tx_hash = self.transfer(
537
+ to=to, amount=amount, chain=chain, asset=asset, from_safe=False, rpc=rpc
538
+ )
539
+ if tx_hash:
540
+ tx_hashes.append(tx_hash)
541
+
542
+ return tx_hashes
543
+
544
+ def drain(
545
+ self,
546
+ withdrawal_address: str,
547
+ chain: Chain,
548
+ from_safe: bool = True,
549
+ rpc: t.Optional[str] = None,
550
+ ) -> None:
551
+ """Drain all erc20/native assets to the given account."""
552
+ assets = [token[chain] for token in ERC20_TOKENS.values()] + [ZERO_ADDRESS]
553
+ for asset in assets:
554
+ balance = self.get_balance(chain=chain, asset=asset, from_safe=from_safe)
555
+ if balance <= 0:
556
+ continue
557
+
558
+ self.transfer(
559
+ to=withdrawal_address,
560
+ amount=balance,
561
+ chain=chain,
562
+ asset=asset,
563
+ from_safe=from_safe,
564
+ rpc=rpc,
565
+ )
566
+
228
567
  @classmethod
229
568
  def new(
230
569
  cls, password: str, path: Path
231
570
  ) -> t.Tuple["EthereumMasterWallet", t.List[str]]:
232
571
  """Create a new master wallet."""
233
572
  # Backport support on aea
573
+
574
+ eoa_wallet_path = path / cls._key
575
+ eoa_mnemonic_path = path / cls._mnemonic
576
+
577
+ if eoa_wallet_path.exists():
578
+ raise FileExistsError(f"Wallet file already exists at {eoa_wallet_path}.")
579
+
580
+ if eoa_mnemonic_path.exists():
581
+ raise FileExistsError(
582
+ f"Mnemonic file already exists at {eoa_mnemonic_path}."
583
+ )
584
+
585
+ eoa_wallet_path.parent.mkdir(parents=True, exist_ok=True)
586
+
587
+ # Store private key (Ethereum V3 keystore JSON) and encrypted mnemonic
234
588
  account = Account()
235
589
  account.enable_unaudited_hdwallet_features()
236
590
  crypto, mnemonic = account.create_with_mnemonic()
237
- (path / cls._key).write_text(
591
+ encrypted_mnemonic = EncryptedData.new(
592
+ path=eoa_mnemonic_path, password=password, plaintext_bytes=mnemonic.encode()
593
+ )
594
+ encrypted_mnemonic.store()
595
+ eoa_wallet_path.write_text(
238
596
  data=json.dumps(
239
597
  Account.encrypt(
240
598
  private_key=crypto._private_key, # pylint: disable=protected-access
@@ -251,88 +609,281 @@ class EthereumMasterWallet(MasterWallet):
251
609
  wallet.password = password
252
610
  return wallet, mnemonic.split()
253
611
 
612
+ def decrypt_mnemonic(self, password: str) -> t.Optional[t.List[str]]:
613
+ """Retrieve the mnemonic"""
614
+ eoa_mnemonic_path = self.path / self.ledger_type.mnemonic_file
615
+
616
+ if not eoa_mnemonic_path.exists():
617
+ return None
618
+
619
+ encrypted_mnemonic = EncryptedData.load(eoa_mnemonic_path)
620
+ mnemonic = encrypted_mnemonic.decrypt(password).decode("utf-8")
621
+ return mnemonic.split()
622
+
623
+ def update_password(self, new_password: str) -> None:
624
+ """Updates password."""
625
+ create_backup(self.path / self._key)
626
+ self._crypto = None
627
+ (self.path / self._key).write_text(
628
+ data=json.dumps(
629
+ Account.encrypt(
630
+ private_key=self.crypto.private_key, # pylint: disable=protected-access
631
+ password=new_password,
632
+ ),
633
+ indent=2,
634
+ ),
635
+ encoding="utf-8",
636
+ )
637
+ self.password = new_password
638
+
639
+ def is_mnemonic_valid(self, mnemonic: str) -> bool:
640
+ """Verifies if the provided BIP-39 mnemonic is valid."""
641
+ try:
642
+ w3 = Web3()
643
+ w3.eth.account.enable_unaudited_hdwallet_features()
644
+ new_account = w3.eth.account.from_mnemonic(mnemonic)
645
+ keystore_data = json.loads(
646
+ Path(self.path / self._key).read_text(encoding="utf-8")
647
+ )
648
+ stored_address = keystore_data["address"].removeprefix("0x").lower()
649
+ return stored_address == new_account.address.removeprefix("0x").lower()
650
+ except Exception: # pylint: disable=broad-except
651
+ return False
652
+
653
+ def update_password_with_mnemonic(self, mnemonic: str, new_password: str) -> None:
654
+ """Updates password using the mnemonic."""
655
+ if not self.is_mnemonic_valid(mnemonic):
656
+ raise ValueError("The provided mnemonic is not valid")
657
+
658
+ path = self.path / EthereumMasterWallet._key
659
+ create_backup(path)
660
+
661
+ w3 = Web3()
662
+ w3.eth.account.enable_unaudited_hdwallet_features()
663
+ crypto = Web3().eth.account.from_mnemonic(mnemonic)
664
+ (path).write_text(
665
+ data=json.dumps(
666
+ Account.encrypt(
667
+ private_key=crypto._private_key, # pylint: disable=protected-access
668
+ password=new_password,
669
+ ),
670
+ indent=2,
671
+ ),
672
+ encoding="utf-8",
673
+ )
674
+ self.password = new_password
675
+
254
676
  def create_safe(
255
677
  self,
256
- chain_type: ChainType,
257
- owner: t.Optional[str] = None,
678
+ chain: Chain,
679
+ backup_owner: t.Optional[str] = None,
258
680
  rpc: t.Optional[str] = None,
259
- ) -> None:
681
+ ) -> t.Optional[str]:
260
682
  """Create safe."""
261
- if chain_type in self.safe_chains:
262
- return
263
- self.safe, self.safe_nonce = create_gnosis_safe(
264
- ledger_api=self.ledger_api(chain_type=chain_type, rpc=rpc),
683
+ if chain in self.safes:
684
+ raise ValueError(f"Wallet already has a Safe on chain {chain}.")
685
+
686
+ safe, self.safe_nonce, tx_hash = create_gnosis_safe(
687
+ ledger_api=self.ledger_api(chain=chain, rpc=rpc),
265
688
  crypto=self.crypto,
266
- owner=owner,
689
+ backup_owner=backup_owner,
267
690
  salt_nonce=self.safe_nonce,
268
691
  )
269
- self.safe_chains.append(chain_type)
692
+ self.safe_chains.append(chain)
693
+ if self.safes is None:
694
+ self.safes = {}
695
+ self.safes[chain] = safe
270
696
  self.store()
697
+ return tx_hash
271
698
 
272
- def add_backup_owner(
699
+ def update_backup_owner(
273
700
  self,
274
- chain_type: ChainType,
275
- owner: str,
701
+ chain: Chain,
702
+ backup_owner: t.Optional[str] = None,
276
703
  rpc: t.Optional[str] = None,
277
- ) -> None:
278
- """Add a backup owner."""
279
- ledger_api = self.ledger_api(chain_type=chain_type, rpc=rpc)
280
- if len(get_owners(ledger_api=ledger_api, safe=t.cast(str, self.safe))) == 2:
281
- raise ValueError("Backup owner already exist!")
282
- add_owner(
283
- ledger_api=ledger_api,
284
- safe=t.cast(str, self.safe),
285
- owner=owner,
286
- crypto=self.crypto,
287
- )
704
+ ) -> bool:
705
+ """Adds a backup owner if not present, or updates it by the provided backup owner. Setting a None backup owner will remove the current one, if any."""
706
+ ledger_api = self.ledger_api(chain=chain, rpc=rpc)
707
+ if chain not in self.safes:
708
+ raise ValueError(f"Wallet does not have a Safe on chain {chain}.")
709
+ safe = t.cast(str, self.safes[chain])
710
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
711
+
712
+ if len(owners) > 2:
713
+ raise RuntimeError(
714
+ f"Safe {safe} on chain {chain} has more than 2 owners: {owners}."
715
+ )
288
716
 
289
- def swap_backup_owner(
290
- self,
291
- chain_type: ChainType,
292
- old_owner: str,
293
- new_owner: str,
294
- rpc: t.Optional[str] = None,
295
- ) -> None:
296
- """Swap backup owner."""
297
- ledger_api = self.ledger_api(chain_type=chain_type, rpc=rpc)
298
- if len(get_owners(ledger_api=ledger_api, safe=t.cast(str, self.safe))) == 1:
299
- raise ValueError("Backup owner does not exist, cannot swap!")
300
- swap_owner(
301
- ledger_api=ledger_api,
302
- safe=t.cast(str, self.safe),
303
- old_owner=old_owner,
304
- new_owner=new_owner,
305
- crypto=self.crypto,
306
- )
717
+ if backup_owner == safe:
718
+ raise ValueError("The Safe address cannot be set as the Safe backup owner.")
307
719
 
308
- def add_or_swap_owner(
309
- self,
310
- chain_type: ChainType,
311
- owner: str,
312
- rpc: t.Optional[str] = None,
313
- ) -> None:
314
- """Add or swap backup owner."""
315
- ledger_api = self.ledger_api(chain_type=chain_type, rpc=rpc)
316
- owners = get_owners(ledger_api=ledger_api, safe=t.cast(str, self.safe))
317
- if len(owners) == 1:
318
- return self.add_backup_owner(chain_type=chain_type, owner=owner, rpc=rpc)
720
+ if backup_owner == self.address:
721
+ raise ValueError(
722
+ "The master wallet cannot be set as the Safe backup owner."
723
+ )
724
+
725
+ if self.address not in owners:
726
+ return False
319
727
 
320
728
  owners.remove(self.address)
321
- (old_owner,) = owners
322
- if old_owner == owner:
323
- return None
729
+ old_backup_owner = owners[0] if owners else None
324
730
 
325
- return self.swap_backup_owner(
326
- chain_type=chain_type,
327
- old_owner=old_owner,
328
- new_owner=owner,
329
- rpc=rpc,
731
+ if old_backup_owner == backup_owner:
732
+ return False
733
+
734
+ if not old_backup_owner and backup_owner:
735
+ add_owner(
736
+ ledger_api=ledger_api,
737
+ safe=safe,
738
+ owner=backup_owner,
739
+ crypto=self.crypto,
740
+ )
741
+ return True
742
+ if old_backup_owner and not backup_owner:
743
+ remove_owner(
744
+ ledger_api=ledger_api,
745
+ safe=safe,
746
+ owner=old_backup_owner,
747
+ crypto=self.crypto,
748
+ threshold=1,
749
+ )
750
+ return True
751
+ if old_backup_owner and backup_owner:
752
+ swap_owner(
753
+ ledger_api=ledger_api,
754
+ safe=safe,
755
+ old_owner=old_backup_owner,
756
+ new_owner=backup_owner,
757
+ crypto=self.crypto,
758
+ )
759
+ return True
760
+
761
+ return False
762
+
763
+ @property
764
+ def extended_json(self) -> t.Dict:
765
+ """Get JSON representation with extended information (e.g., safe owners)."""
766
+ rpc = None
767
+ wallet_json = self.json
768
+
769
+ balances: t.Dict[str, t.Dict[str, t.Dict[str, int]]] = {}
770
+ owner_sets = set()
771
+ for chain, safe in self.safes.items():
772
+ chain_str = chain.value
773
+ ledger_api = self.ledger_api(chain=chain, rpc=rpc)
774
+ owners = get_owners(ledger_api=ledger_api, safe=safe)
775
+
776
+ if self.address in owners:
777
+ owners.remove(self.address)
778
+
779
+ balances[chain_str] = {self.address: {}, safe: {}}
780
+
781
+ assets = [token[chain] for token in ERC20_TOKENS.values()] + [ZERO_ADDRESS]
782
+ for asset in assets:
783
+ balances[chain_str][self.address][asset] = self.get_balance(
784
+ chain=chain, asset=asset, from_safe=False
785
+ )
786
+ balances[chain_str][safe][asset] = self.get_balance(
787
+ chain=chain, asset=asset, from_safe=True
788
+ )
789
+ wallet_json["safes"][chain_str] = {
790
+ safe: {
791
+ "backup_owners": owners,
792
+ "balances": balances[chain_str][safe],
793
+ }
794
+ }
795
+ owner_sets.add(frozenset(owners))
796
+
797
+ wallet_json["balances"] = balances
798
+ wallet_json["extended_json"] = True
799
+ wallet_json["all_safes_have_backup_owner"] = all(
800
+ len(owners) > 0 for owners in owner_sets
801
+ )
802
+ wallet_json["consistent_safe_address"] = len(set(self.safes.values())) == 1
803
+ wallet_json["consistent_backup_owner"] = len(owner_sets) == 1
804
+ wallet_json["consistent_backup_owner_count"] = all(
805
+ len(owner) == 1 for owner in owner_sets
330
806
  )
807
+ return wallet_json
331
808
 
332
809
  @classmethod
333
810
  def load(cls, path: Path) -> "EthereumMasterWallet":
334
811
  """Load master wallet."""
335
- return super().load(path) # type: ignore
812
+ # TODO: This is a complex way to read the 'safes' dictionary.
813
+ # The reason for that is that wallet.safes[chain] would fail
814
+ # (for example in service manager) when passed a ChainType key.
815
+
816
+ raw_ethereum_wallet = t.cast(EthereumMasterWallet, super().load(path)) # type: ignore
817
+ safes = {}
818
+ for chain, safe_address in raw_ethereum_wallet.safes.items():
819
+ safes[Chain(chain)] = safe_address
820
+
821
+ raw_ethereum_wallet.safes = safes
822
+ return raw_ethereum_wallet
823
+
824
+ @classmethod
825
+ def migrate_format(cls, path: Path) -> bool:
826
+ """Migrate the JSON file format if needed."""
827
+ wallet_path = path / cls._file
828
+ with open(wallet_path, "r", encoding="utf-8") as file:
829
+ data = json.load(file)
830
+
831
+ migrated = False
832
+ if "safes" not in data:
833
+ safes = {}
834
+ for chain in data["safe_chains"]:
835
+ safes[chain] = data["safe"]
836
+ data.pop("safe")
837
+ data["safes"] = safes
838
+ migrated = True
839
+
840
+ old_to_new_chains = [
841
+ "ethereum",
842
+ "goerli",
843
+ "gnosis",
844
+ "solana",
845
+ "optimism",
846
+ "base",
847
+ "mode",
848
+ ]
849
+ safe_chains = []
850
+ for chain in data["safe_chains"]:
851
+ if isinstance(chain, int):
852
+ safe_chains.append(old_to_new_chains[chain])
853
+ migrated = True
854
+ else:
855
+ safe_chains.append(chain)
856
+ data["safe_chains"] = safe_chains
857
+
858
+ if isinstance(data["ledger_type"], int):
859
+ old_to_new_ledgers = [ledger_type.value for ledger_type in LedgerType]
860
+ data["ledger_type"] = old_to_new_ledgers[data["ledger_type"]]
861
+ migrated = True
862
+
863
+ safes = {}
864
+ for chain, address in data["safes"].items():
865
+ if str(chain).isnumeric():
866
+ safes[old_to_new_chains[int(chain)]] = address
867
+ migrated = True
868
+ else:
869
+ safes[chain] = address
870
+ data["safes"] = safes
871
+
872
+ if "optimistic" in data.get("safes", {}):
873
+ data["safes"]["optimism"] = data["safes"].pop("optimistic")
874
+ migrated = True
875
+
876
+ if "optimistic" in data.get("safe_chains"):
877
+ data["safe_chains"] = [
878
+ "optimism" if chain == "optimistic" else chain
879
+ for chain in data["safe_chains"]
880
+ ]
881
+ migrated = True
882
+
883
+ with open(wallet_path, "w", encoding="utf-8") as file:
884
+ json.dump(data, file, indent=2)
885
+
886
+ return migrated
336
887
 
337
888
 
338
889
  LEDGER_TYPE_TO_WALLET_CLASS = {
@@ -343,7 +894,11 @@ LEDGER_TYPE_TO_WALLET_CLASS = {
343
894
  class MasterWalletManager:
344
895
  """Master wallet manager."""
345
896
 
346
- def __init__(self, path: Path, password: t.Optional[str] = None) -> None:
897
+ def __init__(
898
+ self,
899
+ path: Path,
900
+ password: t.Optional[str] = None,
901
+ ) -> None:
347
902
  """Initialize master wallet manager."""
348
903
  self.path = path
349
904
  self._password = password
@@ -354,14 +909,14 @@ class MasterWalletManager:
354
909
  return [wallet.json for wallet in self]
355
910
 
356
911
  @property
357
- def password(self) -> str:
912
+ def password(self) -> t.Optional[str]:
358
913
  """Password string."""
359
914
  if self._password is None:
360
915
  raise ValueError("Password not set.")
361
916
  return self._password
362
917
 
363
918
  @password.setter
364
- def password(self, value: str) -> None:
919
+ def password(self, value: t.Optional[str]) -> None:
365
920
  """Set password value."""
366
921
  self._password = value
367
922
 
@@ -406,6 +961,36 @@ class MasterWalletManager:
406
961
  wallet.password = self.password
407
962
  return wallet
408
963
 
964
+ def is_password_valid(self, password: str) -> bool:
965
+ """Verifies if the provided password is valid."""
966
+ for wallet in self:
967
+ if not wallet.is_password_valid(password):
968
+ return False
969
+
970
+ return True
971
+
972
+ def update_password(self, new_password: str) -> None:
973
+ """Updates password of manager and wallets."""
974
+ for wallet in self:
975
+ wallet.password = self.password
976
+ wallet.update_password(new_password)
977
+
978
+ self.password = new_password
979
+
980
+ def is_mnemonic_valid(self, mnemonic: str) -> bool:
981
+ """Verifies if the provided BIP-39 mnemonic is valid."""
982
+ for wallet in self:
983
+ if not wallet.is_mnemonic_valid(mnemonic):
984
+ return False
985
+ return True
986
+
987
+ def update_password_with_mnemonic(self, mnemonic: str, new_password: str) -> None:
988
+ """Updates password using the mnemonic."""
989
+ for wallet in self:
990
+ wallet.update_password_with_mnemonic(mnemonic, new_password)
991
+
992
+ self.password = new_password
993
+
409
994
  def __iter__(self) -> t.Iterator[MasterWallet]:
410
995
  """Iterate over master wallets."""
411
996
  for ledger_type in LedgerType: